first commit
This commit is contained in:
parent
4383b3ed03
commit
23f9c45329
3
gradle/gradle-daemon-jvm.properties
Normal file
3
gradle/gradle-daemon-jvm.properties
Normal file
@ -0,0 +1,3 @@
|
||||
#This file is generated by updateDaemonJvm
|
||||
toolchainVendor=jetbrains
|
||||
toolchainVersion=21
|
||||
@ -10,6 +10,9 @@ pluginManagement {
|
||||
}
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
|
||||
}
|
||||
|
||||
include(":uhabits-android", ":uhabits-core")
|
||||
dependencyResolutionManagement {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"-->
|
||||
@ -86,4 +105,4 @@
|
||||
<!-- android:text="@string/subjective_example" />-->
|
||||
<!-- </LinearLayout>-->
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@ -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>
|
||||
|
||||
1
uhabits-core/assets/main/migrations/26.sql
Normal file
1
uhabits-core/assets/main/migrations/26.sql
Normal file
@ -0,0 +1 @@
|
||||
alter table Habits add column minutes_per_unit integer not null default 0;
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user