Merge branch 'dev' into feature/sync2

This commit is contained in:
2022-09-11 06:05:23 -05:00
202 changed files with 2647 additions and 1956 deletions

View File

@@ -0,0 +1,11 @@
HabitName,HabitDescription,HabitCategory,CalendarDate,Value,CommentText
Pushups,,Fitness,2021-09-01,30,
Pushups,,Fitness,2022-01-08,100,
Pushups,,Fitness,2022-01-09,100,
Pushups,,Fitness,2022-01-10,100,
Pushups,,Fitness,2022-01-11,100,
Pushups,,Fitness,2022-01-12,100,
Pushups,,Fitness,2022-01-13,100,
run,,Fitness,2022-01-03,1,
run,,Fitness,2022-01-18,1,
run,,Fitness,2022-01-19,1,
1 HabitName HabitDescription HabitCategory CalendarDate Value CommentText
2 Pushups Fitness 2021-09-01 30
3 Pushups Fitness 2022-01-08 100
4 Pushups Fitness 2022-01-09 100
5 Pushups Fitness 2022-01-10 100
6 Pushups Fitness 2022-01-11 100
7 Pushups Fitness 2022-01-12 100
8 Pushups Fitness 2022-01-13 100
9 run Fitness 2022-01-03 1
10 run Fitness 2022-01-18 1
11 run Fitness 2022-01-19 1

View File

@@ -43,13 +43,13 @@ kotlin {
val jvmMain by getting {
dependencies {
implementation(kotlin("stdlib-jdk8"))
compileOnly("com.google.dagger:dagger:2.40.3")
implementation("com.google.guava:guava:31.0.1-android")
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.5.2")
implementation("androidx.annotation:annotation:1.3.0")
compileOnly("com.google.dagger:dagger:2.43.2")
implementation("com.google.guava:guava:31.1-android")
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.7.10")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4")
implementation("androidx.annotation:annotation:1.4.0")
implementation("com.google.code.findbugs:jsr305:3.0.2")
implementation("com.opencsv:opencsv:5.5.2")
implementation("com.opencsv:opencsv:5.6")
implementation("commons-codec:commons-codec:1.15")
implementation("org.apache.commons:commons-lang3:3.12.0")
}
@@ -59,7 +59,7 @@ kotlin {
dependencies {
implementation(kotlin("test"))
implementation(kotlin("test-junit"))
implementation("org.xerial:sqlite-jdbc:3.36.0.3")
implementation("org.xerial:sqlite-jdbc:3.39.2.1")
implementation("org.hamcrest:hamcrest:2.2")
implementation("org.apache.commons:commons-io:1.3.2")
implementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")

View File

@@ -29,6 +29,11 @@ enum class Font {
FONT_AWESOME
}
data class ScreenLocation(
val x: Double,
val y: Double,
)
interface Canvas {
fun setColor(color: Color)
fun drawLine(x1: Double, y1: Double, x2: Double, y2: Double)

View File

@@ -25,6 +25,12 @@ class StringUtils {
fun joinLongs(values: LongArray): String = values.joinToString(separator = ",")
fun splitLongs(str: String): LongArray = str.split(",").map { it.toLong() }.toLongArray()
fun splitLongs(str: String): LongArray {
return try {
str.split(",").map { it.toLong() }.toLongArray()
} catch (e: NumberFormatException) {
LongArray(0)
}
}
}
}

View File

@@ -19,6 +19,7 @@
package org.isoron.platform.time
import java.text.DateFormat
import java.util.Calendar.DAY_OF_MONTH
import java.util.Calendar.DAY_OF_WEEK
import java.util.Calendar.HOUR_OF_DAY
@@ -66,4 +67,10 @@ class JavaLocalDateFormatter(private val locale: Locale) : LocalDateFormatter {
val cal = date.toGregorianCalendar()
return cal.getDisplayName(DAY_OF_WEEK, SHORT, locale)
}
fun longFormat(date: LocalDate): String {
val df = DateFormat.getDateInstance(DateFormat.LONG, locale)
df.timeZone = TimeZone.getTimeZone("UTC")
return df.format(date.toGregorianCalendar().time)
}
}

View File

@@ -33,5 +33,6 @@ data class EditHabitCommand(
habitList.update(habit)
habit.observable.notifyListeners()
habit.recompute()
habitList.resort()
}
}

View File

@@ -23,6 +23,7 @@ import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.HabitType
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.Timestamp
import java.io.BufferedReader
@@ -50,7 +51,7 @@ class HabitBullCSVImporter
logging: Logging,
) : AbstractImporter() {
val logger = logging.getLogger("HabitBullCSVImporter")
private val logger = logging.getLogger("HabitBullCSVImporter")
override fun canHandle(file: File): Boolean {
val reader = BufferedReader(FileReader(file))
@@ -77,10 +78,16 @@ class HabitBullCSVImporter
logger.info("Creating habit: $name")
}
val notes = cols[5] ?: ""
if (parseInt(cols[4]) == 1) {
h.originalEntries.add(Entry(timestamp, Entry.YES_MANUAL, notes))
} else {
h.originalEntries.add(Entry(timestamp, Entry.NO, notes))
when (val value = parseInt(cols[4])) {
0 -> h.originalEntries.add(Entry(timestamp, Entry.NO, notes))
1 -> h.originalEntries.add(Entry(timestamp, Entry.YES_MANUAL, notes))
else -> {
if (value > 1 && h.type != HabitType.NUMERICAL) {
logger.info("Found a value of $value, considering this habit as numerical.")
h.type = HabitType.NUMERICAL
}
h.originalEntries.add(Entry(timestamp, value, notes))
}
}
}
}

View File

@@ -20,14 +20,13 @@ package org.isoron.uhabits.core.io
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.DATABASE_VERSION
import org.isoron.uhabits.core.commands.Command
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateHabitCommand
import org.isoron.uhabits.core.commands.CreateRepetitionCommand
import org.isoron.uhabits.core.commands.EditHabitCommand
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.database.MigrationHelper
import org.isoron.uhabits.core.database.Repository
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.Timestamp
@@ -81,34 +80,33 @@ class LoopDBImporter
var habit = habitList.getByUUID(habitRecord.uuid)
val entryRecords = entryRepository.findAll("where habit = ?", habitRecord.id.toString())
var command: Command
if (habit == null) {
habit = modelFactory.buildHabit()
habitRecord.id = null
habitRecord.copyTo(habit)
command = CreateHabitCommand(modelFactory, habitList, habit)
command.run()
CreateHabitCommand(modelFactory, habitList, habit).run()
} else {
val modified = modelFactory.buildHabit()
habitRecord.id = habit.id
habitRecord.copyTo(modified)
command = EditHabitCommand(habitList, habit.id!!, modified)
command.run()
EditHabitCommand(habitList, habit.id!!, modified).run()
}
// Reload saved version of the habit
habit = habitList.getByUUID(habitRecord.uuid)
habit = habitList.getByUUID(habitRecord.uuid)!!
val entries = habit.originalEntries
// Import entries
for (r in entryRecords) {
val t = Timestamp(r.timestamp!!)
val (_, value, notes) = habit!!.originalEntries.get(t)
val oldNotes = r.notes ?: ""
if (value != r.value || notes != oldNotes) CreateRepetitionCommand(habitList, habit, t, r.value!!, oldNotes).run()
val (_, value, notes) = entries.get(t)
if (value != r.value || notes != r.notes) {
entries.add(Entry(t, r.value!!, r.notes ?: ""))
}
}
runner.notifyListeners(command)
habit.recompute()
}
habitList.resort()
db.close()
}
}

View File

@@ -276,6 +276,8 @@ open class EntryList {
* For numerical habits, non-positive entry values are converted to zero. For boolean habits, each
* YES_MANUAL value is converted to 1000 and all other values are converted to zero.
*
* SKIP values are converted to zero (if they weren't, each SKIP day would count as 0.003).
*
* The returned list is sorted by timestamp, with the newest entry coming first and the oldest entry
* coming last. If the original list has gaps in it (for example, weeks or months without any
* entries), then the list produced by this method will also have gaps.
@@ -289,7 +291,10 @@ fun List<Entry>.groupedSum(
): List<Entry> {
return this.map { (timestamp, value) ->
if (isNumerical) {
Entry(timestamp, max(0, value))
if (value == SKIP)
Entry(timestamp, 0)
else
Entry(timestamp, max(0, value))
} else {
Entry(timestamp, if (value == YES_MANUAL) 1000 else 0)
}
@@ -301,6 +306,31 @@ fun List<Entry>.groupedSum(
}.entries.map { (timestamp, entries) ->
Entry(timestamp, entries.sumOf { it.value })
}.sortedBy { (timestamp, _) ->
- timestamp.unixTime
-timestamp.unixTime
}
}
/**
* Counts the number of days with vaLue SKIP in the given period.
*/
fun List<Entry>.countSkippedDays(
truncateField: DateUtils.TruncateField,
firstWeekday: Int = Calendar.SATURDAY
): List<Entry> {
return this.map { (timestamp, value) ->
if (value == SKIP) {
Entry(timestamp, 1)
} else {
Entry(timestamp, 0)
}
}.groupBy { entry ->
entry.timestamp.truncate(
truncateField,
firstWeekday,
)
}.entries.map { (timestamp, entries) ->
Entry(timestamp, entries.sumOf { it.value })
}.sortedBy { (timestamp, _) ->
-timestamp.unixTime
}
}

View File

@@ -100,20 +100,25 @@ class ScoreList {
}
val normalizedRollingSum = rollingSum / 1000
val percentageCompleted = if (!isAtMost) {
if (targetValue > 0)
min(1.0, normalizedRollingSum / targetValue)
else
1.0
} else {
if (targetValue > 0) {
(1 - ((normalizedRollingSum - targetValue) / targetValue)).coerceIn(0.0, 1.0)
if (values[offset] != Entry.SKIP) {
val percentageCompleted = if (!isAtMost) {
if (targetValue > 0)
min(1.0, normalizedRollingSum / targetValue)
else
1.0
} else {
if (normalizedRollingSum > 0) 0.0 else 1.0
if (targetValue > 0) {
(1 - ((normalizedRollingSum - targetValue) / targetValue)).coerceIn(
0.0,
1.0
)
} else {
if (normalizedRollingSum > 0) 0.0 else 1.0
}
}
}
previousValue = compute(freq, previousValue, percentageCompleted)
previousValue = compute(freq, previousValue, percentageCompleted)
}
} else {
if (values[offset] == Entry.YES_MANUAL) {
rollingSum += 1.0

View File

@@ -152,19 +152,10 @@ open class Preferences(private val storage: Storage) {
for (l in listeners) l.onNotificationsChanged()
}
fun setNotificationsLed(enabled: Boolean) {
storage.putBoolean("pref_led_notifications", enabled)
for (l in listeners) l.onNotificationsChanged()
}
fun shouldMakeNotificationsSticky(): Boolean {
return storage.getBoolean("pref_sticky_notifications", false)
}
fun shouldMakeNotificationsLed(): Boolean {
return storage.getBoolean("pref_led_notifications", false)
}
open var isCheckmarkSequenceReversed: Boolean
get() {
if (shouldReverseCheckmarks == null) shouldReverseCheckmarks =

View File

@@ -79,8 +79,8 @@ class HabitCardListCache @Inject constructor(
}
@Synchronized
fun getNoteIndicators(habitId: Long): BooleanArray {
return data.notesIndicators[habitId]!!
fun getNotes(habitId: Long): Array<String> {
return data.notes[habitId]!!
}
@Synchronized
@@ -168,7 +168,7 @@ class HabitCardListCache @Inject constructor(
data.habits.removeAt(position)
data.idToHabit.remove(id)
data.checkmarks.remove(id)
data.notesIndicators.remove(id)
data.notes.remove(id)
data.scores.remove(id)
listener.onItemRemoved(position)
}
@@ -213,7 +213,7 @@ class HabitCardListCache @Inject constructor(
val habits: MutableList<Habit>
val checkmarks: HashMap<Long?, IntArray>
val scores: HashMap<Long?, Double>
val notesIndicators: HashMap<Long?, BooleanArray>
val notes: HashMap<Long?, Array<String>>
@Synchronized
fun copyCheckmarksFrom(oldData: CacheData) {
@@ -226,10 +226,10 @@ class HabitCardListCache @Inject constructor(
@Synchronized
fun copyNoteIndicatorsFrom(oldData: CacheData) {
val empty = BooleanArray(checkmarkCount)
val empty = (0..checkmarkCount).map { "" }.toTypedArray()
for (id in idToHabit.keys) {
if (oldData.notesIndicators.containsKey(id)) notesIndicators[id] =
oldData.notesIndicators[id]!! else notesIndicators[id] = empty
if (oldData.notes.containsKey(id)) notes[id] =
oldData.notes[id]!! else notes[id] = empty
}
}
@@ -257,7 +257,7 @@ class HabitCardListCache @Inject constructor(
habits = LinkedList()
checkmarks = HashMap()
scores = HashMap()
notesIndicators = HashMap()
notes = HashMap()
}
}
@@ -298,14 +298,14 @@ class HabitCardListCache @Inject constructor(
if (targetId != null && targetId != habit.id) continue
newData.scores[habit.id] = habit.scores[today].value
val list: MutableList<Int> = ArrayList()
val notesIndicators: MutableList<Boolean> = ArrayList()
val notes: MutableList<String> = ArrayList()
for ((_, value, note) in habit.computedEntries.getByInterval(dateFrom, today)) {
list.add(value)
notesIndicators.add(note.isNotEmpty())
notes.add(note)
}
val entries = list.toTypedArray()
newData.checkmarks[habit.id] = ArrayUtils.toPrimitive(entries)
newData.notesIndicators[habit.id] = notesIndicators.toBooleanArray()
newData.notes[habit.id] = notes.toTypedArray()
runner!!.publishProgress(this, position)
}
}
@@ -333,7 +333,7 @@ class HabitCardListCache @Inject constructor(
data.idToHabit[id] = habit
data.scores[id] = newData.scores[id]!!
data.checkmarks[id] = newData.checkmarks[id]!!
data.notesIndicators[id] = newData.notesIndicators[id]!!
data.notes[id] = newData.notes[id]!!
listener.onItemInserted(position)
}
@@ -361,10 +361,10 @@ class HabitCardListCache @Inject constructor(
private fun performUpdate(id: Long, position: Int) {
val oldScore = data.scores[id]!!
val oldCheckmarks = data.checkmarks[id]
val oldNoteIndicators = data.notesIndicators[id]
val oldNoteIndicators = data.notes[id]
val newScore = newData.scores[id]!!
val newCheckmarks = newData.checkmarks[id]!!
val newNoteIndicators = newData.notesIndicators[id]!!
val newNoteIndicators = newData.notes[id]!!
var unchanged = true
if (oldScore != newScore) unchanged = false
if (!Arrays.equals(oldCheckmarks, newCheckmarks)) unchanged = false
@@ -372,7 +372,7 @@ class HabitCardListCache @Inject constructor(
if (unchanged) return
data.scores[id] = newScore
data.checkmarks[id] = newCheckmarks
data.notesIndicators[id] = newNoteIndicators
data.notes[id] = newNoteIndicators
listener.onItemChanged(position)
}

View File

@@ -51,21 +51,15 @@ open class ListHabitsBehavior @Inject constructor(
fun onEdit(habit: Habit, timestamp: Timestamp?) {
val entry = habit.computedEntries.get(timestamp!!)
if (habit.type == HabitType.NUMERICAL) {
val oldValue = entry.value.toDouble()
screen.showNumberPicker(
oldValue / 1000,
habit.unit,
entry.notes,
timestamp.toDialogDateString(),
) { newValue: Double, newNotes: String, ->
val oldValue = entry.value.toDouble() / 1000
screen.showNumberPopup(oldValue, entry.notes) { newValue: Double, newNotes: String ->
val value = (newValue * 1000).roundToInt()
commandRunner.run(CreateRepetitionCommand(habitList, habit, timestamp, value, newNotes))
}
} else {
screen.showCheckmarkDialog(
screen.showCheckmarkPopup(
entry.value,
entry.notes,
timestamp.toDialogDateString(),
habit.color,
) { newValue, newNotes ->
commandRunner.run(CreateRepetitionCommand(habitList, habit, timestamp, newValue, newNotes))
@@ -119,8 +113,7 @@ open class ListHabitsBehavior @Inject constructor(
if (prefs.isFirstRun) onFirstRun()
}
fun onToggle(habit: Habit, timestamp: Timestamp?, value: Int) {
val notes = habit.computedEntries.get(timestamp!!).notes
fun onToggle(habit: Habit, timestamp: Timestamp, value: Int, notes: String) {
commandRunner.run(
CreateRepetitionCommand(habitList, habit, timestamp, value, notes)
)
@@ -160,21 +153,17 @@ open class ListHabitsBehavior @Inject constructor(
fun showHabitScreen(h: Habit)
fun showIntroScreen()
fun showMessage(m: Message)
fun showNumberPicker(
fun showNumberPopup(
value: Double,
unit: String,
notes: String,
dateString: String,
callback: NumberPickerCallback
)
fun showCheckmarkDialog(
value: Int,
fun showCheckmarkPopup(
selectedValue: Int,
notes: String,
dateString: String,
color: PaletteColor,
callback: CheckMarkDialogCallback
)
fun showSendBugReportToDeveloperScreen(log: String)
fun showSendFileScreen(filename: String)
}

View File

@@ -30,6 +30,7 @@ data class FrequencyCardState(
val firstWeekday: Int,
val frequency: HashMap<Timestamp, Array<Int>>,
val theme: Theme,
val isNumerical: Boolean
)
class FrequencyCardPresenter {
@@ -40,6 +41,7 @@ class FrequencyCardPresenter {
theme: Theme
) = FrequencyCardState(
color = habit.color,
isNumerical = habit.isNumerical,
frequency = habit.originalEntries.computeWeekdayFrequency(
isNumerical = habit.isNumerical
),

View File

@@ -29,16 +29,21 @@ import org.isoron.uhabits.core.models.Entry.Companion.YES_AUTO
import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.NumericalHabitType
import org.isoron.uhabits.core.models.NumericalHabitType.AT_LEAST
import org.isoron.uhabits.core.models.NumericalHabitType.AT_MOST
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior
import org.isoron.uhabits.core.ui.views.HistoryChart
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.DIMMED
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.GREY
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.HATCHED
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.OFF
import org.isoron.uhabits.core.ui.views.HistoryChart.Square.ON
import org.isoron.uhabits.core.ui.views.OnDateClickedListener
import org.isoron.uhabits.core.ui.views.Theme
import org.isoron.uhabits.core.utils.DateUtils
import kotlin.math.max
import kotlin.math.roundToInt
data class HistoryCardState(
@@ -63,23 +68,10 @@ class HistoryCardPresenter(
val timestamp = Timestamp.fromLocalDate(date)
screen.showFeedback()
if (habit.isNumerical) {
showNumberPicker(timestamp)
showNumberPopup(timestamp)
} else {
val entry = habit.computedEntries.get(timestamp)
val nextValue = Entry.nextToggleValue(
value = entry.value,
isSkipEnabled = preferences.isSkipEnabled,
areQuestionMarksEnabled = preferences.areQuestionMarksEnabled
)
commandRunner.run(
CreateRepetitionCommand(
habitList,
habit,
timestamp,
nextValue,
entry.notes,
),
)
if (preferences.isShortToggleEnabled) showCheckmarkPopup(timestamp)
else toggle(timestamp)
}
}
@@ -87,37 +79,58 @@ class HistoryCardPresenter(
val timestamp = Timestamp.fromLocalDate(date)
screen.showFeedback()
if (habit.isNumerical) {
showNumberPicker(timestamp)
showNumberPopup(timestamp)
} else {
val entry = habit.computedEntries.get(timestamp)
screen.showCheckmarkDialog(
entry.value,
entry.notes,
timestamp.toDialogDateString(),
preferences,
habit.color,
) { newValue, newNotes ->
commandRunner.run(
CreateRepetitionCommand(
habitList,
habit,
timestamp,
newValue,
newNotes,
),
)
}
if (preferences.isShortToggleEnabled) toggle(timestamp)
else showCheckmarkPopup(timestamp)
}
}
private fun showNumberPicker(timestamp: Timestamp) {
private fun showCheckmarkPopup(timestamp: Timestamp) {
val entry = habit.computedEntries.get(timestamp)
screen.showCheckmarkPopup(
entry.value,
entry.notes,
preferences,
habit.color,
) { newValue, newNotes ->
commandRunner.run(
CreateRepetitionCommand(
habitList,
habit,
timestamp,
newValue,
newNotes,
),
)
}
}
private fun toggle(timestamp: Timestamp) {
val entry = habit.computedEntries.get(timestamp)
val nextValue = Entry.nextToggleValue(
value = entry.value,
isSkipEnabled = preferences.isSkipEnabled,
areQuestionMarksEnabled = preferences.areQuestionMarksEnabled
)
commandRunner.run(
CreateRepetitionCommand(
habitList,
habit,
timestamp,
nextValue,
entry.notes,
),
)
}
private fun showNumberPopup(timestamp: Timestamp) {
val entry = habit.computedEntries.get(timestamp)
val oldValue = entry.value
screen.showNumberPicker(
oldValue / 1000.0,
habit.unit,
entry.notes,
timestamp.toDialogDateString(),
screen.showNumberPopup(
value = oldValue / 1000.0,
notes = entry.notes,
preferences = preferences,
) { newValue: Double, newNotes: String ->
val thousands = (newValue * 1000).roundToInt()
commandRunner.run(
@@ -146,36 +159,25 @@ class HistoryCardPresenter(
val oldest = habit.computedEntries.getKnown().lastOrNull()?.timestamp ?: today
val entries = habit.computedEntries.getByInterval(oldest, today)
val series = if (habit.isNumerical) {
if (habit.targetType == NumericalHabitType.AT_LEAST) {
entries.map {
when (max(0, it.value)) {
0 -> HistoryChart.Square.OFF
else -> HistoryChart.Square.ON
}
}
} else {
entries.map {
when {
max(0.0, it.value / 1000.0) <= habit.targetValue -> HistoryChart.Square.ON
else -> HistoryChart.Square.OFF
}
entries.map {
when {
it.value == Entry.UNKNOWN -> OFF
it.value == SKIP -> HATCHED
(habit.targetType == AT_MOST) && (it.value / 1000.0 <= habit.targetValue) -> ON
(habit.targetType == AT_LEAST) && (it.value / 1000.0 >= habit.targetValue) -> ON
else -> GREY
}
}
} else {
entries.map {
when (it.value) {
YES_MANUAL -> HistoryChart.Square.ON
YES_AUTO -> HistoryChart.Square.DIMMED
SKIP -> HistoryChart.Square.HATCHED
else -> HistoryChart.Square.OFF
YES_MANUAL -> ON
YES_AUTO -> DIMMED
SKIP -> HATCHED
else -> OFF
}
}
}
val defaultSquare = if (habit.isNumerical && habit.targetType == NumericalHabitType.AT_MOST)
HistoryChart.Square.ON
else
HistoryChart.Square.OFF
val notesIndicators = entries.map {
when (it.notes) {
"" -> false
@@ -189,7 +191,7 @@ class HistoryCardPresenter(
today = today.toLocalDate(),
theme = theme,
series = series,
defaultSquare = defaultSquare,
defaultSquare = OFF,
notesIndicators = notesIndicators,
)
}
@@ -198,17 +200,15 @@ class HistoryCardPresenter(
interface Screen {
fun showHistoryEditorDialog(listener: OnDateClickedListener)
fun showFeedback()
fun showNumberPicker(
fun showNumberPopup(
value: Double,
unit: String,
notes: String,
dateString: String,
preferences: Preferences,
callback: ListHabitsBehavior.NumberPickerCallback,
)
fun showCheckmarkDialog(
value: Int,
fun showCheckmarkPopup(
selectedValue: Int,
notes: String,
dateString: String,
preferences: Preferences,
color: PaletteColor,
callback: ListHabitsBehavior.CheckMarkDialogCallback,

View File

@@ -21,11 +21,13 @@ package org.isoron.uhabits.core.ui.screens.habits.show.views
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.models.countSkippedDays
import org.isoron.uhabits.core.models.groupedSum
import org.isoron.uhabits.core.ui.views.Theme
import org.isoron.uhabits.core.utils.DateUtils
import java.util.ArrayList
import java.util.Calendar
import kotlin.math.max
data class TargetCardState(
val color: PaletteColor,
@@ -51,37 +53,88 @@ class TargetCardPresenter {
isNumerical = habit.isNumerical
).firstOrNull()?.value ?: 0
val skippedDayToday = entries.countSkippedDays(
truncateField = DateUtils.TruncateField.DAY
).firstOrNull()?.value ?: 0
val valueThisWeek = entries.groupedSum(
truncateField = DateUtils.TruncateField.WEEK_NUMBER,
firstWeekday = firstWeekday,
isNumerical = habit.isNumerical
).firstOrNull()?.value ?: 0
val skippedDaysThisWeek = entries.countSkippedDays(
truncateField = DateUtils.TruncateField.WEEK_NUMBER,
firstWeekday = firstWeekday
).firstOrNull()?.value ?: 0
val valueThisMonth = entries.groupedSum(
truncateField = DateUtils.TruncateField.MONTH,
isNumerical = habit.isNumerical
).firstOrNull()?.value ?: 0
val skippedDaysThisMonth = entries.countSkippedDays(
truncateField = DateUtils.TruncateField.MONTH,
).firstOrNull()?.value ?: 0
val valueThisQuarter = entries.groupedSum(
truncateField = DateUtils.TruncateField.QUARTER,
isNumerical = habit.isNumerical
).firstOrNull()?.value ?: 0
val skippedDaysThisQuarter = entries.countSkippedDays(
truncateField = DateUtils.TruncateField.QUARTER
).firstOrNull()?.value ?: 0
val valueThisYear = entries.groupedSum(
truncateField = DateUtils.TruncateField.YEAR,
isNumerical = habit.isNumerical
).firstOrNull()?.value ?: 0
val skippedDaysThisYear = entries.countSkippedDays(
truncateField = DateUtils.TruncateField.YEAR
).firstOrNull()?.value ?: 0
val cal = DateUtils.getStartOfTodayCalendarWithOffset()
val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
val daysInWeek = 7
val daysInQuarter = 91
val daysInYear = cal.getActualMaximum(Calendar.DAY_OF_YEAR)
val weeksInMonth = daysInMonth / 7
val weeksInQuarter = 13
val weeksInYear = 52
val monthsInQuarter = 3
val monthsInYear = 12
val targetToday = habit.targetValue / habit.frequency.denominator
val targetThisWeek = targetToday * 7
val targetThisMonth = targetToday * daysInMonth
val targetThisQuarter = targetToday * daysInQuarter
val targetThisYear = targetToday * daysInYear
val denominator = habit.frequency.denominator
val dailyTarget = habit.targetValue / habit.frequency.denominator
var targetToday = dailyTarget
var targetThisWeek = when (denominator) {
7 -> habit.targetValue
else -> dailyTarget * daysInWeek
}
var targetThisMonth = when (denominator) {
30 -> habit.targetValue
7 -> habit.targetValue * weeksInMonth
else -> dailyTarget * daysInMonth
}
var targetThisQuarter = when (denominator) {
30 -> habit.targetValue * monthsInQuarter
7 -> habit.targetValue * weeksInQuarter
else -> dailyTarget * daysInQuarter
}
var targetThisYear = when (denominator) {
30 -> habit.targetValue * monthsInYear
7 -> habit.targetValue * weeksInYear
else -> dailyTarget * daysInYear
}
targetToday = max(0.0, targetToday - dailyTarget * skippedDayToday)
targetThisWeek = max(0.0, targetThisWeek - dailyTarget * skippedDaysThisWeek)
targetThisMonth = max(0.0, targetThisMonth - dailyTarget * skippedDaysThisMonth)
targetThisQuarter = max(0.0, targetThisQuarter - dailyTarget * skippedDaysThisQuarter)
targetThisYear = max(0.0, targetThisYear - dailyTarget * skippedDaysThisYear)
val values = ArrayList<Double>()
if (habit.frequency.denominator <= 1) values.add(valueToday / 1e3)

View File

@@ -92,7 +92,7 @@ class BarChart(
val r = round(barWidth * 0.15)
if (2 * r < barHeight) {
canvas.fillRect(x, y + r, barWidth, barHeight - r)
canvas.fillRect(x + r, y, barWidth - 2 * r, r)
canvas.fillRect(x + r, y, barWidth - 2 * r, r + 1)
canvas.fillCircle(x + r, y + r, r)
canvas.fillCircle(x + barWidth - r, y + r, r)
} else {

View File

@@ -53,6 +53,7 @@ class HistoryChart(
enum class Square {
ON,
OFF,
GREY,
DIMMED,
HATCHED,
}
@@ -86,7 +87,7 @@ class HistoryChart(
val col = ((x - padding) / squareSize).toInt()
val row = ((y - padding) / squareSize).toInt()
val offset = col * 7 + (row - 1)
if (row == 0 || col == nColumns) return
if (x - padding < 0 || row == 0 || row > 7 || col == nColumns) return
val clickedDate = topLeftDate.plus(offset)
if (clickedDate.isNewerThan(today)) return
if (isLongClick) {
@@ -216,6 +217,9 @@ class HistoryChart(
Square.OFF -> {
theme.lowContrastTextColor
}
Square.GREY -> {
theme.mediumContrastTextColor
}
Square.DIMMED, Square.HATCHED -> {
color.blendWith(theme.cardBackgroundColor, 0.5)
}
@@ -254,7 +258,7 @@ class HistoryChart(
if (hasNotes) {
circleColor = when (value) {
Square.ON -> theme.lowContrastTextColor
Square.ON, Square.GREY -> theme.lowContrastTextColor
else -> color
}
canvas.setColor(circleColor)

View File

@@ -19,6 +19,7 @@
package org.isoron.uhabits.core.utils
import org.isoron.uhabits.core.models.Timestamp
import java.time.YearMonth
import java.util.Calendar
import java.util.Calendar.DAY_OF_MONTH
import java.util.Calendar.DAY_OF_WEEK
@@ -178,6 +179,26 @@ abstract class DateUtils {
return getWeekdayNames(GregorianCalendar.SHORT, firstWeekday)
}
/**
* Returns a vector of Int representing the frequency of each weekday in a given month.
*
* @param startOfMonth a Timestamp representing the beginning of the month.
*/
@JvmStatic
fun getWeekdaysInMonth(startOfMonth: Timestamp): Array<Int> {
val month = startOfMonth.toCalendar()[Calendar.MONTH] + 1
val year = startOfMonth.toCalendar()[Calendar.YEAR]
val weekday = startOfMonth.weekday
val monthLength = YearMonth.of(year, month).lengthOfMonth()
val freq = Array(7) { 0 }
for (day in weekday until weekday + monthLength) {
freq[day % 7] += 1
}
return freq
}
@JvmStatic
fun getToday(): Timestamp = Timestamp(getStartOfToday())

View File

@@ -26,6 +26,7 @@ import org.isoron.uhabits.core.BaseUnitTest
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitType
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayCalendar
import org.isoron.uhabits.core.utils.DateUtils.Companion.setFixedLocalTime
@@ -73,6 +74,30 @@ class ImportTest : BaseUnitTest() {
assertTrue(isNotesEqual(habit, 2019, 6, 14, "Habit 3 notes"))
}
@Test
@Throws(IOException::class)
fun testHabitBullCSV3() {
importFromFile("habitbull3.csv")
assertThat(habitList.size(), equalTo(2))
val habit = habitList.getByPosition(0)
assertThat(habit.name, equalTo("Pushups"))
assertThat(habit.type, equalTo(HabitType.NUMERICAL))
assertThat(habit.description, equalTo(""))
assertThat(habit.frequency, equalTo(Frequency.DAILY))
assertThat(getValue(habit, 2021, 9, 1), equalTo(30))
assertThat(getValue(habit, 2022, 1, 8), equalTo(100))
val habit2 = habitList.getByPosition(1)
assertThat(habit2.name, equalTo("run"))
assertThat(habit2.type, equalTo(HabitType.YES_NO))
assertThat(habit2.description, equalTo(""))
assertThat(habit2.frequency, equalTo(Frequency.DAILY))
assertTrue(isChecked(habit2, 2022, 1, 3))
assertTrue(isChecked(habit2, 2022, 1, 18))
assertTrue(isChecked(habit2, 2022, 1, 19))
}
@Test
@Throws(IOException::class)
fun testLoopDB() {
@@ -124,10 +149,14 @@ class ImportTest : BaseUnitTest() {
}
private fun isChecked(h: Habit, year: Int, month: Int, day: Int): Boolean {
return getValue(h, year, month, day) == Entry.YES_MANUAL
}
private fun getValue(h: Habit, year: Int, month: Int, day: Int): Int {
val date = getStartOfTodayCalendar()
date.set(year, month - 1, day)
val timestamp = Timestamp(date)
return h.originalEntries.get(timestamp).value == Entry.YES_MANUAL
return h.originalEntries.get(timestamp).value
}
private fun isNotesEqual(h: Habit, year: Int, month: Int, day: Int, notes: String): Boolean {

View File

@@ -23,6 +23,7 @@ import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.number.IsCloseTo
import org.hamcrest.number.OrderingComparison
import org.isoron.uhabits.core.BaseUnitTest
import org.isoron.uhabits.core.models.Entry.Companion.SKIP
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
import org.junit.Before
import org.junit.Test
@@ -381,6 +382,66 @@ class NumericalAtLeastScoreListTest : NumericalScoreListTest() {
}
}
class NumericalAtLeastScoreListWithSkipTest : NumericalScoreListTest() {
@Before
@Throws(Exception::class)
override fun setUp() {
super.setUp()
habit = fixtures.createEmptyNumericalHabit(NumericalHabitType.AT_LEAST)
}
@Test
fun test_getValue() {
addEntries(0, 10, 2000)
addEntries(10, 11, SKIP)
addEntries(11, 15, 2000)
addEntries(15, 16, SKIP)
addEntries(16, 20, 2000)
val expectedValues = doubleArrayOf(
0.617008,
0.596033,
0.573910,
0.550574,
0.525961,
0.500000,
0.472617,
0.443734,
0.413270,
0.381137,
0.347244, // skipped day should have the same score as the previous day
0.347244,
0.311495,
0.273788,
0.234017,
0.192067, // skipped day should have the same score as the previous day
0.192067,
0.147820,
0.101149,
0.051922,
0.000000,
0.000000,
0.000000
)
checkScoreValues(expectedValues)
}
@Test
fun skipsShouldNotAffectScore() {
addEntries(0, 500, 1000)
val initialScore = habit.scores[today].value
addEntries(500, 1000, SKIP)
assertThat(habit.scores[today].value, IsCloseTo.closeTo(initialScore, E))
addEntries(0, 300, 1000)
addEntries(300, 500, SKIP)
addEntries(500, 700, 1000)
// skipped days should be treated as if they never existed
assertThat(habit.scores[today].value, IsCloseTo.closeTo(initialScore, E))
}
}
class NumericalAtMostScoreListTest : NumericalScoreListTest() {
@Before
@Throws(Exception::class)

View File

@@ -116,9 +116,6 @@ class PreferencesTest : BaseUnitTest() {
assertFalse(prefs.shouldMakeNotificationsSticky())
prefs.setNotificationsSticky(true)
assertTrue(prefs.shouldMakeNotificationsSticky())
assertFalse(prefs.shouldMakeNotificationsLed())
prefs.setNotificationsLed(true)
assertTrue(prefs.shouldMakeNotificationsLed())
}
@Test

View File

@@ -79,7 +79,11 @@ class ListHabitsBehaviorTest : BaseUnitTest() {
@Test
fun testOnEdit() {
behavior.onEdit(habit2, getToday())
verify(screen).showNumberPicker(eq(0.1), eq("miles"), eq(""), eq("Jan 25, 2015"), picker.capture())
verify(screen).showNumberPopup(
eq(0.1),
eq(""),
picker.capture()
)
picker.lastValue.onNumberPicked(100.0, "")
val today = getTodayWithOffset()
assertThat(habit2.computedEntries.get(today).value, equalTo(100000))
@@ -160,7 +164,12 @@ class ListHabitsBehaviorTest : BaseUnitTest() {
@Test
fun testOnToggle() {
assertTrue(habit1.isCompletedToday())
behavior.onToggle(habit1, getToday(), Entry.NO)
behavior.onToggle(
habit = habit1,
timestamp = getToday(),
value = Entry.NO,
notes = ""
)
assertFalse(habit1.isCompletedToday())
}
}

View File

@@ -73,8 +73,7 @@ class HistoryChartTest {
else -> OFF
}
},
notesIndicators = MutableList(85) {
index: Int ->
notesIndicators = MutableList(85) { index: Int ->
index % 3 == 0
}
)

View File

@@ -118,6 +118,31 @@ class DateUtilsTest : BaseUnitTest() {
assertThat(arrayOf("Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"), equalTo(longWeekdayNames))
}
@Test
fun getWeekdaysInMonth() {
val february = GregorianCalendar(2018, Calendar.FEBRUARY, 1)
val leapFebruary = GregorianCalendar(2020, Calendar.FEBRUARY, 1)
val month = GregorianCalendar(2020, Calendar.APRIL, 1)
val longMonth = GregorianCalendar(2020, Calendar.AUGUST, 1)
assertThat(
arrayOf(4, 4, 4, 4, 4, 4, 4),
equalTo(DateUtils.getWeekdaysInMonth(Timestamp(february)))
)
assertThat(
arrayOf(5, 4, 4, 4, 4, 4, 4),
equalTo(DateUtils.getWeekdaysInMonth(Timestamp(leapFebruary)))
)
assertThat(
arrayOf(4, 4, 4, 4, 5, 5, 4),
equalTo(DateUtils.getWeekdaysInMonth(Timestamp(month)))
)
assertThat(
arrayOf(5, 5, 5, 4, 4, 4, 4),
equalTo(DateUtils.getWeekdaysInMonth(Timestamp(longMonth)))
)
}
@Test
fun testGetToday() {
setFixedLocalTime(FIXED_LOCAL_TIME)