first commit

This commit is contained in:
mel1sg 2026-05-05 22:09:49 +03:00
parent 4383b3ed03
commit 23f9c45329
24 changed files with 455 additions and 41 deletions

View File

@ -0,0 +1,3 @@
#This file is generated by updateDaemonJvm
toolchainVendor=jetbrains
toolchainVersion=21

View File

@ -10,6 +10,9 @@ pluginManagement {
} }
} }
} }
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}
include(":uhabits-android", ":uhabits-core") include(":uhabits-android", ":uhabits-core")
dependencyResolutionManagement { dependencyResolutionManagement {

View File

@ -28,6 +28,7 @@ import org.isoron.uhabits.activities.habits.list.ListHabitsModule
import org.isoron.uhabits.activities.habits.list.ListHabitsScreen import org.isoron.uhabits.activities.habits.list.ListHabitsScreen
import org.isoron.uhabits.activities.habits.list.views.CheckmarkButtonViewFactory import org.isoron.uhabits.activities.habits.list.views.CheckmarkButtonViewFactory
import org.isoron.uhabits.activities.habits.list.views.CheckmarkPanelViewFactory import org.isoron.uhabits.activities.habits.list.views.CheckmarkPanelViewFactory
import org.isoron.uhabits.activities.habits.list.views.CounterPanelViewFactory
import org.isoron.uhabits.activities.habits.list.views.HabitCardListAdapter import org.isoron.uhabits.activities.habits.list.views.HabitCardListAdapter
import org.isoron.uhabits.activities.habits.list.views.HabitCardViewFactory import org.isoron.uhabits.activities.habits.list.views.HabitCardViewFactory
import org.isoron.uhabits.activities.habits.list.views.NumberButtonViewFactory import org.isoron.uhabits.activities.habits.list.views.NumberButtonViewFactory
@ -48,6 +49,7 @@ abstract class HabitsActivityTestComponent(
) { ) {
abstract fun getCheckmarkPanelViewFactory(): CheckmarkPanelViewFactory abstract fun getCheckmarkPanelViewFactory(): CheckmarkPanelViewFactory
abstract fun getHabitCardViewFactory(): HabitCardViewFactory abstract fun getHabitCardViewFactory(): HabitCardViewFactory
abstract fun getCounterPanelViewFactory(): CounterPanelViewFactory
abstract fun getEntryButtonViewFactory(): CheckmarkButtonViewFactory abstract fun getEntryButtonViewFactory(): CheckmarkButtonViewFactory
abstract fun getNumberButtonViewFactory(): NumberButtonViewFactory abstract fun getNumberButtonViewFactory(): NumberButtonViewFactory
abstract fun getNumberPanelViewFactory(): NumberPanelViewFactory abstract fun getNumberPanelViewFactory(): NumberPanelViewFactory

View File

@ -23,6 +23,7 @@ import android.annotation.SuppressLint
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Resources import android.content.res.Resources
import android.os.Bundle import android.os.Bundle
import android.text.InputType
import android.text.Html import android.text.Html
import android.text.Spanned import android.text.Spanned
import android.text.format.DateFormat import android.text.format.DateFormat
@ -58,6 +59,20 @@ import org.isoron.uhabits.utils.dismissCurrentAndShow
import org.isoron.uhabits.utils.formatTime import org.isoron.uhabits.utils.formatTime
import org.isoron.uhabits.utils.toFormattedString import org.isoron.uhabits.utils.toFormattedString
private data class UnitPreset(
val label: String,
val unit: String,
val minutesPerUnit: Int
)
private val TIME_COST_PRESETS = listOf(
UnitPreset("Банка (500 мл)", "банка", 30),
UnitPreset("Стакан (250 мл)", "стакан", 15),
UnitPreset("Глоток", "глоток", 1),
UnitPreset("Сигарета", "сигарета", 11),
UnitPreset("Затяжка", "затяжка", 1)
)
fun formatFrequency(freqNum: Int, freqDen: Int, resources: Resources) = when { fun formatFrequency(freqNum: Int, freqDen: Int, resources: Resources) = when {
freqNum == 1 && (freqDen == 30 || freqDen == 31) -> resources.getString(R.string.every_month) freqNum == 1 && (freqDen == 30 || freqDen == 31) -> resources.getString(R.string.every_month)
freqDen == 30 || freqDen == 31 -> resources.getString(R.string.x_times_per_month, freqNum) freqDen == 30 || freqDen == 31 -> resources.getString(R.string.x_times_per_month, freqNum)
@ -77,6 +92,7 @@ class EditHabitActivity : AppCompatActivity() {
var habitId = -1L var habitId = -1L
lateinit var habitType: HabitType lateinit var habitType: HabitType
var unit = "" var unit = ""
var minutesPerUnit = 0
var color = PaletteColor(11) var color = PaletteColor(11)
var androidColor = 0 var androidColor = 0
var freqNum = 1 var freqNum = 1
@ -107,6 +123,8 @@ class EditHabitActivity : AppCompatActivity() {
freqNum = habit.frequency.numerator freqNum = habit.frequency.numerator
freqDen = habit.frequency.denominator freqDen = habit.frequency.denominator
targetType = habit.targetType targetType = habit.targetType
unit = habit.unit
minutesPerUnit = habit.minutesPerUnit
habit.reminder?.let { habit.reminder?.let {
reminderHour = it.hour reminderHour = it.hour
reminderMin = it.minute reminderMin = it.minute
@ -127,12 +145,15 @@ class EditHabitActivity : AppCompatActivity() {
color = PaletteColor(state.getInt("paletteColor")) color = PaletteColor(state.getInt("paletteColor"))
freqNum = state.getInt("freqNum") freqNum = state.getInt("freqNum")
freqDen = state.getInt("freqDen") freqDen = state.getInt("freqDen")
unit = state.getString("unit") ?: unit
minutesPerUnit = state.getInt("minutesPerUnit")
reminderHour = state.getInt("reminderHour") reminderHour = state.getInt("reminderHour")
reminderMin = state.getInt("reminderMin") reminderMin = state.getInt("reminderMin")
reminderDays = WeekdayList(state.getInt("reminderDays")) reminderDays = WeekdayList(state.getInt("reminderDays"))
} }
updateColors() updateColors()
setupUnitDropdown()
when (habitType) { when (habitType) {
HabitType.YES_NO -> { HabitType.YES_NO -> {
@ -145,6 +166,19 @@ class EditHabitActivity : AppCompatActivity() {
binding.questionInput.hint = getString(R.string.measurable_question_example) binding.questionInput.hint = getString(R.string.measurable_question_example)
binding.frequencyOuterBox.visibility = View.GONE binding.frequencyOuterBox.visibility = View.GONE
} }
HabitType.TIME_COST -> {
binding.nameInput.hint = getString(R.string.time_cost_short_example)
binding.questionInput.hint = getString(R.string.time_cost_question_example)
binding.frequencyOuterBox.visibility = View.GONE
binding.targetOuterBox.visibility = View.GONE
binding.targetTypeOuterBox.visibility = View.GONE
val preset = findPresetByUnit(unit)
?: findPresetByMinutes(minutesPerUnit)
?: TIME_COST_PRESETS.first()
minutesPerUnit = preset.minutesPerUnit
unit = preset.unit
binding.unitInput.setText(preset.label, false)
}
} }
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
@ -283,6 +317,10 @@ class EditHabitActivity : AppCompatActivity() {
habit.targetValue = binding.targetInput.text.toString().toDouble() habit.targetValue = binding.targetInput.text.toString().toDouble()
habit.targetType = targetType habit.targetType = targetType
habit.unit = binding.unitInput.text.trim().toString() habit.unit = binding.unitInput.text.trim().toString()
} else if (habitType == HabitType.TIME_COST) {
val preset = selectedPreset() ?: findPresetByUnit(unit) ?: TIME_COST_PRESETS.first()
habit.unit = preset.unit
habit.minutesPerUnit = preset.minutesPerUnit
} }
habit.type = habitType habit.type = habitType
@ -314,10 +352,67 @@ class EditHabitActivity : AppCompatActivity() {
binding.targetInput.error = getString(R.string.validation_cannot_be_blank) binding.targetInput.error = getString(R.string.validation_cannot_be_blank)
isValid = false isValid = false
} }
} else if (habitType == HabitType.TIME_COST) {
if (binding.unitInput.text.isNullOrEmpty()) {
binding.unitInput.error = getString(R.string.validation_cannot_be_blank)
isValid = false
}
} }
return isValid return isValid
} }
private fun setupUnitDropdown() {
if (habitType == HabitType.TIME_COST) {
val adapter = ArrayAdapter(
this,
android.R.layout.simple_list_item_1,
TIME_COST_PRESETS.map { it.label }
)
binding.unitInput.setAdapter(adapter)
binding.unitInput.setOnItemClickListener { _, _, position, _ ->
val preset = TIME_COST_PRESETS[position]
minutesPerUnit = preset.minutesPerUnit
binding.unitInput.setText(preset.label, false)
}
binding.unitInput.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) syncPresetFromText()
}
binding.unitInput.setOnClickListener {
binding.unitInput.showDropDown()
}
binding.unitInput.inputType = InputType.TYPE_NULL
binding.unitInput.isFocusable = false
binding.unitInput.isClickable = true
} else {
binding.unitInput.setOnItemClickListener(null)
binding.unitInput.setOnFocusChangeListener(null)
binding.unitInput.setOnClickListener(null)
binding.unitInput.inputType = InputType.TYPE_CLASS_TEXT
binding.unitInput.isFocusable = true
binding.unitInput.isClickable = true
}
}
private fun selectedPreset(): UnitPreset? {
val value = binding.unitInput.text?.toString()?.trim().orEmpty()
return TIME_COST_PRESETS.firstOrNull { it.label == value }
}
private fun findPresetByMinutes(minutes: Int): UnitPreset? {
return TIME_COST_PRESETS.firstOrNull { it.minutesPerUnit == minutes }
}
private fun findPresetByUnit(unit: String): UnitPreset? {
return TIME_COST_PRESETS.firstOrNull { it.unit == unit || it.label == unit }
}
private fun syncPresetFromText() {
selectedPreset()?.let { preset ->
minutesPerUnit = preset.minutesPerUnit
binding.unitInput.setText(preset.label, false)
}
}
private fun populateReminder() { private fun populateReminder() {
if (reminderHour < 0) { if (reminderHour < 0) {
binding.reminderTimePicker.text = getString(R.string.reminder_off) binding.reminderTimePicker.text = getString(R.string.reminder_off)
@ -373,6 +468,8 @@ class EditHabitActivity : AppCompatActivity() {
putInt("androidColor", androidColor) putInt("androidColor", androidColor)
putInt("freqNum", freqNum) putInt("freqNum", freqNum)
putInt("freqDen", freqDen) putInt("freqDen", freqDen)
putString("unit", unit)
putInt("minutesPerUnit", minutesPerUnit)
putInt("reminderHour", reminderHour) putInt("reminderHour", reminderHour)
putInt("reminderMin", reminderMin) putInt("reminderMin", reminderMin)
putInt("reminderDays", reminderDays.toInteger()) putInt("reminderDays", reminderDays.toInteger())

View File

@ -51,6 +51,12 @@ class HabitTypeDialog : AppCompatDialogFragment() {
dismiss() dismiss()
} }
binding.buttonTimeCost.setOnClickListener {
val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.TIME_COST.value)
startActivity(intent)
dismiss()
}
binding.background.setOnClickListener { binding.background.setOnClickListener {
dismiss() dismiss()
} }

View File

@ -0,0 +1,151 @@
/*
* Copyright (C) 2016-2025 Á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.views
import android.content.Context
import android.graphics.Typeface
import android.util.TypedValue
import android.view.HapticFeedbackConstants
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.LinearLayout
import androidx.appcompat.widget.AppCompatTextView
import me.tatarka.inject.annotations.Inject
import org.isoron.uhabits.R
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.inject.ActivityContext
import org.isoron.uhabits.utils.dp
import org.isoron.uhabits.utils.sres
@Inject
class CounterPanelViewFactory(
@ActivityContext val context: Context,
val preferences: Preferences
) {
fun create() = CounterPanelView(context, preferences)
}
class CounterPanelView(
context: Context,
private val preferences: Preferences
) : LinearLayout(context), Preferences.Listener {
var value: Int = 0
set(newValue) {
field = newValue.coerceAtLeast(0)
countView.text = field.toString()
}
var color: Int = 0
set(newValue) {
field = newValue
refreshColors()
}
var onEdit: () -> Unit = {}
var onChange: (Int) -> Unit = {}
private val minusButton = buildButton("-")
private val countView = buildCountView()
private val plusButton = buildButton("+")
init {
orientation = HORIZONTAL
layoutParams = LayoutParams(MATCH_PARENT, LayoutParams.WRAP_CONTENT)
addView(minusButton)
addView(countView)
addView(plusButton)
value = 0
setupActions()
refreshColors()
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
preferences.addListener(this)
}
override fun onDetachedFromWindow() {
preferences.removeListener(this)
super.onDetachedFromWindow()
}
override fun onCheckmarkSequenceChanged() {
refreshColors()
}
private fun setupActions() {
minusButton.setOnClickListener {
updateValue(value - 1)
}
plusButton.setOnClickListener {
updateValue(value + 1)
}
listOf(minusButton, countView, plusButton).forEach { view ->
view.setOnLongClickListener {
onEdit()
true
}
}
countView.setOnClickListener {
onEdit()
}
}
private fun updateValue(newValue: Int) {
if (newValue == value) return
value = newValue
onChange(value)
performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
private fun buildButton(text: String): AppCompatTextView {
return AppCompatTextView(context).apply {
layoutParams = LayoutParams(0, dp(40f).toInt(), 1f)
this.text = text
gravity = android.view.Gravity.CENTER
isClickable = true
isFocusable = true
setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
setBackgroundResource(R.drawable.round_ripple)
}
}
private fun buildCountView(): AppCompatTextView {
return AppCompatTextView(context).apply {
layoutParams = LayoutParams(0, dp(40f).toInt(), 1.35f)
gravity = android.view.Gravity.CENTER
isClickable = true
isFocusable = true
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
setBackgroundResource(R.drawable.bg_input_box)
}
}
private fun refreshColors() {
val activeColor = if (color == 0) sres.getColor(R.attr.contrast60) else color
minusButton.setTextColor(activeColor)
plusButton.setTextColor(activeColor)
countView.setTextColor(activeColor)
}
}

View File

@ -29,6 +29,8 @@ import android.os.Looper
import android.text.TextUtils import android.text.TextUtils
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
@ -52,15 +54,23 @@ import org.isoron.uhabits.utils.sres
class HabitCardViewFactory( class HabitCardViewFactory(
@ActivityContext val context: Context, @ActivityContext val context: Context,
private val checkmarkPanelFactory: CheckmarkPanelViewFactory, private val checkmarkPanelFactory: CheckmarkPanelViewFactory,
private val counterPanelFactory: CounterPanelViewFactory,
private val numberPanelFactory: NumberPanelViewFactory, private val numberPanelFactory: NumberPanelViewFactory,
private val behavior: ListHabitsBehavior private val behavior: ListHabitsBehavior
) { ) {
fun create() = HabitCardView(context, checkmarkPanelFactory, numberPanelFactory, behavior) fun create() = HabitCardView(
context,
checkmarkPanelFactory,
counterPanelFactory,
numberPanelFactory,
behavior
)
} }
class HabitCardView( class HabitCardView(
@ActivityContext context: Context, @ActivityContext context: Context,
checkmarkPanelFactory: CheckmarkPanelViewFactory, checkmarkPanelFactory: CheckmarkPanelViewFactory,
counterPanelFactory: CounterPanelViewFactory,
numberPanelFactory: NumberPanelViewFactory, numberPanelFactory: NumberPanelViewFactory,
private val behavior: ListHabitsBehavior private val behavior: ListHabitsBehavior
) : FrameLayout(context), ) : FrameLayout(context),
@ -124,9 +134,12 @@ class HabitCardView(
} }
var checkmarkPanel: CheckmarkPanelView var checkmarkPanel: CheckmarkPanelView
private var counterPanel: CounterPanelView
private var numberPanel: NumberPanelView private var numberPanel: NumberPanelView
private var innerFrame: LinearLayout private var innerFrame: LinearLayout
private var labelContainer: LinearLayout
private var label: TextView private var label: TextView
private var lossText: TextView
private var scoreRing: RingView private var scoreRing: RingView
private var currentToggleTaskId = 0 private var currentToggleTaskId = 0
@ -146,12 +159,25 @@ class HabitCardView(
label = TextView(context).apply { label = TextView(context).apply {
maxLines = 2 maxLines = 2
ellipsize = TextUtils.TruncateAt.END ellipsize = TextUtils.TruncateAt.END
layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)
if (SDK_INT >= Build.VERSION_CODES.Q) { if (SDK_INT >= Build.VERSION_CODES.Q) {
breakStrategy = BREAK_STRATEGY_BALANCED breakStrategy = BREAK_STRATEGY_BALANCED
} }
} }
lossText = TextView(context).apply {
maxLines = 1
visibility = GONE
textSize = 12f
setTextColor(sres.getColor(R.attr.contrast60))
}
labelContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)
addView(label)
addView(lossText)
}
checkmarkPanel = checkmarkPanelFactory.create().apply { checkmarkPanel = checkmarkPanelFactory.create().apply {
onToggle = { date, value, notes -> onToggle = { date, value, notes ->
triggerRipple(date) triggerRipple(date)
@ -174,6 +200,29 @@ class HabitCardView(
} }
} }
counterPanel = counterPanelFactory.create().apply {
visibility = GONE
onChange = { value ->
val date = getToday()
val location = getAbsoluteCounterLocation()
habit?.let {
behavior.onToggle(
it,
date,
value,
it.computedEntries.get(date).notes,
location.x,
location.y
)
}
}
onEdit = {
val date = getToday()
val location = getAbsoluteCounterLocation()
habit?.let { behavior.onEdit(it, date, location.x, location.y) }
}
}
numberPanel = numberPanelFactory.create().apply { numberPanel = numberPanelFactory.create().apply {
visibility = GONE visibility = GONE
onEdit = { date -> onEdit = { date ->
@ -190,8 +239,9 @@ class HabitCardView(
elevation = dp(1f) elevation = dp(1f)
addView(scoreRing) addView(scoreRing)
addView(label) addView(labelContainer)
addView(checkmarkPanel) addView(checkmarkPanel)
addView(counterPanel)
addView(numberPanel) addView(numberPanel)
setOnTouchListener { v, event -> setOnTouchListener { v, event ->
@ -253,6 +303,15 @@ class HabitCardView(
) )
} }
private fun getAbsoluteCounterLocation(): PointF {
val containerLocation = IntArray(2)
counterPanel.getLocationInWindow(containerLocation)
return PointF(
containerLocation[0].toFloat() + counterPanel.width / 2f,
containerLocation[1].toFloat() + counterPanel.height / 2f
)
}
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
habit?.observable?.addListener(this) habit?.observable?.addListener(this)
@ -276,25 +335,34 @@ class HabitCardView(
text = h.name text = h.name
setTextColor(c) setTextColor(c)
} }
lossText.apply {
visibility = if (h.isTimeCost) VISIBLE else GONE
text = if (h.isTimeCost) {
val lostMin = h.getTodayValue() * h.minutesPerUnit
context.getString(R.string.time_lost_today, lostMin)
} else {
""
}
}
scoreRing.apply { scoreRing.apply {
setColor(c) setColor(c)
visibility = if (h.isNumerical && !h.isTimeCost) VISIBLE else GONE
} }
checkmarkPanel.apply { checkmarkPanel.apply {
color = c color = c
visibility = when (h.isNumerical) { visibility = if (h.isTimeCost || h.isNumerical) GONE else VISIBLE
true -> View.GONE }
false -> View.VISIBLE counterPanel.apply {
} color = c
value = h.getTodayValue()
visibility = if (h.isTimeCost) VISIBLE else GONE
} }
numberPanel.apply { numberPanel.apply {
color = c color = c
units = h.unit units = h.unit
targetType = h.targetType targetType = h.targetType
threshold = h.targetValue threshold = h.targetValue
visibility = when (h.isNumerical) { visibility = if (h.isNumerical && !h.isTimeCost) VISIBLE else GONE
true -> View.VISIBLE
false -> View.GONE
}
} }
} }

View File

@ -163,11 +163,14 @@
<TextView <TextView
style="@style/FormLabel" style="@style/FormLabel"
android:text="@string/unit" /> android:text="@string/unit" />
<EditText <androidx.appcompat.widget.AppCompatAutoCompleteTextView
style="@style/FormInput" style="@style/FormInput"
android:id="@+id/unitInput" android:id="@+id/unitInput"
android:maxLines="1" android:maxLines="1"
android:ems="10" android:ems="10"
android:inputType="none"
android:focusable="false"
android:clickable="true"
android:hint="@string/measurable_units_example"/> android:hint="@string/measurable_units_example"/>
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>

View File

@ -67,6 +67,25 @@
android:text="@string/measurable_example" /> android:text="@string/measurable_example" />
</LinearLayout> </LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/buttonTimeCost"
style="@style/SelectHabitTypeButton">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/SelectHabitTypeButtonTitle"
android:text="@string/time_cost" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/SelectHabitTypeButtonBody"
android:text="@string/time_cost_example" />
</LinearLayout>
<!-- <LinearLayout--> <!-- <LinearLayout-->
<!-- android:layout_width="match_parent"--> <!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"--> <!-- android:layout_height="wrap_content"-->
@ -86,4 +105,4 @@
<!-- android:text="@string/subjective_example" />--> <!-- android:text="@string/subjective_example" />-->
<!-- </LinearLayout>--> <!-- </LinearLayout>-->
</LinearLayout> </LinearLayout>

View File

@ -208,6 +208,8 @@
<string name="yes_or_no_example">e.g. Did you wake up early today? Did you exercise? Did you play chess?</string> <string name="yes_or_no_example">e.g. Did you wake up early today? Did you exercise? Did you play chess?</string>
<string name="measurable">Measurable</string> <string name="measurable">Measurable</string>
<string name="measurable_example">e.g. How many miles did you run today? How many pages did you read?</string> <string name="measurable_example">e.g. How many miles did you run today? How many pages did you read?</string>
<string name="time_cost">Time Cost</string>
<string name="time_cost_example">Track lost time in minutes</string>
<string name="x_times_per_week">%d times per week</string> <string name="x_times_per_week">%d times per week</string>
<string name="x_times_per_month">%d times per month</string> <string name="x_times_per_month">%d times per month</string>
<string name="x_times_per_y_days">%d times in %d days</string> <string name="x_times_per_y_days">%d times in %d days</string>
@ -217,6 +219,9 @@
<string name="measurable_short_example">e.g. Run</string> <string name="measurable_short_example">e.g. Run</string>
<string name="measurable_question_example">e.g. How many miles did you run today?</string> <string name="measurable_question_example">e.g. How many miles did you run today?</string>
<string name="measurable_units_example">e.g. miles</string> <string name="measurable_units_example">e.g. miles</string>
<string name="time_cost_short_example">e.g. Smoke</string>
<string name="time_cost_question_example">e.g. How many cigarettes today?</string>
<string name="time_lost_today">Time lost today: %d min</string>
<string name="every_month">Every month</string> <string name="every_month">Every month</string>
<string name="validation_cannot_be_blank">Cannot be blank</string> <string name="validation_cannot_be_blank">Cannot be blank</string>
<string name="today">Today</string> <string name="today">Today</string>

View File

@ -0,0 +1 @@
alter table Habits add column minutes_per_unit integer not null default 0;

View File

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

View File

@ -22,6 +22,7 @@ data class HabitData(
var archived: Int = 0, var archived: Int = 0,
var type: Int = 0, var type: Int = 0,
var targetValue: Double = 0.0, var targetValue: Double = 0.0,
var minutesPerUnit: Int = 0,
var targetType: Int = 0, var targetType: Int = 0,
var unit: String = "", var unit: String = "",
var uuid: String? = null var uuid: String? = null
@ -32,7 +33,7 @@ class HabitRepository(private val db: Database) {
db.prepareStatement( db.prepareStatement(
"""SELECT id, name, description, question, freq_num, freq_den, color, """SELECT id, name, description, question, freq_num, freq_den, color,
position, reminder_hour, reminder_min, reminder_days, highlight, position, reminder_hour, reminder_min, reminder_days, highlight,
archived, type, target_value, target_type, unit, uuid archived, type, target_value, minutes_per_unit, target_type, unit, uuid
FROM Habits ORDER BY position""" FROM Habits ORDER BY position"""
) )
} }
@ -41,8 +42,8 @@ class HabitRepository(private val db: Database) {
db.prepareStatement( db.prepareStatement(
"""INSERT INTO Habits(name, description, question, freq_num, freq_den, """INSERT INTO Habits(name, description, question, freq_num, freq_den,
color, position, reminder_hour, reminder_min, reminder_days, color, position, reminder_hour, reminder_min, reminder_days,
highlight, archived, type, target_value, target_type, unit, uuid) highlight, archived, type, target_value, minutes_per_unit, target_type, unit, uuid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
) )
} }
@ -50,8 +51,8 @@ class HabitRepository(private val db: Database) {
db.prepareStatement( db.prepareStatement(
"""INSERT INTO Habits(id, name, description, question, freq_num, freq_den, """INSERT INTO Habits(id, name, description, question, freq_num, freq_den,
color, position, reminder_hour, reminder_min, reminder_days, color, position, reminder_hour, reminder_min, reminder_days,
highlight, archived, type, target_value, target_type, unit, uuid) highlight, archived, type, target_value, minutes_per_unit, target_type, unit, uuid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
) )
} }
@ -60,7 +61,7 @@ class HabitRepository(private val db: Database) {
"""UPDATE Habits SET name=?, description=?, question=?, freq_num=?, """UPDATE Habits SET name=?, description=?, question=?, freq_num=?,
freq_den=?, color=?, position=?, reminder_hour=?, reminder_min=?, freq_den=?, color=?, position=?, reminder_hour=?, reminder_min=?,
reminder_days=?, highlight=?, archived=?, type=?, target_value=?, reminder_days=?, highlight=?, archived=?, type=?, target_value=?,
target_type=?, unit=?, uuid=? WHERE id=?""" minutes_per_unit=?, target_type=?, unit=?, uuid=? WHERE id=?"""
) )
} }
@ -94,7 +95,7 @@ class HabitRepository(private val db: Database) {
fun update(data: HabitData) { fun update(data: HabitData) {
updateStmt.reset() updateStmt.reset()
bindForInsert(updateStmt, data) bindForInsert(updateStmt, data)
updateStmt.bindLong(18, data.id!!) updateStmt.bindLong(19, data.id!!)
updateStmt.step() updateStmt.step()
} }
@ -124,9 +125,10 @@ class HabitRepository(private val db: Database) {
stmt.bindInt(12 + o, data.archived) stmt.bindInt(12 + o, data.archived)
stmt.bindInt(13 + o, data.type) stmt.bindInt(13 + o, data.type)
stmt.bindReal(14 + o, data.targetValue) stmt.bindReal(14 + o, data.targetValue)
stmt.bindInt(15 + o, data.targetType) stmt.bindInt(15 + o, data.minutesPerUnit)
stmt.bindText(16 + o, data.unit) stmt.bindInt(16 + o, data.targetType)
if (data.uuid != null) stmt.bindText(17 + o, data.uuid!!) else stmt.bindNull(17 + o) stmt.bindText(17 + o, data.unit)
if (data.uuid != null) stmt.bindText(18 + o, data.uuid!!) else stmt.bindNull(18 + o)
} }
private fun readRow(stmt: PreparedStatement): HabitData { private fun readRow(stmt: PreparedStatement): HabitData {
@ -146,9 +148,10 @@ class HabitRepository(private val db: Database) {
archived = stmt.getInt(12), archived = stmt.getInt(12),
type = stmt.getInt(13), type = stmt.getInt(13),
targetValue = stmt.getReal(14), targetValue = stmt.getReal(14),
targetType = stmt.getInt(15), minutesPerUnit = stmt.getInt(15),
unit = stmt.getText(16), targetType = stmt.getInt(16),
uuid = stmt.getTextOrNull(17) unit = stmt.getText(17),
uuid = stmt.getTextOrNull(18)
) )
} }
} }

View File

@ -123,7 +123,7 @@ class LoopDBImporter(
db.query( db.query(
"SELECT id, name, description, question, freq_num, freq_den, color, " + "SELECT id, name, description, question, freq_num, freq_den, color, " +
"position, reminder_hour, reminder_min, reminder_days, highlight, " + "position, reminder_hour, reminder_min, reminder_days, highlight, " +
"archived, type, target_value, target_type, unit, uuid " + "archived, type, target_value, minutes_per_unit, target_type, unit, uuid " +
"FROM Habits ORDER BY position" "FROM Habits ORDER BY position"
) { stmt -> ) { stmt ->
result.add( result.add(
@ -143,9 +143,10 @@ class LoopDBImporter(
archived = stmt.getIntOrNull(12) ?: 0, archived = stmt.getIntOrNull(12) ?: 0,
type = stmt.getIntOrNull(13) ?: 0, type = stmt.getIntOrNull(13) ?: 0,
targetValue = stmt.getRealOrNull(14) ?: 0.0, targetValue = stmt.getRealOrNull(14) ?: 0.0,
targetType = stmt.getIntOrNull(15) ?: 0, minutesPerUnit = stmt.getIntOrNull(15) ?: 0,
unit = stmt.getTextOrNull(16) ?: "", targetType = stmt.getIntOrNull(16) ?: 0,
uuid = stmt.getTextOrNull(17) unit = stmt.getTextOrNull(17) ?: "",
uuid = stmt.getTextOrNull(18)
) )
) )
} }

View File

@ -35,6 +35,7 @@ data class Habit(
var reminder: Reminder? = null, var reminder: Reminder? = null,
var targetType: NumericalHabitType = NumericalHabitType.AT_LEAST, var targetType: NumericalHabitType = NumericalHabitType.AT_LEAST,
var targetValue: Double = 0.0, var targetValue: Double = 0.0,
var minutesPerUnit: Int = 0,
var type: HabitType = HabitType.YES_NO, var type: HabitType = HabitType.YES_NO,
var unit: String = "", var unit: String = "",
var uuid: String? = null, var uuid: String? = null,
@ -52,15 +53,21 @@ data class Habit(
val isNumerical: Boolean val isNumerical: Boolean
get() = type == HabitType.NUMERICAL get() = type == HabitType.NUMERICAL
val isTimeCost: Boolean
get() = type == HabitType.TIME_COST
val uriString: String val uriString: String
get() = "content://org.isoron.uhabits/habit/$id" get() = "content://org.isoron.uhabits/habit/$id"
fun hasReminder(): Boolean = reminder != null fun hasReminder(): Boolean = reminder != null
fun getTodayValue(): Int = computedEntries.get(getToday()).value.coerceAtLeast(0)
fun isCompletedToday(): Boolean { fun isCompletedToday(): Boolean {
val today = getToday() val value = getTodayValue()
val value = computedEntries.get(today).value return if (isTimeCost) {
return if (isNumerical) { value > 0
} else if (isNumerical) {
when (targetType) { when (targetType) {
NumericalHabitType.AT_LEAST -> value / 1000.0 >= targetValue NumericalHabitType.AT_LEAST -> value / 1000.0 >= targetValue
NumericalHabitType.AT_MOST -> false NumericalHabitType.AT_MOST -> false
@ -80,7 +87,7 @@ data class Habit(
computedEntries.recomputeFrom( computedEntries.recomputeFrom(
originalEntries = originalEntries, originalEntries = originalEntries,
frequency = frequency, frequency = frequency,
isNumerical = isNumerical isNumerical = isNumerical || isTimeCost
) )
val today = getToday() val today = getToday()
@ -94,6 +101,7 @@ data class Habit(
isNumerical = isNumerical, isNumerical = isNumerical,
numericalHabitType = targetType, numericalHabitType = targetType,
targetValue = targetValue, targetValue = targetValue,
isTimeCost = isTimeCost,
computedEntries = computedEntries, computedEntries = computedEntries,
from = from, from = from,
to = to to = to
@ -104,6 +112,7 @@ data class Habit(
from, from,
to, to,
isNumerical, isNumerical,
isTimeCost,
targetValue, targetValue,
targetType targetType
) )
@ -121,6 +130,7 @@ data class Habit(
this.reminder = other.reminder this.reminder = other.reminder
this.targetType = other.targetType this.targetType = other.targetType
this.targetValue = other.targetValue this.targetValue = other.targetValue
this.minutesPerUnit = other.minutesPerUnit
this.type = other.type this.type = other.type
this.unit = other.unit this.unit = other.unit
this.uuid = other.uuid this.uuid = other.uuid
@ -141,6 +151,7 @@ data class Habit(
if (reminder != other.reminder) return false if (reminder != other.reminder) return false
if (targetType != other.targetType) return false if (targetType != other.targetType) return false
if (targetValue != other.targetValue) return false if (targetValue != other.targetValue) return false
if (minutesPerUnit != other.minutesPerUnit) return false
if (type != other.type) return false if (type != other.type) return false
if (unit != other.unit) return false if (unit != other.unit) return false
if (uuid != other.uuid) return false if (uuid != other.uuid) return false
@ -160,6 +171,7 @@ data class Habit(
result = 31 * result + (reminder?.hashCode() ?: 0) result = 31 * result + (reminder?.hashCode() ?: 0)
result = 31 * result + targetType.value result = 31 * result + targetType.value
result = 31 * result + targetValue.hashCode() result = 31 * result + targetValue.hashCode()
result = 31 * result + minutesPerUnit
result = 31 * result + type.value result = 31 * result + type.value
result = 31 * result + unit.hashCode() result = 31 * result + unit.hashCode()
result = 31 * result + (uuid?.hashCode() ?: 0) result = 31 * result + (uuid?.hashCode() ?: 0)

View File

@ -192,7 +192,7 @@ abstract class HabitList : Iterable<Habit> {
numerator.toString(), numerator.toString(),
denominator.toString(), denominator.toString(),
habit.color.toCsvColor(), habit.color.toCsvColor(),
if (habit.isNumerical) habit.unit else "", if (habit.isNumerical || habit.isTimeCost) habit.unit else "",
if (habit.isNumerical) habit.targetType.name else "", if (habit.isNumerical) habit.targetType.name else "",
if (habit.isNumerical) format("%.1f", habit.targetValue) else "", if (habit.isNumerical) format("%.1f", habit.targetValue) else "",
habit.isArchived.toString() habit.isArchived.toString()

View File

@ -1,13 +1,16 @@
package org.isoron.uhabits.core.models package org.isoron.uhabits.core.models
enum class HabitType(val value: Int) { enum class HabitType(val value: Int) {
YES_NO(0), NUMERICAL(1); YES_NO(0),
NUMERICAL(1),
TIME_COST(2);
companion object { companion object {
fun fromInt(value: Int): HabitType { fun fromInt(value: Int): HabitType {
return when (value) { return when (value) {
YES_NO.value -> YES_NO YES_NO.value -> YES_NO
NUMERICAL.value -> NUMERICAL NUMERICAL.value -> NUMERICAL
TIME_COST.value -> TIME_COST
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
} }

View File

@ -66,6 +66,7 @@ class ScoreList {
fun recompute( fun recompute(
frequency: Frequency, frequency: Frequency,
isNumerical: Boolean, isNumerical: Boolean,
isTimeCost: Boolean,
numericalHabitType: NumericalHabitType, numericalHabitType: NumericalHabitType,
targetValue: Double, targetValue: Double,
computedEntries: EntryList, computedEntries: EntryList,
@ -119,11 +120,14 @@ class ScoreList {
previousValue = compute(freq, previousValue, percentageCompleted) previousValue = compute(freq, previousValue, percentageCompleted)
} }
} else { } else {
if (values[offset] == Entry.YES_MANUAL) { if (values[offset] == Entry.YES_MANUAL || (isTimeCost && values[offset] > 0)) {
rollingSum += 1.0 rollingSum += 1.0
} }
if (offset + denominator < values.size) { if (offset + denominator < values.size) {
if (values[offset + denominator] == Entry.YES_MANUAL) { if (
values[offset + denominator] == Entry.YES_MANUAL ||
(isTimeCost && values[offset + denominator] > 0)
) {
rollingSum -= 1.0 rollingSum -= 1.0
} }
} }

View File

@ -39,6 +39,7 @@ class StreakList {
from: LocalDate, from: LocalDate,
to: LocalDate, to: LocalDate,
isNumerical: Boolean, isNumerical: Boolean,
isTimeCost: Boolean,
targetValue: Double, targetValue: Double,
targetType: NumericalHabitType targetType: NumericalHabitType
) { ) {
@ -52,6 +53,8 @@ class StreakList {
NumericalHabitType.AT_LEAST -> value / 1000.0 >= targetValue NumericalHabitType.AT_LEAST -> value / 1000.0 >= targetValue
NumericalHabitType.AT_MOST -> value != Entry.UNKNOWN && value / 1000.0 <= targetValue NumericalHabitType.AT_MOST -> value != Entry.UNKNOWN && value / 1000.0 <= targetValue
} }
} else if (isTimeCost) {
value > 0
} else { } else {
value > 0 value > 0
} }

View File

@ -140,6 +140,9 @@ open class MemoryHabitList : HabitList {
if (h1.isNumerical != h2.isNumerical) { if (h1.isNumerical != h2.isNumerical) {
return@Comparator if (h1.isNumerical) -1 else 1 return@Comparator if (h1.isNumerical) -1 else 1
} }
if (h1.isTimeCost != h2.isTimeCost) {
return@Comparator if (h1.isTimeCost) -1 else 1
}
val today = org.isoron.platform.time.getToday() val today = org.isoron.platform.time.getToday()
val v1 = h1.computedEntries.get(today).value val v1 = h1.computedEntries.get(today).value
val v2 = h2.computedEntries.get(today).value val v2 = h2.computedEntries.get(today).value

View File

@ -229,6 +229,7 @@ class SQLiteHabitList(private val modelFactory: ModelFactory) : HabitList() {
archived = if (habit.isArchived) 1 else 0, archived = if (habit.isArchived) 1 else 0,
type = habit.type.value, type = habit.type.value,
targetValue = habit.targetValue, targetValue = habit.targetValue,
minutesPerUnit = habit.minutesPerUnit,
targetType = habit.targetType.value, targetType = habit.targetType.value,
unit = habit.unit, unit = habit.unit,
uuid = habit.uuid uuid = habit.uuid
@ -246,6 +247,7 @@ class SQLiteHabitList(private val modelFactory: ModelFactory) : HabitList() {
habit.type = HabitType.fromInt(data.type) habit.type = HabitType.fromInt(data.type)
habit.targetType = NumericalHabitType.fromInt(data.targetType) habit.targetType = NumericalHabitType.fromInt(data.targetType)
habit.targetValue = data.targetValue habit.targetValue = data.targetValue
habit.minutesPerUnit = data.minutesPerUnit
habit.unit = data.unit habit.unit = data.unit
habit.position = data.position habit.position = data.position
habit.uuid = data.uuid habit.uuid = data.uuid

View File

@ -55,7 +55,16 @@ open class ListHabitsBehavior(
open fun onEdit(habit: Habit, date: LocalDate, x: Float, y: Float) { open fun onEdit(habit: Habit, date: LocalDate, x: Float, y: Float) {
val entry = habit.computedEntries.get(date) val entry = habit.computedEntries.get(date)
if (habit.type == HabitType.NUMERICAL) { if (habit.type == HabitType.TIME_COST) {
val oldValue = entry.value.coerceAtLeast(0).toDouble()
screen.showNumberPopup(oldValue, entry.notes) { newValue: Double, newNotes: String ->
val value = newValue.roundToInt().coerceAtLeast(0)
if (newValue != oldValue && oldValue <= 0.0 && value > 0) {
screen.showConfetti(habit.color, x, y)
}
commandRunner.run(CreateRepetitionCommand(habitList, habit, date, value, newNotes))
}
} else if (habit.type == HabitType.NUMERICAL) {
val oldValue = entry.value.toDouble() / 1000 val oldValue = entry.value.toDouble() / 1000
screen.showNumberPopup(oldValue, entry.notes) { newValue: Double, newNotes: String -> screen.showNumberPopup(oldValue, entry.notes) { newValue: Double, newNotes: String ->
val value = (newValue * 1000).roundToInt() val value = (newValue * 1000).roundToInt()
@ -136,7 +145,11 @@ open class ListHabitsBehavior(
commandRunner.run( commandRunner.run(
CreateRepetitionCommand(habitList, habit, date, value, notes) CreateRepetitionCommand(habitList, habit, date, value, notes)
) )
if (value == YES_MANUAL) screen.showConfetti(habit.color, x, y) if (habit.type == HabitType.TIME_COST) {
if (value > 0) screen.showConfetti(habit.color, x, y)
} else if (value == YES_MANUAL) {
screen.showConfetti(habit.color, x, y)
}
} }
enum class Message { enum class Message {

View File

@ -173,6 +173,7 @@ class HabitRepositoryTest {
archived = 1, archived = 1,
type = 1, type = 1,
targetValue = 10.0, targetValue = 10.0,
minutesPerUnit = 11,
targetType = 1, targetType = 1,
unit = "minutes", unit = "minutes",
uuid = "550e8400-e29b-41d4-a716-446655440000" uuid = "550e8400-e29b-41d4-a716-446655440000"
@ -194,6 +195,7 @@ class HabitRepositoryTest {
assertEquals(original.archived, loaded.archived) assertEquals(original.archived, loaded.archived)
assertEquals(original.type, loaded.type) assertEquals(original.type, loaded.type)
assertEquals(original.targetValue, loaded.targetValue) assertEquals(original.targetValue, loaded.targetValue)
assertEquals(original.minutesPerUnit, loaded.minutesPerUnit)
assertEquals(original.targetType, loaded.targetType) assertEquals(original.targetType, loaded.targetType)
assertEquals(original.unit, loaded.unit) assertEquals(original.unit, loaded.unit)
assertEquals(original.uuid, loaded.uuid) assertEquals(original.uuid, loaded.uuid)

View File

@ -111,6 +111,16 @@ class HabitTest : BaseUnitTest() {
assertFalse(h.isCompletedToday()) assertFalse(h.isCompletedToday())
} }
@Test
fun test_isCompleted_timeCost() {
val h = modelFactory.buildHabit()
h.type = HabitType.TIME_COST
assertFalse(h.isCompletedToday())
h.originalEntries.add(Entry(getToday(), 1))
h.recompute()
assertTrue(h.isCompletedToday())
}
@Test @Test
fun testURI() { fun testURI() {
assertTrue(habitList.isEmpty) assertTrue(habitList.isEmpty)