From 23f9c45329e510321224b71de025962dbcf3c329 Mon Sep 17 00:00:00 2001 From: mel1sg Date: Tue, 5 May 2026 22:09:49 +0300 Subject: [PATCH] first commit --- gradle/gradle-daemon-jvm.properties | 3 + settings.gradle.kts | 3 + .../uhabits/HabitsActivityTestComponent.kt | 2 + .../habits/edit/EditHabitActivity.kt | 97 +++++++++++ .../activities/habits/edit/HabitTypeDialog.kt | 6 + .../habits/list/views/CounterPanelView.kt | 151 ++++++++++++++++++ .../habits/list/views/HabitCardView.kt | 90 +++++++++-- .../main/res/layout/activity_edit_habit.xml | 5 +- .../src/main/res/layout/select_habit_type.xml | 21 ++- .../src/main/res/values/strings.xml | 5 + uhabits-core/assets/main/migrations/26.sql | 1 + .../org/isoron/uhabits/core/Constants.kt | 2 +- .../uhabits/core/database/HabitRepository.kt | 29 ++-- .../isoron/uhabits/core/io/LoopDBImporter.kt | 9 +- .../org/isoron/uhabits/core/models/Habit.kt | 20 ++- .../isoron/uhabits/core/models/HabitList.kt | 2 +- .../isoron/uhabits/core/models/HabitType.kt | 5 +- .../isoron/uhabits/core/models/ScoreList.kt | 8 +- .../isoron/uhabits/core/models/StreakList.kt | 3 + .../core/models/memory/MemoryHabitList.kt | 3 + .../core/models/sqlite/SQLiteHabitList.kt | 2 + .../screens/habits/list/ListHabitsBehavior.kt | 17 +- .../core/database/HabitRepositoryTest.kt | 2 + .../isoron/uhabits/core/models/HabitTest.kt | 10 ++ 24 files changed, 455 insertions(+), 41 deletions(-) create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/CounterPanelView.kt create mode 100644 uhabits-core/assets/main/migrations/26.sql diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..382c6993 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,3 @@ +#This file is generated by updateDaemonJvm +toolchainVendor=jetbrains +toolchainVersion=21 diff --git a/settings.gradle.kts b/settings.gradle.kts index b7b12418..aad277f2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,9 @@ pluginManagement { } } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} include(":uhabits-android", ":uhabits-core") dependencyResolutionManagement { diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/HabitsActivityTestComponent.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/HabitsActivityTestComponent.kt index 937c7a14..f0553a0f 100644 --- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/HabitsActivityTestComponent.kt +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/HabitsActivityTestComponent.kt @@ -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 diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt index 1708a930..c8434567 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/EditHabitActivity.kt @@ -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()) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt index b80a4ac3..89706e19 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/edit/HabitTypeDialog.kt @@ -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() } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/CounterPanelView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/CounterPanelView.kt new file mode 100644 index 00000000..9389de54 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/CounterPanelView.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2016-2025 Álinson Santos Xavier + * + * 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 . + */ + +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) + } +} diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt index 5a69a5a4..9dac7843 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt @@ -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 } } diff --git a/uhabits-android/src/main/res/layout/activity_edit_habit.xml b/uhabits-android/src/main/res/layout/activity_edit_habit.xml index 3fb35269..3c699806 100644 --- a/uhabits-android/src/main/res/layout/activity_edit_habit.xml +++ b/uhabits-android/src/main/res/layout/activity_edit_habit.xml @@ -163,11 +163,14 @@ - diff --git a/uhabits-android/src/main/res/layout/select_habit_type.xml b/uhabits-android/src/main/res/layout/select_habit_type.xml index 0b7431cb..becc04cf 100644 --- a/uhabits-android/src/main/res/layout/select_habit_type.xml +++ b/uhabits-android/src/main/res/layout/select_habit_type.xml @@ -67,6 +67,25 @@ android:text="@string/measurable_example" /> + + + + + + + @@ -86,4 +105,4 @@ - \ No newline at end of file + diff --git a/uhabits-android/src/main/res/values/strings.xml b/uhabits-android/src/main/res/values/strings.xml index 883e5aa6..6e74e984 100644 --- a/uhabits-android/src/main/res/values/strings.xml +++ b/uhabits-android/src/main/res/values/strings.xml @@ -208,6 +208,8 @@ e.g. Did you wake up early today? Did you exercise? Did you play chess? Measurable e.g. How many miles did you run today? How many pages did you read? + Time Cost + Track lost time in minutes %d times per week %d times per month %d times in %d days @@ -217,6 +219,9 @@ e.g. Run e.g. How many miles did you run today? e.g. miles + e.g. Smoke + e.g. How many cigarettes today? + Time lost today: %d min Every month Cannot be blank Today diff --git a/uhabits-core/assets/main/migrations/26.sql b/uhabits-core/assets/main/migrations/26.sql new file mode 100644 index 00000000..d2b58f96 --- /dev/null +++ b/uhabits-core/assets/main/migrations/26.sql @@ -0,0 +1 @@ +alter table Habits add column minutes_per_unit integer not null default 0; diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/Constants.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/Constants.kt index 48b267a0..8276a356 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/Constants.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/Constants.kt @@ -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 diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/HabitRepository.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/HabitRepository.kt index a09fedac..a1fa579f 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/HabitRepository.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/HabitRepository.kt @@ -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) ) } } diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/io/LoopDBImporter.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/io/LoopDBImporter.kt index 529624f8..5a398063 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/io/LoopDBImporter.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/io/LoopDBImporter.kt @@ -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) ) ) } diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/Habit.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/Habit.kt index dfd68f41..daac3bcf 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/Habit.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/Habit.kt @@ -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) diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitList.kt index d8993dc4..4fcca056 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitList.kt @@ -192,7 +192,7 @@ abstract class HabitList : Iterable { 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() diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitType.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitType.kt index b195eb6b..bbb2e27c 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitType.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitType.kt @@ -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() } } diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ScoreList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ScoreList.kt index fe30e746..2ba61f00 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ScoreList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ScoreList.kt @@ -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 } } diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/StreakList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/StreakList.kt index b08e4129..1dd9d938 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/StreakList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/StreakList.kt @@ -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 } diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt index c12e5710..095b1b28 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt @@ -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 diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt index 2342634f..a24d07e0 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt @@ -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 diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt index 4e072b83..b228be7f 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt @@ -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 { diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt index b36ef890..c1a219fd 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt @@ -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) diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitTest.kt index 7cc8b422..8d03563e 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitTest.kt @@ -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)