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")
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.views.CheckmarkButtonViewFactory
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.HabitCardViewFactory
import org.isoron.uhabits.activities.habits.list.views.NumberButtonViewFactory
@ -48,6 +49,7 @@ abstract class HabitsActivityTestComponent(
) {
abstract fun getCheckmarkPanelViewFactory(): CheckmarkPanelViewFactory
abstract fun getHabitCardViewFactory(): HabitCardViewFactory
abstract fun getCounterPanelViewFactory(): CounterPanelViewFactory
abstract fun getEntryButtonViewFactory(): CheckmarkButtonViewFactory
abstract fun getNumberButtonViewFactory(): NumberButtonViewFactory
abstract fun getNumberPanelViewFactory(): NumberPanelViewFactory

View File

@ -23,6 +23,7 @@ import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.content.res.Resources
import android.os.Bundle
import android.text.InputType
import android.text.Html
import android.text.Spanned
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.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 {
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)
@ -77,6 +92,7 @@ class EditHabitActivity : AppCompatActivity() {
var habitId = -1L
lateinit var habitType: HabitType
var unit = ""
var minutesPerUnit = 0
var color = PaletteColor(11)
var androidColor = 0
var freqNum = 1
@ -107,6 +123,8 @@ class EditHabitActivity : AppCompatActivity() {
freqNum = habit.frequency.numerator
freqDen = habit.frequency.denominator
targetType = habit.targetType
unit = habit.unit
minutesPerUnit = habit.minutesPerUnit
habit.reminder?.let {
reminderHour = it.hour
reminderMin = it.minute
@ -127,12 +145,15 @@ class EditHabitActivity : AppCompatActivity() {
color = PaletteColor(state.getInt("paletteColor"))
freqNum = state.getInt("freqNum")
freqDen = state.getInt("freqDen")
unit = state.getString("unit") ?: unit
minutesPerUnit = state.getInt("minutesPerUnit")
reminderHour = state.getInt("reminderHour")
reminderMin = state.getInt("reminderMin")
reminderDays = WeekdayList(state.getInt("reminderDays"))
}
updateColors()
setupUnitDropdown()
when (habitType) {
HabitType.YES_NO -> {
@ -145,6 +166,19 @@ class EditHabitActivity : AppCompatActivity() {
binding.questionInput.hint = getString(R.string.measurable_question_example)
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)
@ -283,6 +317,10 @@ class EditHabitActivity : AppCompatActivity() {
habit.targetValue = binding.targetInput.text.toString().toDouble()
habit.targetType = targetType
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
@ -314,10 +352,67 @@ class EditHabitActivity : AppCompatActivity() {
binding.targetInput.error = getString(R.string.validation_cannot_be_blank)
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
}
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() {
if (reminderHour < 0) {
binding.reminderTimePicker.text = getString(R.string.reminder_off)
@ -373,6 +468,8 @@ class EditHabitActivity : AppCompatActivity() {
putInt("androidColor", androidColor)
putInt("freqNum", freqNum)
putInt("freqDen", freqDen)
putString("unit", unit)
putInt("minutesPerUnit", minutesPerUnit)
putInt("reminderHour", reminderHour)
putInt("reminderMin", reminderMin)
putInt("reminderDays", reminderDays.toInteger())

View File

@ -51,6 +51,12 @@ class HabitTypeDialog : AppCompatDialogFragment() {
dismiss()
}
binding.buttonTimeCost.setOnClickListener {
val intent = IntentFactory().startEditActivity(requireActivity(), HabitType.TIME_COST.value)
startActivity(intent)
dismiss()
}
binding.background.setOnClickListener {
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.view.Gravity
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.WRAP_CONTENT
import android.widget.FrameLayout
@ -52,15 +54,23 @@ import org.isoron.uhabits.utils.sres
class HabitCardViewFactory(
@ActivityContext val context: Context,
private val checkmarkPanelFactory: CheckmarkPanelViewFactory,
private val counterPanelFactory: CounterPanelViewFactory,
private val numberPanelFactory: NumberPanelViewFactory,
private val behavior: ListHabitsBehavior
) {
fun create() = HabitCardView(context, checkmarkPanelFactory, numberPanelFactory, behavior)
fun create() = HabitCardView(
context,
checkmarkPanelFactory,
counterPanelFactory,
numberPanelFactory,
behavior
)
}
class HabitCardView(
@ActivityContext context: Context,
checkmarkPanelFactory: CheckmarkPanelViewFactory,
counterPanelFactory: CounterPanelViewFactory,
numberPanelFactory: NumberPanelViewFactory,
private val behavior: ListHabitsBehavior
) : FrameLayout(context),
@ -124,9 +134,12 @@ class HabitCardView(
}
var checkmarkPanel: CheckmarkPanelView
private var counterPanel: CounterPanelView
private var numberPanel: NumberPanelView
private var innerFrame: LinearLayout
private var labelContainer: LinearLayout
private var label: TextView
private var lossText: TextView
private var scoreRing: RingView
private var currentToggleTaskId = 0
@ -146,12 +159,25 @@ class HabitCardView(
label = TextView(context).apply {
maxLines = 2
ellipsize = TextUtils.TruncateAt.END
layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)
if (SDK_INT >= Build.VERSION_CODES.Q) {
breakStrategy = BREAK_STRATEGY_BALANCED
}
}
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 {
onToggle = { date, value, notes ->
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 {
visibility = GONE
onEdit = { date ->
@ -190,8 +239,9 @@ class HabitCardView(
elevation = dp(1f)
addView(scoreRing)
addView(label)
addView(labelContainer)
addView(checkmarkPanel)
addView(counterPanel)
addView(numberPanel)
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() {
super.onAttachedToWindow()
habit?.observable?.addListener(this)
@ -276,25 +335,34 @@ class HabitCardView(
text = h.name
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 {
setColor(c)
visibility = if (h.isNumerical && !h.isTimeCost) VISIBLE else GONE
}
checkmarkPanel.apply {
color = c
visibility = when (h.isNumerical) {
true -> View.GONE
false -> View.VISIBLE
}
visibility = if (h.isTimeCost || h.isNumerical) GONE else VISIBLE
}
counterPanel.apply {
color = c
value = h.getTodayValue()
visibility = if (h.isTimeCost) VISIBLE else GONE
}
numberPanel.apply {
color = c
units = h.unit
targetType = h.targetType
threshold = h.targetValue
visibility = when (h.isNumerical) {
true -> View.VISIBLE
false -> View.GONE
}
visibility = if (h.isNumerical && !h.isTimeCost) VISIBLE else GONE
}
}

View File

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

View File

@ -67,6 +67,25 @@
android:text="@string/measurable_example" />
</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-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->

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="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="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_month">%d times per month</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_question_example">e.g. How many miles did you run today?</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="validation_cannot_be_blank">Cannot be blank</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_VERSION = 25
const val DATABASE_VERSION = 26

View File

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

View File

@ -123,7 +123,7 @@ class LoopDBImporter(
db.query(
"SELECT id, name, description, question, freq_num, freq_den, color, " +
"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"
) { stmt ->
result.add(
@ -143,9 +143,10 @@ class LoopDBImporter(
archived = stmt.getIntOrNull(12) ?: 0,
type = stmt.getIntOrNull(13) ?: 0,
targetValue = stmt.getRealOrNull(14) ?: 0.0,
targetType = stmt.getIntOrNull(15) ?: 0,
unit = stmt.getTextOrNull(16) ?: "",
uuid = stmt.getTextOrNull(17)
minutesPerUnit = stmt.getIntOrNull(15) ?: 0,
targetType = stmt.getIntOrNull(16) ?: 0,
unit = stmt.getTextOrNull(17) ?: "",
uuid = stmt.getTextOrNull(18)
)
)
}

View File

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

View File

@ -192,7 +192,7 @@ abstract class HabitList : Iterable<Habit> {
numerator.toString(),
denominator.toString(),
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) format("%.1f", habit.targetValue) else "",
habit.isArchived.toString()

View File

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

View File

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

View File

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

View File

@ -140,6 +140,9 @@ open class MemoryHabitList : HabitList {
if (h1.isNumerical != h2.isNumerical) {
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 v1 = h1.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,
type = habit.type.value,
targetValue = habit.targetValue,
minutesPerUnit = habit.minutesPerUnit,
targetType = habit.targetType.value,
unit = habit.unit,
uuid = habit.uuid
@ -246,6 +247,7 @@ class SQLiteHabitList(private val modelFactory: ModelFactory) : HabitList() {
habit.type = HabitType.fromInt(data.type)
habit.targetType = NumericalHabitType.fromInt(data.targetType)
habit.targetValue = data.targetValue
habit.minutesPerUnit = data.minutesPerUnit
habit.unit = data.unit
habit.position = data.position
habit.uuid = data.uuid

View File

@ -55,7 +55,16 @@ open class ListHabitsBehavior(
open fun onEdit(habit: Habit, date: LocalDate, x: Float, y: Float) {
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
screen.showNumberPopup(oldValue, entry.notes) { newValue: Double, newNotes: String ->
val value = (newValue * 1000).roundToInt()
@ -136,7 +145,11 @@ open class ListHabitsBehavior(
commandRunner.run(
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 {

View File

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

View File

@ -111,6 +111,16 @@ class HabitTest : BaseUnitTest() {
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
fun testURI() {
assertTrue(habitList.isEmpty)