diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt index 132250754..07be68a00 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Habit.kt @@ -107,6 +107,10 @@ data class Habit( ) } + fun firstEntryDate(): Timestamp { + return computedEntries.getKnown().lastOrNull()?.timestamp ?: DateUtils.getTodayWithOffset() + } + fun copyFrom(other: Habit) { this.color = other.color this.description = other.description diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitGroup.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitGroup.kt new file mode 100644 index 000000000..6d8773732 --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitGroup.kt @@ -0,0 +1,123 @@ +package org.isoron.uhabits.core.models + +import org.isoron.uhabits.core.utils.DateUtils +import java.util.UUID + +data class HabitGroup( + var color: PaletteColor = PaletteColor(8), + var description: String = "", + var id: Long? = null, + var isArchived: Boolean = false, + var name: String = "", + var position: Int = 0, + var question: String = "", + var reminder: Reminder? = null, + var unit: String = "", + var uuid: String? = null, + var habitList: HabitList, + var habitGroupList: HabitGroupList, + val scores: ScoreList, + val streaks: StreakList +) { + init { + if (uuid == null) this.uuid = UUID.randomUUID().toString().replace("-", "") + } + + var observable = ModelObservable() + + val uriString: String + get() = "content://org.isoron.uhabits/habit/$id" + + fun hasReminder(): Boolean = reminder != null + + fun isCompletedToday(): Boolean { + return habitList.all { it.isCompletedToday() } && habitGroupList.all { it.isCompletedToday() } + } + + fun isEnteredToday(): Boolean { + return habitList.all { it.isEnteredToday() } && habitGroupList.all { it.isEnteredToday() } + } + + fun firstEntryDate(): Timestamp { + val today = DateUtils.getTodayWithOffset() + var earliest = today + for (h in habitList) { + val first = h.firstEntryDate() + if (earliest.isNewerThan(first)) earliest = first + } + for (hgr in habitGroupList) { + val first = hgr.firstEntryDate() + if (earliest.isNewerThan(first)) earliest = first + } + return earliest + } + + fun recompute() { + for (h in habitList) h.recompute() + for (hgr in habitGroupList) hgr.recompute() + + val today = DateUtils.getTodayWithOffset() + val to = today.plus(30) + var from = firstEntryDate() + if (from.isNewerThan(to)) from = to + + scores.combineFrom( + habitList = habitList, + habitGroupList = habitGroupList, + from = from, + to = to + ) + + streaks.combineFrom( + habitList = habitList, + habitGroupList = habitGroupList, + from = from, + to = to + ) + } + + fun copyFrom(other: Habit) { + this.color = other.color + this.description = other.description + // this.id should not be copied + this.isArchived = other.isArchived + this.name = other.name + this.position = other.position + this.question = other.question + this.reminder = other.reminder + this.unit = other.unit + this.uuid = other.uuid + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Habit) return false + + if (color != other.color) return false + if (description != other.description) return false + if (id != other.id) return false + if (isArchived != other.isArchived) return false + if (name != other.name) return false + if (position != other.position) return false + if (question != other.question) return false + if (reminder != other.reminder) return false + if (unit != other.unit) return false + if (uuid != other.uuid) return false + + return true + } + + override fun hashCode(): Int { + var result = color.hashCode() + result = 31 * result + description.hashCode() + result = 31 * result + (id?.hashCode() ?: 0) + result = 31 * result + isArchived.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + position + result = 31 * result + question.hashCode() + result = 31 * result + (reminder?.hashCode() ?: 0) + result = 31 * result + unit.hashCode() + result = 31 * result + (uuid?.hashCode() ?: 0) + return result + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitGroupList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitGroupList.kt new file mode 100644 index 000000000..583e5c82f --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitGroupList.kt @@ -0,0 +1,200 @@ +package org.isoron.uhabits.core.models + +import com.opencsv.CSVWriter +import java.io.IOException +import java.io.Writer +import java.util.LinkedList +import javax.annotation.concurrent.ThreadSafe + +/** + * An ordered collection of [HabitGroup]s. + */ +@ThreadSafe +abstract class HabitGroupList : Iterable { + val observable: ModelObservable + + @JvmField + protected val filter: HabitMatcher + + /** + * Creates a new HabitList. + * + * Depending on the implementation, this list can either be empty or be + * populated by some pre-existing habits, for example, from a certain + * database. + */ + constructor() { + observable = ModelObservable() + filter = HabitMatcher(isArchivedAllowed = true) + } + + protected constructor(filter: HabitMatcher) { + observable = ModelObservable() + this.filter = filter + } + + /** + * Inserts a new habit in the list. + * + * If the id of the habit is null, the list will assign it a new id, which + * is guaranteed to be unique in the scope of the list. If id is not null, + * the caller should make sure that the list does not already contain + * another habit with same id, otherwise a RuntimeException will be thrown. + * + * @param habitGroup the habit to be inserted + * @throws IllegalArgumentException if the habit is already on the list. + */ + @Throws(IllegalArgumentException::class) + abstract fun add(habitGroup: HabitGroup) + + /** + * Returns the habit with specified id. + * + * @param id the id of the habit + * @return the habit, or null if none exist + */ + abstract fun getById(id: Long): HabitGroup? + + /** + * Returns the habit with specified UUID. + * + * @param uuid the UUID of the habit + * @return the habit, or null if none exist + */ + abstract fun getByUUID(uuid: String?): HabitGroup? + + /** + * Returns the habit that occupies a certain position. + * + * @param position the position of the desired habit + * @return the habit at that position + * @throws IndexOutOfBoundsException when the position is invalid + */ + abstract fun getByPosition(position: Int): HabitGroup + + /** + * Returns the list of habits that match a given condition. + * + * @param matcher the matcher that checks the condition + * @return the list of matching habits + */ + abstract fun getFiltered(matcher: HabitMatcher?): HabitGroupList + abstract var primaryOrder: Order + abstract var secondaryOrder: Order + + /** + * Returns the index of the given habit in the list, or -1 if the list does + * not contain the habit. + * + * @param h the habit + * @return the index of the habit, or -1 if not in the list + */ + abstract fun indexOf(h: HabitGroup): Int + val isEmpty: Boolean + get() = size() == 0 + + /** + * Removes the given habit from the list. + * + * If the given habit is not in the list, does nothing. + * + * @param h the habit to be removed. + */ + abstract fun remove(h: HabitGroup) + + /** + * Removes all the habits from the list. + */ + open fun removeAll() { + val copy: MutableList = LinkedList() + for (h in this) copy.add(h) + for (h in copy) remove(h) + observable.notifyListeners() + } + + /** + * Changes the position of a habit in the list. + * + * @param from the habit that should be moved + * @param to the habit that currently occupies the desired position + */ + abstract fun reorder(from: HabitGroup, to: HabitGroup) + open fun repair() {} + + /** + * Returns the number of habits in this list. + * + * @return number of habits + */ + abstract fun size(): Int + + /** + * Notifies the list that a certain list of habits has been modified. + * + * Depending on the implementation, this operation might trigger a write to + * disk, or do nothing at all. To make sure that the habits get persisted, + * this operation must be called. + * + * @param habitGroups the list of habits that have been modified. + */ + abstract fun update(habitGroups: List) + + /** + * Notifies the list that a certain habit has been modified. + * + * See [.update] for more details. + * + * @param habitGroup the habit that has been modified. + */ + fun update(habitGroup: HabitGroup) { + update(listOf(habitGroup)) + } + + /** + * Writes the list of habits to the given writer, in CSV format. There is + * one line for each habit, containing the fields name, description, + * frequency numerator, frequency denominator and color. The color is + * written in HTML format (#000000). + * + * @param out the writer that will receive the result + * @throws IOException if write operations fail + */ + @Throws(IOException::class) + fun writeCSV(out: Writer) { + val header = arrayOf( + "Position", + "Name", + "Question", + "Description", + "NumRepetitions", + "Interval", + "Color" + ) + val csv = CSVWriter(out) + csv.writeNext(header, false) + for (habit in this) { + val cols = arrayOf( + String.format("%03d", indexOf(habit) + 1), + habit.name, + habit.question, + habit.description, + habit.color.toCsvColor() + ) + csv.writeNext(cols, false) + } + csv.close() + } + + abstract fun resort() + enum class Order { + BY_NAME_ASC, + BY_NAME_DESC, + BY_COLOR_ASC, + BY_COLOR_DESC, + BY_SCORE_ASC, + BY_SCORE_DESC, + BY_STATUS_ASC, + BY_STATUS_DESC, + BY_POSITION + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt index 84945ad97..b608334b0 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt @@ -19,8 +19,6 @@ package org.isoron.uhabits.core.models import org.isoron.uhabits.core.models.Score.Companion.compute -import java.util.ArrayList -import java.util.HashMap import javax.annotation.concurrent.ThreadSafe import kotlin.math.max import kotlin.math.min @@ -139,4 +137,21 @@ class ScoreList { map[timestamp] = Score(timestamp, previousValue) } } + + @Synchronized + fun combineFrom( + habitList: HabitList, + habitGroupList: HabitGroupList, + from: Timestamp, + to: Timestamp + ) { + var current = to + while (current >= from) { + val habitScores = habitList.map { it.scores[current].value } + val groupScores = habitGroupList.map { it.scores[current].value } + val averageScore = (habitScores + groupScores).average() + map[current] = Score(current, averageScore) + current = current.minus(1) + } + } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Streak.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Streak.kt index 582dbf6f1..446f394b2 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Streak.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/Streak.kt @@ -38,4 +38,8 @@ data class Streak( val length: Int get() = start.daysUntil(end) + 1 + + fun isInStreak(timestamp: Timestamp): Boolean { + return timestamp in start..end + } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/StreakList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/StreakList.kt index d3bed0af7..466e7b596 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/StreakList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/StreakList.kt @@ -62,4 +62,35 @@ class StreakList { } list.add(Streak(begin, end)) } + + @Synchronized + fun isInStreaks(timestamp: Timestamp): Boolean { + return list.any { it.isInStreak(timestamp) } + } + + @Synchronized + fun combineFrom( + habitList: HabitList, + habitGroupList: HabitGroupList, + from: Timestamp, + to: Timestamp + ) { + var current = from + var streakRunning = false + var streakStart = from + while (current <= to) { + if (habitList.all { it.streaks.isInStreaks(current) } && + habitGroupList.all { it.streaks.isInStreaks(current) } && + !streakRunning + ) { + streakStart = current + streakRunning = true + } else if (streakRunning) { + val streakEnd = current.minus(1) + list.add(Streak(streakStart, streakEnd)) + streakRunning = false + } + current = current.plus(1) + } + } }