diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt index 4f00f4d1f..da242e3d2 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsApplication.kt @@ -79,6 +79,10 @@ class HabitsApplication : Application() { val habitList = component.habitList for (h in habitList) h.recompute() + val habitGroupList = component.habitGroupList + for (hgr in habitGroupList) hgr.recompute() + habitGroupList.populateGroupsWith(habitList) + widgetUpdater = component.widgetUpdater.apply { startListening() scheduleStartDayWidgetUpdate() diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt index da7f957f3..c3dc68e82 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt @@ -24,6 +24,7 @@ import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.io.GenericImporter import org.isoron.uhabits.core.io.Logging +import org.isoron.uhabits.core.models.HabitGroupList import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.preferences.Preferences @@ -50,6 +51,7 @@ interface HabitsApplicationComponent { val genericImporter: GenericImporter val habitCardListCache: HabitCardListCache val habitList: HabitList + val habitGroupList: HabitGroupList val intentFactory: IntentFactory val intentParser: IntentParser val logging: Logging diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt index c7b6843d0..bac2faa9b 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsModule.kt @@ -26,9 +26,11 @@ import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.database.Database import org.isoron.uhabits.core.database.DatabaseOpener import org.isoron.uhabits.core.io.Logging +import org.isoron.uhabits.core.models.HabitGroupList import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.sqlite.SQLModelFactory +import org.isoron.uhabits.core.models.sqlite.SQLiteHabitGroupList import org.isoron.uhabits.core.models.sqlite.SQLiteHabitList import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.preferences.WidgetPreferences @@ -97,6 +99,12 @@ class HabitsModule(dbFile: File) { return list } + @Provides + @AppScope + fun getHabitGroupList(list: SQLiteHabitGroupList): HabitGroupList { + return list + } + @Provides @AppScope fun getDatabaseOpener(opener: AndroidDatabaseOpener): DatabaseOpener { 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 07be68a00..fb4009046 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 @@ -40,12 +40,15 @@ data class Habit( val computedEntries: EntryList, val originalEntries: EntryList, val scores: ScoreList, - val streaks: StreakList + val streaks: StreakList, + var parentID: Long? = null, + var parentUUID: String? = null ) { init { if (uuid == null) this.uuid = UUID.randomUUID().toString().replace("-", "") } + var parent: HabitGroup? = null var observable = ModelObservable() val isNumerical: Boolean @@ -111,6 +114,14 @@ data class Habit( return computedEntries.getKnown().lastOrNull()?.timestamp ?: DateUtils.getTodayWithOffset() } + fun hierarchyLevel(): Int { + return if (parentID == null) { + 0 + } else { + 1 + parent!!.hierarchyLevel() + } + } + fun copyFrom(other: Habit) { this.color = other.color this.description = other.description @@ -127,6 +138,8 @@ data class Habit( this.type = other.type this.unit = other.unit this.uuid = other.uuid + this.parentID = other.parentID + this.parentUUID = other.parentUUID } override fun equals(other: Any?): Boolean { @@ -148,6 +161,8 @@ data class Habit( if (type != other.type) return false if (unit != other.unit) return false if (uuid != other.uuid) return false + if (parentID != other.parentID) return false + if (parentUUID != other.parentUUID) return false return true } @@ -168,6 +183,8 @@ data class Habit( result = 31 * result + type.value result = 31 * result + unit.hashCode() result = 31 * result + (uuid?.hashCode() ?: 0) + result = 31 * result + (parentID?.hashCode() ?: 0) + result = 31 * result + (parentUUID?.hashCode() ?: 0) return result } } 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 index 6d8773732..5b80f1bc7 100644 --- 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 @@ -12,17 +12,19 @@ data class HabitGroup( 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 + val streaks: StreakList, + var parentID: Long? = null, + var parentUUID: String? = null ) { init { if (uuid == null) this.uuid = UUID.randomUUID().toString().replace("-", "") } + var parent: HabitGroup? = null var observable = ModelObservable() val uriString: String @@ -76,7 +78,7 @@ data class HabitGroup( ) } - fun copyFrom(other: Habit) { + fun copyFrom(other: HabitGroup) { this.color = other.color this.description = other.description // this.id should not be copied @@ -85,8 +87,9 @@ data class HabitGroup( this.position = other.position this.question = other.question this.reminder = other.reminder - this.unit = other.unit this.uuid = other.uuid + this.parentID = other.parentID + this.parentUUID = other.parentUUID } override fun equals(other: Any?): Boolean { @@ -101,8 +104,9 @@ data class HabitGroup( 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 + if (parentID != other.parentID) return false + if (parentUUID != other.parentUUID) return false return true } @@ -116,8 +120,37 @@ data class HabitGroup( 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) + result = 31 * result + (parentID?.hashCode() ?: 0) + result = 31 * result + (parentUUID?.hashCode() ?: 0) return result } + + fun hierarchyLevel(): Int { + return if (parentID == null) { + 0 + } else { + 1 + parent!!.hierarchyLevel() + } + } + + fun getHabitByUUIDDeep(uuid: String?): Habit? { + val habit = habitList.getByUUID(uuid) + if (habit != null) return habit + for (hgr in habitGroupList) { + val found = hgr.getHabitByUUIDDeep(uuid) + if (found != null) return found + } + return null + } + + fun getHabitGroupByUUIDDeep(uuid: String?): HabitGroup? { + val habitGroup = habitGroupList.getByUUID(uuid) + if (habitGroup != null) return habitGroup + for (hgr in habitGroupList) { + val found = hgr.getHabitGroupByUUIDDeep(uuid) + if (found != null) return found + } + return null + } } 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 index 583e5c82f..944adfbf3 100644 --- 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 @@ -1,6 +1,7 @@ package org.isoron.uhabits.core.models import com.opencsv.CSVWriter +import org.isoron.uhabits.core.models.HabitList.Order import java.io.IOException import java.io.Writer import java.util.LinkedList @@ -63,6 +64,30 @@ abstract class HabitGroupList : Iterable { */ abstract fun getByUUID(uuid: String?): HabitGroup? + /** + * Returns the habit with the specified UUID which is + * present at any hierarchy within this list. + */ + fun getHabitByUUIDDeep(uuid: String?): Habit? { + for (hgr in this) { + val habit = hgr.getHabitByUUIDDeep(uuid) + if (habit != null) { + return habit + } + } + return null + } + + fun getHabitGroupByUUIDDeep(uuid: String?): HabitGroup? { + for (hgr in this) { + val habit = hgr.getHabitGroupByUUIDDeep(uuid) + if (habit != null) { + return habit + } + } + return null + } + /** * Returns the habit that occupies a certain position. * @@ -150,6 +175,42 @@ abstract class HabitGroupList : Iterable { update(listOf(habitGroup)) } + fun populateGroupsWith(habitList: HabitList) { + val toRemove = mutableListOf() + for (habit in habitList) { + val hgr = getByUUID(habit.parentUUID) + if (hgr != null) { + hgr.habitList.add(habit) + habit.parent = hgr + toRemove.add(habit.uuid) + } + } + for (uuid in toRemove) { + val h = habitList.getByUUID(uuid) + if (h != null) { + habitList.remove(h) + } + } + toRemove.clear() + for (hgr1 in this) { + val hgr2 = getByUUID(hgr1.parentUUID) + if (hgr2 != null) { + hgr2.habitGroupList.add(hgr1) + toRemove.add(hgr1.uuid) + hgr1.parent = hgr2 + } + } + for (uuid in toRemove) { + val h = getByUUID(uuid) + if (h != null) { + remove(h) + } + } + for (hgr in this) { + hgr.recompute() + } + } + /** * Writes the list of habits to the given writer, in CSV format. There is * one line for each habit, containing the fields name, description, @@ -186,15 +247,4 @@ abstract class HabitGroupList : Iterable { } 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/HabitMatcher.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitMatcher.kt index b39e661ff..fbfedb3e0 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitMatcher.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitMatcher.kt @@ -32,6 +32,14 @@ data class HabitMatcher( return true } + fun matches(habitGroup: HabitGroup): Boolean { + if (!isArchivedAllowed && habitGroup.isArchived) return false + if (isReminderRequired && !habitGroup.hasReminder()) return false + if (!isCompletedAllowed && habitGroup.isCompletedToday()) return false + if (!isEnteredAllowed && habitGroup.isEnteredToday()) return false + return true + } + companion object { @JvmField val WITH_ALARM = HabitMatcher( diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ModelFactory.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ModelFactory.kt index 8c4339258..b150aa03a 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ModelFactory.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ModelFactory.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.core.models import org.isoron.uhabits.core.database.Repository import org.isoron.uhabits.core.models.sqlite.records.EntryRecord +import org.isoron.uhabits.core.models.sqlite.records.HabitGroupRecord import org.isoron.uhabits.core.models.sqlite.records.HabitRecord /** @@ -38,11 +39,25 @@ interface ModelFactory { computedEntries = buildComputedEntries() ) } + fun buildHabitGroup(): HabitGroup { + val habits = buildHabitList() + val groups = buildHabitGroupList() + val scores = buildScoreList() + val streaks = buildStreakList() + return HabitGroup( + habitList = habits, + habitGroupList = groups, + scores = scores, + streaks = streaks + ) + } fun buildComputedEntries(): EntryList fun buildOriginalEntries(): EntryList fun buildHabitList(): HabitList + fun buildHabitGroupList(): HabitGroupList fun buildScoreList(): ScoreList fun buildStreakList(): StreakList fun buildHabitListRepository(): Repository fun buildRepetitionListRepository(): Repository + fun buildHabitGroupListRepository(): Repository } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryHabitGroupList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryHabitGroupList.kt new file mode 100644 index 000000000..9502fdb28 --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryHabitGroupList.kt @@ -0,0 +1,216 @@ +package org.isoron.uhabits.core.models.memory + +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.models.HabitGroupList +import org.isoron.uhabits.core.models.HabitList.Order +import org.isoron.uhabits.core.models.HabitMatcher +import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset +import java.util.LinkedList +import java.util.Objects + +/** + * In-memory implementation of [HabitGroupList]. + */ +class MemoryHabitGroupList : HabitGroupList { + private val list = LinkedList() + + @get:Synchronized + override var primaryOrder = Order.BY_POSITION + set(value) { + field = value + comparator = getComposedComparatorByOrder(primaryOrder, secondaryOrder) + resort() + } + + @get:Synchronized + override var secondaryOrder = Order.BY_NAME_ASC + set(value) { + field = value + comparator = getComposedComparatorByOrder(primaryOrder, secondaryOrder) + resort() + } + + private var comparator: Comparator? = + getComposedComparatorByOrder(primaryOrder, secondaryOrder) + private var parent: MemoryHabitGroupList? = null + + constructor() : super() + constructor( + matcher: HabitMatcher, + comparator: Comparator?, + parent: MemoryHabitGroupList + ) : super(matcher) { + this.parent = parent + this.comparator = comparator + primaryOrder = parent.primaryOrder + secondaryOrder = parent.secondaryOrder + parent.observable.addListener { loadFromParent() } + loadFromParent() + } + + @Synchronized + @Throws(IllegalArgumentException::class) + override fun add(habitGroup: HabitGroup) { + throwIfHasParent() + require(!list.contains(habitGroup)) { "habit already added" } + val id = habitGroup.id + if (id != null && getById(id) != null) throw RuntimeException("duplicate id") + if (id == null) habitGroup.id = list.size.toLong() + list.addLast(habitGroup) + resort() + } + + @Synchronized + override fun getById(id: Long): HabitGroup? { + for (h in list) { + checkNotNull(h.id) + if (h.id == id) return h + } + return null + } + + @Synchronized + override fun getByUUID(uuid: String?): HabitGroup? { + for (h in list) if (Objects.requireNonNull(h.uuid) == uuid) return h + return null + } + + @Synchronized + override fun getByPosition(position: Int): HabitGroup { + return list[position] + } + + @Synchronized + override fun getFiltered(matcher: HabitMatcher?): HabitGroupList { + return MemoryHabitGroupList(matcher!!, comparator, this) + } + + private fun getComposedComparatorByOrder( + firstOrder: Order, + secondOrder: Order? + ): Comparator { + return Comparator { h1: HabitGroup, h2: HabitGroup -> + val firstResult = getComparatorByOrder(firstOrder).compare(h1, h2) + if (firstResult != 0 || secondOrder == null) { + return@Comparator firstResult + } + getComparatorByOrder(secondOrder).compare(h1, h2) + } + } + + private fun getComparatorByOrder(order: Order): Comparator { + val nameComparatorAsc = Comparator { habit1, habit2 -> + habit1.name.compareTo(habit2.name) + } + val nameComparatorDesc = + Comparator { h1: HabitGroup, h2: HabitGroup -> nameComparatorAsc.compare(h2, h1) } + val colorComparatorAsc = Comparator { (color1), (color2) -> + color1.compareTo(color2) + } + val colorComparatorDesc = + Comparator { h1: HabitGroup, h2: HabitGroup -> colorComparatorAsc.compare(h2, h1) } + val scoreComparatorDesc = + Comparator { habit1, habit2 -> + val today = getTodayWithOffset() + habit1.scores[today].value.compareTo(habit2.scores[today].value) + } + val scoreComparatorAsc = + Comparator { h1: HabitGroup, h2: HabitGroup -> scoreComparatorDesc.compare(h2, h1) } + val positionComparator = + Comparator { habit1, habit2 -> habit1.position.compareTo(habit2.position) } + val statusComparatorDesc = Comparator { h1: HabitGroup, h2: HabitGroup -> + if (h1.isCompletedToday() != h2.isCompletedToday()) { + return@Comparator if (h1.isCompletedToday()) -1 else 1 + } + val today = getTodayWithOffset() + val v1 = h1.scores[today].value + val v2 = h2.scores[today].value + v2.compareTo(v1) + } + val statusComparatorAsc = + Comparator { h1: HabitGroup, h2: HabitGroup -> statusComparatorDesc.compare(h2, h1) } + return when { + order === Order.BY_POSITION -> positionComparator + order === Order.BY_NAME_ASC -> nameComparatorAsc + order === Order.BY_NAME_DESC -> nameComparatorDesc + order === Order.BY_COLOR_ASC -> colorComparatorAsc + order === Order.BY_COLOR_DESC -> colorComparatorDesc + order === Order.BY_SCORE_DESC -> scoreComparatorDesc + order === Order.BY_SCORE_ASC -> scoreComparatorAsc + order === Order.BY_STATUS_DESC -> statusComparatorDesc + order === Order.BY_STATUS_ASC -> statusComparatorAsc + else -> throw IllegalStateException() + } + } + + @Synchronized + override fun indexOf(h: HabitGroup): Int { + return list.indexOf(h) + } + + @Synchronized + override fun iterator(): Iterator { + return ArrayList(list).iterator() + } + + @Synchronized + override fun remove(h: HabitGroup) { + throwIfHasParent() + list.remove(h) + observable.notifyListeners() + } + + @Synchronized + override fun reorder(from: HabitGroup, to: HabitGroup) { + throwIfHasParent() + check(!(primaryOrder !== Order.BY_POSITION)) { "cannot reorder automatically sorted list" } + require(indexOf(from) >= 0) { "list does not contain (from) habit" } + val toPos = indexOf(to) + require(toPos >= 0) { "list does not contain (to) habit" } + list.remove(from) + list.add(toPos, from) + var position = 0 + for (h in list) h.position = position++ + observable.notifyListeners() + } + + @Synchronized + override fun size(): Int { + return list.size + } + + @Synchronized + override fun update(habitGroups: List) { + resort() + } + + private fun throwIfHasParent() { + check(parent == null) { + "Filtered lists cannot be modified directly. " + + "You should modify the parent list instead." + } + } + + @Synchronized + private fun loadFromParent() { + checkNotNull(parent) + list.clear() + for (h in parent!!) if (filter.matches(h)) list.add(h) + resort() + } + + @Synchronized + override fun resort() { + for (hgr in list) { + hgr.habitList.primaryOrder = primaryOrder + hgr.habitList.secondaryOrder = secondaryOrder + hgr.habitList.resort() + + hgr.habitGroupList.primaryOrder = primaryOrder + hgr.habitGroupList.secondaryOrder = secondaryOrder + hgr.habitGroupList.resort() + } + if (comparator != null) list.sortWith(comparator!!) + observable.notifyListeners() + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryModelFactory.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryModelFactory.kt index d15f9603d..01a1dfe38 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryModelFactory.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryModelFactory.kt @@ -27,8 +27,10 @@ class MemoryModelFactory : ModelFactory { override fun buildComputedEntries() = EntryList() override fun buildOriginalEntries() = EntryList() override fun buildHabitList() = MemoryHabitList() + override fun buildHabitGroupList() = MemoryHabitGroupList() override fun buildScoreList() = ScoreList() override fun buildStreakList() = StreakList() override fun buildHabitListRepository() = throw NotImplementedError() override fun buildRepetitionListRepository() = throw NotImplementedError() + override fun buildHabitGroupListRepository() = throw NotImplementedError() } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLModelFactory.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLModelFactory.kt index 096397576..3e23967cd 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLModelFactory.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLModelFactory.kt @@ -25,6 +25,7 @@ import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.ScoreList import org.isoron.uhabits.core.models.StreakList import org.isoron.uhabits.core.models.sqlite.records.EntryRecord +import org.isoron.uhabits.core.models.sqlite.records.HabitGroupRecord import org.isoron.uhabits.core.models.sqlite.records.HabitRecord import javax.inject.Inject @@ -38,6 +39,8 @@ class SQLModelFactory override fun buildOriginalEntries() = SQLiteEntryList(database) override fun buildComputedEntries() = EntryList() override fun buildHabitList() = SQLiteHabitList(this) + override fun buildHabitGroupList() = SQLiteHabitGroupList(this) + override fun buildScoreList() = ScoreList() override fun buildStreakList() = StreakList() @@ -46,4 +49,7 @@ class SQLModelFactory override fun buildRepetitionListRepository() = Repository(EntryRecord::class.java, database) + + override fun buildHabitGroupListRepository() = + Repository(HabitGroupRecord::class.java, database) } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitGroupList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitGroupList.kt new file mode 100644 index 000000000..f9a44da65 --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitGroupList.kt @@ -0,0 +1,200 @@ +package org.isoron.uhabits.core.models.sqlite + +import org.isoron.uhabits.core.database.Repository +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.models.HabitGroupList +import org.isoron.uhabits.core.models.HabitList.Order +import org.isoron.uhabits.core.models.HabitMatcher +import org.isoron.uhabits.core.models.ModelFactory +import org.isoron.uhabits.core.models.memory.MemoryHabitGroupList +import org.isoron.uhabits.core.models.sqlite.records.HabitGroupRecord +import javax.inject.Inject + +/** + * Implementation of a [HabitGroupList] that is backed by SQLite. + */ +class SQLiteHabitGroupList @Inject constructor(private val modelFactory: ModelFactory) : HabitGroupList() { + private val repository: Repository = modelFactory.buildHabitGroupListRepository() + private val list: MemoryHabitGroupList = MemoryHabitGroupList() + private var loaded = false + private fun loadRecords() { + if (loaded) return + loaded = true + list.removeAll() + val records = repository.findAll("order by position") + var shouldRebuildOrder = false + for ((expectedPosition, rec) in records.withIndex()) { + if (rec.position != expectedPosition) shouldRebuildOrder = true + val h = modelFactory.buildHabitGroup() + rec.copyTo(h) + list.add(h) + } + if (shouldRebuildOrder) rebuildOrder() + } + + @Synchronized + override fun add(habitGroup: HabitGroup) { + loadRecords() + habitGroup.position = size() + val record = HabitGroupRecord() + record.copyFrom(habitGroup) + repository.save(record) + habitGroup.id = record.id + list.add(habitGroup) + observable.notifyListeners() + } + + @Synchronized + override fun getById(id: Long): HabitGroup? { + loadRecords() + return list.getById(id) + } + + @Synchronized + override fun getByUUID(uuid: String?): HabitGroup? { + loadRecords() + return list.getByUUID(uuid) + } + + @Synchronized + override fun getByPosition(position: Int): HabitGroup { + loadRecords() + return list.getByPosition(position) + } + + @Synchronized + override fun getFiltered(matcher: HabitMatcher?): HabitGroupList { + loadRecords() + return list.getFiltered(matcher) + } + + @set:Synchronized + override var primaryOrder: Order + get() = list.primaryOrder + set(order) { + list.primaryOrder = order + observable.notifyListeners() + } + + @set:Synchronized + override var secondaryOrder: Order + get() = list.secondaryOrder + set(order) { + list.secondaryOrder = order + observable.notifyListeners() + } + + @Synchronized + override fun indexOf(h: HabitGroup): Int { + loadRecords() + return list.indexOf(h) + } + + @Synchronized + override fun iterator(): Iterator { + loadRecords() + return list.iterator() + } + + @Synchronized + private fun rebuildOrder() { + val records = repository.findAll("order by position") + repository.executeAsTransaction { + for ((pos, r) in records.withIndex()) { + if (r.position != pos) { + r.position = pos + repository.save(r) + } + } + } + } + + @Synchronized + override fun remove(h: HabitGroup) { + loadRecords() + list.remove(h) + val record = repository.find( + h.id!! + ) ?: throw RuntimeException("habit not in database") + repository.executeAsTransaction { + repository.remove(record) + } + rebuildOrder() + observable.notifyListeners() + } + + @Synchronized + override fun removeAll() { + list.removeAll() + repository.execSQL("delete from habits") + repository.execSQL("delete from repetitions") + observable.notifyListeners() + } + + @Synchronized + override fun reorder(from: HabitGroup, to: HabitGroup) { + loadRecords() + list.reorder(from, to) + val fromRecord = repository.find( + from.id!! + ) + val toRecord = repository.find( + to.id!! + ) + if (fromRecord == null) throw RuntimeException("habit not in database") + if (toRecord == null) throw RuntimeException("habit not in database") + if (toRecord.position!! < fromRecord.position!!) { + repository.execSQL( + "update habits set position = position + 1 " + + "where position >= ? and position < ?", + toRecord.position!!, + fromRecord.position!! + ) + } else { + repository.execSQL( + "update habits set position = position - 1 " + + "where position > ? and position <= ?", + fromRecord.position!!, + toRecord.position!! + ) + } + fromRecord.position = toRecord.position + repository.save(fromRecord) + observable.notifyListeners() + } + + @Synchronized + override fun repair() { + loadRecords() + rebuildOrder() + observable.notifyListeners() + } + + @Synchronized + override fun size(): Int { + loadRecords() + return list.size() + } + + @Synchronized + override fun update(habitGroups: List) { + loadRecords() + list.update(habitGroups) + for (h in habitGroups) { + val record = repository.find(h.id!!) ?: continue + record.copyFrom(h) + repository.save(record) + } + observable.notifyListeners() + } + + override fun resort() { + list.resort() + observable.notifyListeners() + } + + @Synchronized + fun reload() { + loaded = false + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/HabitGroupRecord.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/HabitGroupRecord.kt new file mode 100644 index 000000000..0e5a90699 --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/HabitGroupRecord.kt @@ -0,0 +1,100 @@ +package org.isoron.uhabits.core.models.sqlite.records + +import org.isoron.uhabits.core.database.Column +import org.isoron.uhabits.core.database.Table +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.models.PaletteColor +import org.isoron.uhabits.core.models.Reminder +import org.isoron.uhabits.core.models.WeekdayList +import java.util.Objects.requireNonNull + +/** + * The SQLite database record corresponding to a [HabitGroup]. + */ +@Table(name = "habitgroups") +class HabitGroupRecord { + @field:Column + var description: String? = null + + @field:Column + var question: String? = null + + @field:Column + var name: String? = null + + @field:Column + var color: Int? = null + + @field:Column + var position: Int? = null + + @field:Column(name = "reminder_hour") + var reminderHour: Int? = null + + @field:Column(name = "reminder_min") + var reminderMin: Int? = null + + @field:Column(name = "reminder_days") + var reminderDays: Int? = null + + @field:Column + var highlight: Int? = null + + @field:Column + var archived: Int? = null + + @field:Column + var id: Long? = null + + @field:Column + var uuid: String? = null + + @field:Column(name = "parent_id") + var parentID: Long? = null + + @field:Column(name = "parent_uuid") + var parentUUID: String? = null + + fun copyFrom(model: HabitGroup) { + id = model.id + name = model.name + description = model.description + highlight = 0 + color = model.color.paletteIndex + archived = if (model.isArchived) 1 else 0 + position = model.position + question = model.question + uuid = model.uuid + reminderDays = 0 + reminderMin = null + reminderHour = null + parentID = model.parentID + parentUUID = model.parentUUID + if (model.hasReminder()) { + val reminder = model.reminder + reminderHour = requireNonNull(reminder)!!.hour + reminderMin = reminder!!.minute + reminderDays = reminder.days.toInteger() + } + } + + fun copyTo(habitGroup: HabitGroup) { + habitGroup.id = id + habitGroup.name = name!! + habitGroup.description = description!! + habitGroup.question = question!! + habitGroup.color = PaletteColor(color!!) + habitGroup.isArchived = archived != 0 + habitGroup.position = position!! + habitGroup.uuid = uuid + habitGroup.parentID = parentID + habitGroup.parentUUID = parentUUID + if (reminderHour != null && reminderMin != null) { + habitGroup.reminder = Reminder( + reminderHour!!, + reminderMin!!, + WeekdayList(reminderDays!!) + ) + } + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.kt index e4324a214..85412e5e4 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.kt @@ -95,6 +95,12 @@ class HabitRecord { @field:Column var uuid: String? = null + @field:Column(name = "parent_id") + var parentID: Long? = null + + @field:Column(name = "parent_uuid") + var parentUUID: String? = null + fun copyFrom(model: Habit) { id = model.id name = model.name @@ -109,6 +115,8 @@ class HabitRecord { position = model.position question = model.question uuid = model.uuid + parentID = model.parentID + parentUUID = model.parentUUID val (numerator, denominator) = model.frequency freqNum = numerator freqDen = denominator @@ -140,6 +148,8 @@ class HabitRecord { habit.unit = unit!! habit.position = position!! habit.uuid = uuid + habit.parentID = parentID + habit.parentUUID = parentUUID if (reminderHour != null && reminderMin != null) { habit.reminder = Reminder( reminderHour!!, diff --git a/uhabits-core/src/jvmMain/resources/migrations/26.sql b/uhabits-core/src/jvmMain/resources/migrations/26.sql index e8dc1d4a3..d934bd4b8 100644 --- a/uhabits-core/src/jvmMain/resources/migrations/26.sql +++ b/uhabits-core/src/jvmMain/resources/migrations/26.sql @@ -1,2 +1,21 @@ alter table Habits add column skip_days integer not null default 0; -alter table Habits add column skip_days_list integer not null default 0; \ No newline at end of file +alter table Habits add column skip_days_list integer not null default 0; +alter table Habits add column parent_id integer; +alter table Habits add column parent_uuid text; + +create table HabitGroups ( + id integer primary key autoincrement, + archived integer, + color integer, + description text not null default "", + highlight integer, + name text, + position integer, + reminder_days integer not null default 127, + reminder_hour integer, + reminder_min integer, + question text not null default "", + uuid text, + parent_id integer, + parent_uuid integer +); \ No newline at end of file