Merge branch 'iSoron:dev' into feature/remove-jvm-dateutils

pull/1120/head
Sebastian Gallese 4 years ago committed by GitHub
commit 156332cf80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,7 +5,7 @@ plugins {
id("org.jetbrains.kotlin.kapt") version kotlinVersion apply (false) id("org.jetbrains.kotlin.kapt") version kotlinVersion apply (false)
id("org.jetbrains.kotlin.android.extensions") version kotlinVersion apply (false) id("org.jetbrains.kotlin.android.extensions") version kotlinVersion apply (false)
id("org.jetbrains.kotlin.multiplatform") version kotlinVersion apply (false) id("org.jetbrains.kotlin.multiplatform") version kotlinVersion apply (false)
id("org.jlleitschuh.gradle.ktlint") version "10.1.0" id("org.jlleitschuh.gradle.ktlint") version "10.2.0"
} }
apply { apply {

@ -86,10 +86,10 @@ android {
} }
dependencies { dependencies {
val daggerVersion = "2.38.1" val daggerVersion = "2.39"
val kotlinVersion = "1.5.30" val kotlinVersion = "1.5.31"
val kxCoroutinesVersion = "1.5.1" val kxCoroutinesVersion = "1.5.2"
val ktorVersion = "1.6.3" val ktorVersion = "1.6.4"
val espressoVersion = "3.4.0" val espressoVersion = "3.4.0"
androidTestImplementation("androidx.test.espresso:espresso-contrib:$espressoVersion") androidTestImplementation("androidx.test.espresso:espresso-contrib:$espressoVersion")
@ -108,7 +108,7 @@ dependencies {
implementation("com.github.AppIntro:AppIntro:6.1.0") implementation("com.github.AppIntro:AppIntro:6.1.0")
implementation("com.google.code.findbugs:jsr305:3.0.2") implementation("com.google.code.findbugs:jsr305:3.0.2")
implementation("com.google.dagger:dagger:$daggerVersion") implementation("com.google.dagger:dagger:$daggerVersion")
implementation("com.google.guava:guava:30.1.1-android") implementation("com.google.guava:guava:31.0.1-android")
implementation("io.ktor:ktor-client-android:$ktorVersion") implementation("io.ktor:ktor-client-android:$ktorVersion")
implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-jackson:$ktorVersion") implementation("io.ktor:ktor-client-jackson:$ktorVersion")
@ -120,7 +120,7 @@ dependencies {
implementation("androidx.legacy:legacy-preference-v14:1.0.0") implementation("androidx.legacy:legacy-preference-v14:1.0.0")
implementation("androidx.legacy:legacy-support-v4:1.0.0") implementation("androidx.legacy:legacy-support-v4:1.0.0")
implementation("com.google.android.material:material:1.4.0") implementation("com.google.android.material:material:1.4.0")
implementation("com.opencsv:opencsv:5.5.1") implementation("com.opencsv:opencsv:5.5.2")
implementation(project(":uhabits-core")) implementation(project(":uhabits-core"))
kapt("com.google.dagger:dagger-compiler:$daggerVersion") kapt("com.google.dagger:dagger-compiler:$daggerVersion")
kaptAndroidTest("com.google.dagger:dagger-compiler:$daggerVersion") kaptAndroidTest("com.google.dagger:dagger-compiler:$daggerVersion")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

@ -55,6 +55,7 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@LargeTest @LargeTest
class HabitsTest : BaseUserInterfaceTest() { class HabitsTest : BaseUserInterfaceTest() {
@Test @Test
@Throws(Exception::class) @Throws(Exception::class)
fun shouldCreateHabit() { fun shouldCreateHabit() {
@ -180,6 +181,8 @@ class HabitsTest : BaseUserInterfaceTest() {
longPressCheckmarks("Wake up early", count = 2) longPressCheckmarks("Wake up early", count = 2)
clickText("Wake up early") clickText("Wake up early")
verifyShowsScreen(SHOW_HABIT) verifyShowsScreen(SHOW_HABIT)
// TODO: find a better way than sleeping in tests
Thread.sleep(2001L)
verifyDisplaysText("10%") verifyDisplaysText("10%")
} }
@ -194,6 +197,8 @@ class HabitsTest : BaseUserInterfaceTest() {
verifyDoesNotDisplayText("Track time") verifyDoesNotDisplayText("Track time")
verifyDisplaysText("Wake up early") verifyDisplaysText("Wake up early")
longPressCheckmarks("Wake up early", count = 1) longPressCheckmarks("Wake up early", count = 1)
// TODO: find a better way than sleeping in tests
Thread.sleep(2001L)
verifyDoesNotDisplayText("Wake up early") verifyDoesNotDisplayText("Wake up early")
clickMenu(TOGGLE_COMPLETED) clickMenu(TOGGLE_COMPLETED)
verifyDisplaysText("Track time") verifyDisplaysText("Track time")

@ -24,6 +24,7 @@ import androidx.test.filters.MediumTest
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.isoron.uhabits.BaseViewTest import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.core.models.NumericalHabitType
import org.isoron.uhabits.utils.PaletteUtils import org.isoron.uhabits.utils.PaletteUtils
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -42,6 +43,7 @@ class NumberButtonViewTest : BaseViewTest() {
super.setUp() super.setUp()
view = component.getNumberButtonViewFactory().create().apply { view = component.getNumberButtonViewFactory().create().apply {
units = "steps" units = "steps"
targetType = NumericalHabitType.AT_LEAST
threshold = 100.0 threshold = 100.0
color = PaletteUtils.getAndroidTestColor(8) color = PaletteUtils.getAndroidTestColor(8)
onEdit = { edited = true } onEdit = { edited = true }
@ -74,10 +76,10 @@ class NumberButtonViewTest : BaseViewTest() {
} }
@Test @Test
fun testRender_emptyUnits() { fun testRender_atMostAboveThreshold() {
view.value = 500.0 view.value = 500.0
view.units = "" view.targetType = NumericalHabitType.AT_MOST
assertRenders(view, "$PATH/render_unitless.png") assertRenders(view, "$PATH/render_at_most_above.png")
} }
@Test @Test
@ -86,12 +88,33 @@ class NumberButtonViewTest : BaseViewTest() {
assertRenders(view, "$PATH/render_below.png") 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 @Test
fun testRender_zero() { fun testRender_zero() {
view.value = 0.0 view.value = 0.0
assertRenders(view, "$PATH/render_zero.png") 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 @Test
fun testClick_shortToggleDisabled() { fun testClick_shortToggleDisabled() {
prefs.isShortToggleEnabled = false prefs.isShortToggleEnabled = false

@ -24,6 +24,7 @@ import androidx.test.filters.MediumTest
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.isoron.uhabits.BaseViewTest import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.core.models.NumericalHabitType
import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.utils.PaletteUtils import org.isoron.uhabits.utils.PaletteUtils
import org.junit.After import org.junit.After
@ -55,6 +56,7 @@ class NumberPanelViewTest : BaseViewTest() {
buttonCount = 4 buttonCount = 4
color = PaletteUtils.getAndroidTestColor(7) color = PaletteUtils.getAndroidTestColor(7)
units = "steps" units = "steps"
targetType = NumericalHabitType.AT_LEAST
threshold = 5000.0 threshold = 5000.0
} }
view.onAttachedToWindow() view.onAttachedToWindow()

@ -53,8 +53,6 @@ class SubtitleCardViewTest : BaseViewTest() {
isNumerical = false, isNumerical = false,
question = "Did you meditate this morning?", question = "Did you meditate this morning?",
reminder = Reminder(8, 30, EVERY_DAY), reminder = Reminder(8, 30, EVERY_DAY),
unit = "",
targetValue = 0.0,
theme = LightTheme(), theme = LightTheme(),
) )
) )

@ -24,8 +24,8 @@ import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.isoron.uhabits.BaseViewTest import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.R import org.isoron.uhabits.R

@ -62,6 +62,7 @@ class HistoryEditorDialog : AppCompatDialogFragment(), CommandRunner.Listener {
firstWeekday = preferences.firstWeekday, firstWeekday = preferences.firstWeekday,
paletteColor = habit.color, paletteColor = habit.color,
series = emptyList(), series = emptyList(),
defaultSquare = HistoryChart.Square.OFF,
theme = themeSwitcher.currentTheme, theme = themeSwitcher.currentTheme,
today = DateUtils.getTodayWithOffset().toLocalDate(), today = DateUtils.getTodayWithOffset().toLocalDate(),
onDateClickedListener = onDateClickedListener ?: OnDateClickedListener { }, onDateClickedListener = onDateClickedListener ?: OnDateClickedListener { },
@ -101,6 +102,7 @@ class HistoryEditorDialog : AppCompatDialogFragment(), CommandRunner.Listener {
theme = LightTheme() theme = LightTheme()
) )
chart?.series = model.series chart?.series = model.series
chart?.defaultSquare = model.defaultSquare
dataView.postInvalidate() dataView.postInvalidate()
} }

@ -88,6 +88,7 @@ class EditHabitActivity : AppCompatActivity() {
var reminderHour = -1 var reminderHour = -1
var reminderMin = -1 var reminderMin = -1
var reminderDays: WeekdayList = WeekdayList.EVERY_DAY var reminderDays: WeekdayList = WeekdayList.EVERY_DAY
var targetType = NumericalHabitType.AT_LEAST
override fun onCreate(state: Bundle?) { override fun onCreate(state: Bundle?) {
super.onCreate(state) super.onCreate(state)
@ -107,6 +108,7 @@ class EditHabitActivity : AppCompatActivity() {
color = habit.color color = habit.color
freqNum = habit.frequency.numerator freqNum = habit.frequency.numerator
freqDen = habit.frequency.denominator freqDen = habit.frequency.denominator
targetType = habit.targetType
habit.reminder?.let { habit.reminder?.let {
reminderHour = it.hour reminderHour = it.hour
reminderMin = it.minute reminderMin = it.minute
@ -138,6 +140,7 @@ class EditHabitActivity : AppCompatActivity() {
HabitType.YES_NO -> { HabitType.YES_NO -> {
binding.unitOuterBox.visibility = View.GONE binding.unitOuterBox.visibility = View.GONE
binding.targetOuterBox.visibility = View.GONE binding.targetOuterBox.visibility = View.GONE
binding.targetTypeOuterBox.visibility = View.GONE
} }
HabitType.NUMERICAL -> { HabitType.NUMERICAL -> {
binding.nameInput.hint = getString(R.string.measurable_short_example) binding.nameInput.hint = getString(R.string.measurable_short_example)
@ -172,6 +175,23 @@ class EditHabitActivity : AppCompatActivity() {
dialog.show(supportFragmentManager, "frequencyPicker") dialog.show(supportFragmentManager, "frequencyPicker")
} }
populateTargetType()
binding.targetTypePicker.setOnClickListener {
val builder = AlertDialog.Builder(this)
val arrayAdapter = ArrayAdapter<String>(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 { binding.numericalFrequencyPicker.setOnClickListener {
val builder = AlertDialog.Builder(this) val builder = AlertDialog.Builder(this)
val arrayAdapter = ArrayAdapter<String>(this, android.R.layout.select_dialog_item) val arrayAdapter = ArrayAdapter<String>(this, android.R.layout.select_dialog_item)
@ -262,7 +282,7 @@ class EditHabitActivity : AppCompatActivity() {
habit.frequency = Frequency(freqNum, freqDen) habit.frequency = Frequency(freqNum, freqDen)
if (habitType == HabitType.NUMERICAL) { if (habitType == HabitType.NUMERICAL) {
habit.targetValue = targetInput.text.toString().toDouble() habit.targetValue = targetInput.text.toString().toDouble()
habit.targetType = NumericalHabitType.AT_LEAST habit.targetType = targetType
habit.unit = unitInput.text.trim().toString() habit.unit = unitInput.text.trim().toString()
} }
habit.type = habitType 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() { private fun updateColors() {
androidColor = themeSwitcher.currentTheme.color(color).toInt() androidColor = themeSwitcher.currentTheme.color(color).toInt()
binding.colorButton.backgroundTintList = ColorStateList.valueOf(androidColor) binding.colorButton.backgroundTintList = ColorStateList.valueOf(androidColor)

@ -36,6 +36,7 @@ import android.widget.TextView
import org.isoron.platform.gui.toInt import org.isoron.platform.gui.toInt
import org.isoron.uhabits.R import org.isoron.uhabits.R
import org.isoron.uhabits.activities.common.views.RingView import org.isoron.uhabits.activities.common.views.RingView
import org.isoron.uhabits.activities.habits.list.views.HabitCardView.Companion.delay
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.ModelObservable import org.isoron.uhabits.core.models.ModelObservable
import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.models.Timestamp
@ -143,7 +144,11 @@ class HabitCardView(
checkmarkPanel = checkmarkPanelFactory.create().apply { checkmarkPanel = checkmarkPanelFactory.create().apply {
onToggle = { timestamp, value -> onToggle = { timestamp, value ->
triggerRipple(timestamp) triggerRipple(timestamp)
habit?.let { behavior.onToggle(it, timestamp, value) } habit?.let {
{
behavior.onToggle(it, timestamp, value)
}.delay(TOGGLE_DELAY_MILLIS)
}
} }
} }
@ -236,6 +241,7 @@ class HabitCardView(
numberPanel.apply { numberPanel.apply {
color = c color = c
units = h.unit units = h.unit
targetType = h.targetType
threshold = h.targetValue threshold = h.targetValue
visibility = when (h.isNumerical) { visibility = when (h.isNumerical) {
true -> View.VISIBLE true -> View.VISIBLE
@ -262,4 +268,12 @@ class HabitCardView(
} }
innerFrame.setBackgroundResource(background) innerFrame.setBackgroundResource(background)
} }
companion object {
const val TOGGLE_DELAY_MILLIS = 2000L
fun (() -> Unit).delay(delayInMillis: Long) {
Handler(Looper.getMainLooper()).postDelayed(this, delayInMillis)
}
}
} }

@ -29,13 +29,15 @@ import android.view.View
import android.view.View.OnClickListener import android.view.View.OnClickListener
import android.view.View.OnLongClickListener import android.view.View.OnLongClickListener
import org.isoron.uhabits.R import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.NumericalHabitType
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.inject.ActivityContext import org.isoron.uhabits.inject.ActivityContext
import org.isoron.uhabits.utils.InterfaceUtils.getDimension import org.isoron.uhabits.utils.InterfaceUtils.getDimension
import org.isoron.uhabits.utils.StyledResources
import org.isoron.uhabits.utils.dim import org.isoron.uhabits.utils.dim
import org.isoron.uhabits.utils.getFontAwesome import org.isoron.uhabits.utils.getFontAwesome
import org.isoron.uhabits.utils.showMessage import org.isoron.uhabits.utils.showMessage
import org.isoron.uhabits.utils.sres
import java.lang.Double.max
import java.text.DecimalFormat import java.text.DecimalFormat
import javax.inject.Inject import javax.inject.Inject
@ -88,6 +90,12 @@ class NumberButtonView(
invalidate() invalidate()
} }
var targetType = NumericalHabitType.AT_LEAST
set(value) {
field = value
invalidate()
}
var units = "" var units = ""
set(value) { set(value) {
field = value field = value
@ -127,7 +135,6 @@ class NumberButtonView(
private val em: Float private val em: Float
private val rect: RectF = RectF() private val rect: RectF = RectF()
private val sr = StyledResources(context)
private val lowContrast: Int private val lowContrast: Int
private val mediumContrast: Int private val mediumContrast: Int
@ -148,15 +155,23 @@ class NumberButtonView(
init { init {
em = pNumber.measureText("m") em = pNumber.measureText("m")
lowContrast = sr.getColor(R.attr.contrast40) lowContrast = sres.getColor(R.attr.contrast40)
mediumContrast = sr.getColor(R.attr.contrast60) mediumContrast = sres.getColor(R.attr.contrast60)
} }
fun draw(canvas: Canvas) { fun draw(canvas: Canvas) {
val activeColor = when { var activeColor = if (targetType == NumericalHabitType.AT_LEAST) {
value <= 0.0 -> lowContrast when {
value < threshold -> mediumContrast value < 0.0 && preferences.areQuestionMarksEnabled -> lowContrast
else -> color max(0.0, value) >= threshold -> color
else -> mediumContrast
}
} else {
when {
value < 0.0 && preferences.areQuestionMarksEnabled -> lowContrast
value <= threshold -> color
else -> mediumContrast
}
} }
val label: String val label: String

@ -20,6 +20,7 @@
package org.isoron.uhabits.activities.habits.list.views package org.isoron.uhabits.activities.habits.list.views
import android.content.Context import android.content.Context
import org.isoron.uhabits.core.models.NumericalHabitType
import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.utils.DateUtils import org.isoron.uhabits.core.utils.DateUtils
@ -47,6 +48,12 @@ class NumberPanelView(
setupButtons() setupButtons()
} }
var targetType = NumericalHabitType.AT_LEAST
set(value) {
field = value
setupButtons()
}
var threshold = 0.0 var threshold = 0.0
set(value) { set(value) {
field = value field = value
@ -84,6 +91,7 @@ class NumberPanelView(
else -> 0.0 else -> 0.0
} }
button.color = color button.color = color
button.targetType = targetType
button.threshold = threshold button.threshold = threshold
button.units = units button.units = units
button.onEdit = { onEdit(timestamp) } button.onEdit = { onEdit(timestamp) }

@ -43,6 +43,7 @@ class HistoryCardView(context: Context, attrs: AttributeSet) : LinearLayout(cont
theme = state.theme, theme = state.theme,
dateFormatter = JavaLocalDateFormatter(Locale.getDefault()), dateFormatter = JavaLocalDateFormatter(Locale.getDefault()),
series = state.series, series = state.series,
defaultSquare = state.defaultSquare,
firstWeekday = state.firstWeekday, firstWeekday = state.firstWeekday,
) )
binding.chart.postInvalidate() binding.chart.postInvalidate()

@ -28,6 +28,7 @@ import org.isoron.platform.gui.toInt
import org.isoron.uhabits.R import org.isoron.uhabits.R
import org.isoron.uhabits.activities.habits.edit.formatFrequency import org.isoron.uhabits.activities.habits.edit.formatFrequency
import org.isoron.uhabits.activities.habits.list.views.toShortString 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.core.ui.screens.habits.show.views.SubtitleCardState
import org.isoron.uhabits.databinding.ShowHabitSubtitleBinding import org.isoron.uhabits.databinding.ShowHabitSubtitleBinding
import org.isoron.uhabits.utils.InterfaceUtils import org.isoron.uhabits.utils.InterfaceUtils
@ -65,7 +66,12 @@ class SubtitleCardView(context: Context, attrs: AttributeSet) : LinearLayout(con
binding.questionLabel.visibility = View.VISIBLE binding.questionLabel.visibility = View.VISIBLE
binding.targetIcon.visibility = View.VISIBLE binding.targetIcon.visibility = View.VISIBLE
binding.targetText.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.targetIcon.visibility = View.GONE
binding.targetText.visibility = View.GONE binding.targetText.visibility = View.GONE
} }

@ -56,7 +56,9 @@ class HistoryWidget(
theme = WidgetTheme(), theme = WidgetTheme(),
) )
(widgetView.dataView as AndroidDataView).apply { (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()), dateFormatter = JavaLocalDateFormatter(Locale.getDefault()),
firstWeekday = prefs.firstWeekday, firstWeekday = prefs.firstWeekday,
series = listOf(), series = listOf(),
defaultSquare = HistoryChart.Square.OFF,
) )
} }
).apply { ).apply {

@ -167,6 +167,7 @@
android:hint="@string/measurable_units_example"/> android:hint="@string/measurable_units_example"/>
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
<LinearLayout <LinearLayout
android:id="@+id/targetOuterBox" android:id="@+id/targetOuterBox"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -207,6 +208,22 @@
</FrameLayout> </FrameLayout>
</LinearLayout> </LinearLayout>
<FrameLayout
android:id="@+id/targetTypeOuterBox"
style="@style/FormOuterBox">
<LinearLayout style="@style/FormInnerBox">
<TextView
style="@style/FormLabel"
android:text="@string/target_type" />
<TextView
style="@style/FormDropdown"
android:id="@+id/targetTypePicker"
android:textColor="?attr/contrast100"
/>
</LinearLayout>
</FrameLayout>
<!-- Reminder --> <!-- Reminder -->
<FrameLayout style="@style/FormOuterBox"> <FrameLayout style="@style/FormOuterBox">
<LinearLayout style="@style/FormInnerBox"> <LinearLayout style="@style/FormInnerBox">

@ -47,7 +47,6 @@
android:id="@+id/targetIcon" android:id="@+id/targetIcon"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/fa_arrow_circle_up"
android:textColor="?attr/contrast60" android:textColor="?attr/contrast60"
android:textSize="16sp" /> android:textSize="16sp" />

@ -21,6 +21,7 @@
<resources> <resources>
<string translatable="false" name="fa_star_half_o">&#xf5c0;</string> <string translatable="false" name="fa_star_half_o">&#xf5c0;</string>
<string translatable="false" name="fa_arrow_circle_up">&#xf0aa;</string> <string translatable="false" name="fa_arrow_circle_up">&#xf0aa;</string>
<string translatable="false" name="fa_arrow_circle_down">&#xf0ab;</string>
<string translatable="false" name="fa_check">&#xf00c;</string> <string translatable="false" name="fa_check">&#xf00c;</string>
<string translatable="false" name="fa_times">&#xf00d;</string> <string translatable="false" name="fa_times">&#xf00d;</string>
<string translatable="false" name="fa_skipped">&#xf068;</string> <string translatable="false" name="fa_skipped">&#xf068;</string>
@ -181,7 +182,6 @@
<!--<string translatable="false" name="fa_hand_o_down">&#xf0a7;</string>--> <!--<string translatable="false" name="fa_hand_o_down">&#xf0a7;</string>-->
<!--<string translatable="false" name="fa_arrow_circle_left">&#xf0a8;</string>--> <!--<string translatable="false" name="fa_arrow_circle_left">&#xf0a8;</string>-->
<!--<string translatable="false" name="fa_arrow_circle_right">&#xf0a9;</string>--> <!--<string translatable="false" name="fa_arrow_circle_right">&#xf0a9;</string>-->
<!--<string translatable="false" name="fa_arrow_circle_down">&#xf0ab;</string>-->
<!--<string translatable="false" name="fa_globe">&#xf0ac;</string>--> <!--<string translatable="false" name="fa_globe">&#xf0ac;</string>-->
<!--<string translatable="false" name="fa_wrench">&#xf0ad;</string>--> <!--<string translatable="false" name="fa_wrench">&#xf0ad;</string>-->
<!--<string translatable="false" name="fa_tasks">&#xf0ae;</string>--> <!--<string translatable="false" name="fa_tasks">&#xf0ae;</string>-->

@ -184,6 +184,9 @@
<string name="change_value">Change value</string> <string name="change_value">Change value</string>
<string name="calendar">Calendar</string> <string name="calendar">Calendar</string>
<string name="unit">Unit</string> <string name="unit">Unit</string>
<string name="target_type">Target Type</string>
<string name="target_type_at_least">At least</string>
<string name="target_type_at_most">At most</string>
<string name="example_question_boolean">e.g. Did you exercise today?</string> <string name="example_question_boolean">e.g. Did you exercise today?</string>
<string name="question">Question</string> <string name="question">Question</string>
<string name="target">Target</string> <string name="target">Target</string>

@ -45,13 +45,13 @@ kotlin {
val jvmMain by getting { val jvmMain by getting {
dependencies { dependencies {
implementation(kotlin("stdlib-jdk8")) implementation(kotlin("stdlib-jdk8"))
compileOnly("com.google.dagger:dagger:2.38.1") compileOnly("com.google.dagger:dagger:2.39")
implementation("com.google.guava:guava:30.1.1-android") implementation("com.google.guava:guava:31.0.1-android")
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.5.30") implementation("org.jetbrains.kotlin:kotlin-stdlib:1.5.31")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.5.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.5.2")
implementation("androidx.annotation:annotation:1.2.0") implementation("androidx.annotation:annotation:1.2.0")
implementation("com.google.code.findbugs:jsr305:3.0.2") implementation("com.google.code.findbugs:jsr305:3.0.2")
implementation("com.opencsv:opencsv:5.5.1") implementation("com.opencsv:opencsv:5.5.2")
implementation("commons-codec:commons-codec:1.15") implementation("commons-codec:commons-codec:1.15")
implementation("org.apache.commons:commons-lang3:3.12.0") implementation("org.apache.commons:commons-lang3:3.12.0")
} }

@ -31,5 +31,6 @@ data class CreateHabitCommand(
val habit = modelFactory.buildHabit() val habit = modelFactory.buildHabit()
habit.copyFrom(model) habit.copyFrom(model)
habitList.add(habit) habitList.add(habit)
habit.recompute()
} }
} }

@ -88,14 +88,16 @@ data class Habit(
isNumerical = isNumerical, isNumerical = isNumerical,
) )
val to = DateUtils.getTodayWithOffset().plus(30) val today = DateUtils.getTodayWithOffset()
val to = today.plus(30)
val entries = computedEntries.getKnown() val entries = computedEntries.getKnown()
var from = entries.lastOrNull()?.timestamp ?: to var from = entries.lastOrNull()?.timestamp ?: today
if (from.isNewerThan(to)) from = to if (from.isNewerThan(to)) from = to
scores.recompute( scores.recompute(
frequency = frequency, frequency = frequency,
isNumerical = isNumerical, isNumerical = isNumerical,
numericalHabitType = targetType,
targetValue = targetValue, targetValue = targetValue,
computedEntries = computedEntries, computedEntries = computedEntries,
from = from, from = from,

@ -68,19 +68,19 @@ class ScoreList {
fun recompute( fun recompute(
frequency: Frequency, frequency: Frequency,
isNumerical: Boolean, isNumerical: Boolean,
numericalHabitType: NumericalHabitType,
targetValue: Double, targetValue: Double,
computedEntries: EntryList, computedEntries: EntryList,
from: Timestamp, from: Timestamp,
to: Timestamp, to: Timestamp,
) { ) {
map.clear() map.clear()
if (computedEntries.getKnown().isEmpty()) return
if (from.isNewerThan(to)) return
var rollingSum = 0.0 var rollingSum = 0.0
var numerator = frequency.numerator var numerator = frequency.numerator
var denominator = frequency.denominator var denominator = frequency.denominator
val freq = frequency.toDouble() val freq = frequency.toDouble()
val values = computedEntries.getByInterval(from, to).map { it.value }.toIntArray() 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 // 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 // out irregular repetition schedules (for example, weekly habits performed on different
@ -90,19 +90,29 @@ class ScoreList {
denominator *= 2 denominator *= 2
} }
var previousValue = 0.0 var previousValue = if (isNumerical && isAtMost) 1.0 else 0.0
for (i in values.indices) { for (i in values.indices) {
val offset = values.size - i - 1 val offset = values.size - i - 1
if (isNumerical) { if (isNumerical) {
rollingSum += max(0, values[offset]) rollingSum += max(0, values[offset])
if (offset + denominator < values.size) { 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 { } 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) previousValue = compute(freq, previousValue, percentageCompleted)
} else { } else {
if (values[offset] == Entry.YES_MANUAL) { if (values[offset] == Entry.YES_MANUAL) {

@ -50,6 +50,19 @@ class HabitFixtures(private val modelFactory: ModelFactory, private val habitLis
return habit 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 { fun createLongHabit(): Habit {
val habit = createEmptyHabit() val habit = createEmptyHabit()
habit.frequency = Frequency(3, 7) habit.frequency = Frequency(3, 7)

@ -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.Entry.Companion.YES_MANUAL
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.NumericalHabitType
import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.Preferences
@ -44,6 +45,7 @@ data class HistoryCardState(
val color: PaletteColor, val color: PaletteColor,
val firstWeekday: DayOfWeek, val firstWeekday: DayOfWeek,
val series: List<HistoryChart.Square>, val series: List<HistoryChart.Square>,
val defaultSquare: HistoryChart.Square,
val theme: Theme, val theme: Theme,
val today: LocalDate, val today: LocalDate,
) )
@ -105,12 +107,19 @@ class HistoryCardPresenter(
val oldest = habit.computedEntries.getKnown().lastOrNull()?.timestamp ?: today val oldest = habit.computedEntries.getKnown().lastOrNull()?.timestamp ?: today
val entries = habit.computedEntries.getByInterval(oldest, today) val entries = habit.computedEntries.getByInterval(oldest, today)
val series = if (habit.isNumerical) { val series = if (habit.isNumerical) {
entries.map { if (habit.targetType == NumericalHabitType.AT_LEAST) {
Entry(it.timestamp, max(0, it.value)) entries.map {
}.map { when (max(0, it.value)) {
when (it.value) { 0 -> HistoryChart.Square.OFF
0 -> HistoryChart.Square.OFF else -> HistoryChart.Square.ON
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 { } 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( return HistoryCardState(
color = habit.color, color = habit.color,
@ -130,6 +143,7 @@ class HistoryCardPresenter(
today = today.toLocalDate(), today = today.toLocalDate(),
theme = theme, theme = theme,
series = series, series = series,
defaultSquare = defaultSquare
) )
} }
} }

@ -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.Frequency
import org.isoron.uhabits.core.models.Habit 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.PaletteColor
import org.isoron.uhabits.core.models.Reminder import org.isoron.uhabits.core.models.Reminder
import org.isoron.uhabits.core.ui.views.Theme import org.isoron.uhabits.core.ui.views.Theme
@ -31,8 +32,9 @@ data class SubtitleCardState(
val isNumerical: Boolean, val isNumerical: Boolean,
val question: String, val question: String,
val reminder: Reminder?, val reminder: Reminder?,
val targetValue: Double, val targetValue: Double = 0.0,
val unit: String, val targetType: NumericalHabitType = NumericalHabitType.AT_LEAST,
val unit: String = "",
val theme: Theme, val theme: Theme,
) )
@ -48,6 +50,7 @@ class SubtitleCardPresenter {
question = habit.question, question = habit.question,
reminder = habit.reminder, reminder = habit.reminder,
targetValue = habit.targetValue, targetValue = habit.targetValue,
targetType = habit.targetType,
unit = habit.unit, unit = habit.unit,
theme = theme, theme = theme,
) )

@ -41,6 +41,7 @@ class HistoryChart(
var firstWeekday: DayOfWeek, var firstWeekday: DayOfWeek,
var paletteColor: PaletteColor, var paletteColor: PaletteColor,
var series: List<Square>, var series: List<Square>,
var defaultSquare: Square,
var theme: Theme, var theme: Theme,
var today: LocalDate, var today: LocalDate,
var onDateClickedListener: OnDateClickedListener = OnDateClickedListener { }, var onDateClickedListener: OnDateClickedListener = OnDateClickedListener { },
@ -189,7 +190,7 @@ class HistoryChart(
offset: Int, 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 squareColor: Color
val color = theme.color(paletteColor.paletteIndex) val color = theme.color(paletteColor.paletteIndex)
squareColor = when (value) { squareColor = when (value) {

@ -28,14 +28,36 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import java.util.ArrayList import java.util.ArrayList
class ScoreListTest : BaseUnitTest() { open class BaseScoreListTest : BaseUnitTest() {
private lateinit var habit: Habit protected lateinit var habit: Habit
private lateinit var today: Timestamp protected lateinit var today: Timestamp
@Before @Before
@Throws(Exception::class) @Throws(Exception::class)
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
today = getToday() 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() habit = fixtures.createEmptyHabit()
} }
@ -122,14 +144,6 @@ class ScoreListTest : BaseUnitTest() {
checkScoreValues(expectedValues) checkScoreValues(expectedValues)
} }
@Test
fun test_withZeroTarget() {
habit = fixtures.createNumericalHabit()
habit.targetValue = 0.0
habit.recompute()
assertTrue(habit.scores[today].value.isFinite())
}
@Test @Test
fun test_imperfectNonDaily() { fun test_imperfectNonDaily() {
// If the habit should be performed 3 times per week and the user misses 1 repetition // 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 val entries = habit.originalEntries
entries.add(Entry(today.minus(day), Entry.SKIP)) entries.add(Entry(today.minus(day), Entry.SKIP))
} }
}
private fun checkScoreValues(expectedValues: DoubleArray) { open class NumericalScoreListTest : BaseScoreListTest() {
var current = today protected fun addEntry(day: Int, value: Int) {
val scores = habit.scores val entries = habit.originalEntries
for (expectedValue in expectedValues) { entries.add(Entry(today.minus(day), value))
assertThat(scores[current].value, IsCloseTo.closeTo(expectedValue, E))
current = current.minus(1)
}
} }
companion object { protected fun addEntries(from: Int, to: Int, value: Int) {
private const val E = 1e-6 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))
} }
} }

@ -49,6 +49,7 @@ class HistoryChartTest {
dateFormatter = JavaLocalDateFormatter(Locale.US), dateFormatter = JavaLocalDateFormatter(Locale.US),
firstWeekday = SUNDAY, firstWeekday = SUNDAY,
onDateClickedListener = dateClickedListener, onDateClickedListener = dateClickedListener,
defaultSquare = OFF,
series = listOf( series = listOf(
2, // today 2, // today
2, 1, 2, 1, 2, 1, 2, 2, 1, 2, 1, 2, 1, 2,

@ -33,9 +33,9 @@ application {
} }
dependencies { dependencies {
val ktorVersion = "1.6.3" val ktorVersion = "1.6.4"
val kotlinVersion = "1.5.30" val kotlinVersion = "1.5.31"
val logbackVersion = "1.2.5" val logbackVersion = "1.2.6"
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion") implementation("io.ktor:ktor-server-netty:$ktorVersion")
implementation("ch.qos.logback:logback-classic:$logbackVersion") implementation("ch.qos.logback:logback-classic:$logbackVersion")

Loading…
Cancel
Save