From 35c9a1a0aba5439a5bcb037c5bab9ebb2ebd3ae5 Mon Sep 17 00:00:00 2001 From: Dharanish Date: Fri, 5 Jul 2024 15:12:20 +0200 Subject: [PATCH] Implement listing sub habits --- .../habits/list/views/HabitCardListAdapter.kt | 23 +- .../habits/list/views/HabitCardView.kt | 15 +- .../isoron/uhabits/core/models/HabitList.kt | 14 + .../models/memory/MemoryHabitGroupList.kt | 6 +- .../core/models/memory/MemoryHabitList.kt | 11 + .../core/models/sqlite/SQLiteHabitList.kt | 13 + .../screens/habits/list/HabitCardListCache.kt | 515 ++++++++++++------ .../org/isoron/uhabits/core/BaseUnitTest.kt | 3 + .../habits/list/HabitCardListCacheTest.kt | 6 +- .../ListHabitsSelectionMenuBehaviorTest.kt | 1 + 10 files changed, 432 insertions(+), 175 deletions(-) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt index 8d1080ea5..27ae9be1a 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListAdapter.kt @@ -18,6 +18,7 @@ */ package org.isoron.uhabits.activities.habits.list.views +import android.annotation.SuppressLint import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView.Adapter import org.isoron.uhabits.activities.habits.list.MAX_CHECKMARK_COUNT @@ -74,9 +75,14 @@ class HabitCardListAdapter @Inject constructor( return cache.hasNoHabitGroup() } + fun hasNoSubHabits(): Boolean { + return cache.hasNoSubHabits() + } + /** * Sets all items as not selected. */ + @SuppressLint("NotifyDataSetChanged") override fun clearSelection() { selectedHabits.clear() selectedHabitGroups.clear() @@ -116,7 +122,7 @@ class HabitCardListAdapter @Inject constructor( } override fun getItemId(position: Int): Long { - val uuidString = getItemUUID(position) + val uuidString = cache.getUUIDByPosition(position) return if (uuidString != null) { val formattedUUIDString = formatUUID(uuidString) val uuid = UUID.fromString(formattedUUIDString) @@ -126,18 +132,6 @@ class HabitCardListAdapter @Inject constructor( } } - fun getItemUUID(position: Int): String? { - val h = cache.getHabitByPosition(position) - val hgr = cache.getHabitGroupByPosition(position) - return if (h != null) { - h.uuid!! - } else if (hgr != null) { - hgr.uuid!! - } else { - null - } - } - private fun formatUUID(uuidString: String): String { return uuidString.substring(0, 8) + "-" + uuidString.substring(8, 12) + "-" + @@ -207,7 +201,7 @@ class HabitCardListAdapter @Inject constructor( // function to override getItemViewType and return the type of the view. The view can either be a HabitCardView or a HabitGroupCardView override fun getItemViewType(position: Int): Int { - return if (position < cache.habitCount) { + return if (cache.getHabitByPosition(position) != null) { 0 } else { 1 @@ -322,6 +316,7 @@ class HabitCardListAdapter @Inject constructor( * * @param position position of the item to be toggled */ + @SuppressLint("NotifyDataSetChanged") fun toggleSelection(position: Int) { val h = cache.getHabitByPosition(position) val hgr = cache.getHabitGroupByPosition(position) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt index 596b25668..e047549b9 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardView.kt @@ -136,11 +136,10 @@ class HabitCardView( init { scoreRing = RingView(context).apply { val thickness = dp(3f) - val rightMargin = dp(8f).toInt() + val margin = dp(8f).toInt() val ringSize = dp(15f).toInt() - val leftMargin = if (habit?.isSubHabit() == true) dp(30f).toInt() else dp(8f).toInt() layoutParams = LinearLayout.LayoutParams(ringSize, ringSize).apply { - setMargins(leftMargin, 0, rightMargin, 0) + setMargins(margin, 0, margin, 0) gravity = Gravity.CENTER } setThickness(thickness) @@ -268,6 +267,16 @@ class HabitCardView( } scoreRing.apply { setColor(c) + if (h.isSubHabit()) { + val rightMargin = dp(8f).toInt() + val ringSize = dp(15f).toInt() + val leftMargin = + if (habit?.isSubHabit() == true) dp(30f).toInt() else dp(8f).toInt() + layoutParams = LinearLayout.LayoutParams(ringSize, ringSize).apply { + setMargins(leftMargin, 0, rightMargin, 0) + gravity = Gravity.CENTER + } + } } checkmarkPanel.apply { color = c diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitList.kt index 61db7ed76..54b19dac1 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/HabitList.kt @@ -67,6 +67,20 @@ abstract class HabitList : Iterable { @Throws(IllegalArgumentException::class) abstract fun add(habit: Habit) + /** + * Inserts a new habit in the list at the given position. + * + * 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 habit the habit to be inserted + * @throws IllegalArgumentException if the habit is already on the list. + */ + @Throws(IllegalArgumentException::class) + abstract fun add(position: Int, habit: Habit) + /** * Returns the habit with specified id. * 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 index bc80617ae..983514a4b 100644 --- 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 @@ -195,7 +195,11 @@ class MemoryHabitGroupList : HabitGroupList { private fun loadFromParent() { checkNotNull(parent) list.clear() - for (h in parent!!) if (filter.matches(h)) list.add(h) + for (h in parent!!) { + if (filter.matches(h)) { + list.add(h) + } + } resort() } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt index e0b9037a6..c17a113fb 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt @@ -77,6 +77,17 @@ class MemoryHabitList : HabitList { resort() } + @Synchronized + @Throws(IllegalArgumentException::class) + override fun add(position: Int, habit: Habit) { + throwIfHasParent() + require(!list.contains(habit)) { "habit already added" } + val id = habit.id + if (id != null && getById(id) != null) throw RuntimeException("duplicate id") + if (id == null) habit.id = list.size.toLong() + list.add(position, habit) + } + @Synchronized override fun getById(id: Long): Habit? { for (h in list) { diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt index f7ef12a96..c53144a90 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt @@ -64,6 +64,19 @@ class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory observable.notifyListeners() } + @Synchronized + override fun add(position: Int, habit: Habit) { + loadRecords() + habit.position = size() + val record = HabitRecord() + record.copyFrom(habit) + repository.save(record) + habit.id = record.id + (habit.originalEntries as SQLiteEntryList).habitId = record.id + list.add(position, habit) + observable.notifyListeners() + } + @Synchronized override fun getById(id: Long): Habit? { loadRecords() diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCache.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCache.kt index c18ea5320..0bab9a99c 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCache.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCache.kt @@ -53,8 +53,8 @@ import javax.inject.Inject */ @AppScope class HabitCardListCache @Inject constructor( - private val allHabits: HabitList, - private val allHabitGroups: HabitGroupList, + private val habits: HabitList, + private val habitGroups: HabitGroupList, private val commandRunner: CommandRunner, taskRunner: TaskRunner, logging: Logging @@ -68,6 +68,7 @@ class HabitCardListCache @Inject constructor( private val data: CacheData private var filteredHabits: HabitList private var filteredHabitGroups: HabitGroupList + private var filteredSubHabits: MutableList private val taskRunner: TaskRunner @Synchronized @@ -87,12 +88,17 @@ class HabitCardListCache @Inject constructor( @Synchronized fun hasNoHabit(): Boolean { - return allHabits.isEmpty + return habits.isEmpty } @Synchronized fun hasNoHabitGroup(): Boolean { - return allHabitGroups.isEmpty + return habitGroups.isEmpty + } + + @Synchronized + fun hasNoSubHabits(): Boolean { + return habitGroups.all { it.habitList.isEmpty } } /** @@ -103,11 +109,7 @@ class HabitCardListCache @Inject constructor( */ @Synchronized fun getHabitByPosition(position: Int): Habit? { - return if (position < 0 || position >= data.habits.size) { - null - } else { - data.habits[position] - } + return data.positionToHabit[position] } /** @@ -118,16 +120,21 @@ class HabitCardListCache @Inject constructor( */ @Synchronized fun getHabitGroupByPosition(position: Int): HabitGroup? { - return if (position < data.habits.size || position >= data.habits.size + data.habitGroups.size) { - null + return data.positionToHabitGroup[position] + } + + @Synchronized + fun getUUIDByPosition(position: Int): String? { + return if (data.positionTypes[position] == STANDALONE_HABIT || data.positionTypes[position] == SUB_HABIT) { + data.positionToHabit[position]!!.uuid } else { - data.habitGroups[position - data.habits.size] + data.positionToHabitGroup[position]!!.uuid } } @get:Synchronized val itemCount: Int - get() = habitCount + habitGroupCount + get() = habitCount + habitGroupCount + subHabitCount @get:Synchronized val habitCount: Int @@ -137,15 +144,20 @@ class HabitCardListCache @Inject constructor( val habitGroupCount: Int get() = data.habitGroups.size + @get:Synchronized + val subHabitCount: Int + get() = data.subHabits.sumOf { it.size() } + @get:Synchronized @set:Synchronized var primaryOrder: Order get() = filteredHabits.primaryOrder set(order) { - allHabits.primaryOrder = order + habits.primaryOrder = order + habitGroups.primaryOrder = order filteredHabits.primaryOrder = order - allHabitGroups.primaryOrder = order filteredHabitGroups.primaryOrder = order + filteredSubHabits.forEach { it.primaryOrder = order } refreshAllHabits() } @@ -154,16 +166,17 @@ class HabitCardListCache @Inject constructor( var secondaryOrder: Order get() = filteredHabits.secondaryOrder set(order) { - allHabits.secondaryOrder = order + habits.secondaryOrder = order + habitGroups.secondaryOrder = order filteredHabits.secondaryOrder = order - allHabitGroups.secondaryOrder = order filteredHabitGroups.secondaryOrder = order + filteredSubHabits.forEach { it.secondaryOrder = order } refreshAllHabits() } @Synchronized - fun getScore(habitUUID: String): Double { - return data.scores[habitUUID]!! + fun getScore(uuid: String): Double { + return data.scores[uuid]!! } @Synchronized @@ -201,42 +214,68 @@ class HabitCardListCache @Inject constructor( @Synchronized fun remove(uuid: String) { - val h = data.uuidToHabit[uuid] - if (h != null) { - val position = data.habits.indexOf(h) - data.habits.removeAt(position) - data.uuidToHabit.remove(uuid) - data.checkmarks.remove(uuid) - data.notes.remove(uuid) - data.scores.remove(uuid) - listener.onItemRemoved(position) - } else { + val type = data.positionTypes[data.uuidToPosition[uuid]!!] + if (type == STANDALONE_HABIT) { + val h = data.uuidToHabit[uuid] + if (h != null) { + val position = data.habits.indexOf(h) + data.habits.removeAt(position) + data.checkmarks.remove(uuid) + data.notes.remove(uuid) + data.scores.remove(uuid) + data.decrementPositions(position + 1, data.positionTypes.size) + listener.onItemRemoved(position) + } + } else if (type == SUB_HABIT) { + val h = data.uuidToHabit[uuid] + if (h != null) { + val position = data.uuidToPosition[uuid]!! + val hgrUUID = h.parentUUID + val hgr = data.uuidToHabitGroup[hgrUUID] + val hgrIdx = data.habitGroups.indexOf(hgr) + data.subHabits[hgrIdx].remove(h) + data.checkmarks.remove(uuid) + data.notes.remove(uuid) + data.scores.remove(uuid) + data.decrementPositions(position + 1, data.positionTypes.size) + listener.onItemRemoved(position) + } + } else if (type == HABIT_GROUP) { val hgr = data.uuidToHabitGroup[uuid] if (hgr != null) { - val position = data.habitGroups.indexOf(hgr) - data.habitGroups.removeAt(position) - data.uuidToHabitGroup.remove(uuid) - listener.onItemRemoved(position + data.habits.size) + val position = data.uuidToPosition[uuid]!! + val hgrIdx = data.habitGroups.indexOf(hgr) + + for (habit in data.subHabits[hgrIdx].reversed()) { + data.checkmarks.remove(habit.uuid) + data.notes.remove(habit.uuid) + data.scores.remove(habit.uuid) + listener.onItemRemoved(data.uuidToPosition[habit.uuid]!!) + } + data.subHabits.removeAt(hgrIdx) + data.habitGroups.removeAt(hgrIdx) + data.scores.remove(hgr.uuid) + data.rebuildPositions() + listener.onItemRemoved(position) } } } @Synchronized fun reorder(from: Int, to: Int) { - if (data.habits.size in (from + 1)..to || data.habits.size in (to + 1)..from) { - logger.error("reorder: from and to are in different sections") - return - } - if (from < data.habits.size) { - val fromHabit = data.habits[from] - data.habits.removeAt(from) - data.habits.add(to, fromHabit) + if (from == to) return + val uuid = if (data.positionTypes[from] == STANDALONE_HABIT) { + data.positionToHabit[from]!!.uuid } else { - val fromHabitGroup = data.habitGroups[from] - data.habitGroups.removeAt(from - data.habits.size) - data.habitGroups.add(to - data.habits.size, fromHabitGroup) + data.positionToHabitGroup[from]!!.uuid + } + if (data.positionTypes[from] == STANDALONE_HABIT) { + val habit = data.positionToHabit[from]!! + data.performMove(habit, from, to) + } else if (data.positionTypes[from] == HABIT_GROUP) { + val habitGroup = data.positionToHabitGroup[from]!! + data.performMove(habitGroup, from, to) } - listener.onItemMoved(from, to) } @Synchronized @@ -246,8 +285,11 @@ class HabitCardListCache @Inject constructor( @Synchronized fun setFilter(matcher: HabitMatcher) { - filteredHabits = allHabits.getFiltered(matcher) - filteredHabitGroups = allHabitGroups.getFiltered(matcher) + filteredHabits = habits.getFiltered(matcher) + filteredHabitGroups = habitGroups.getFiltered(matcher) + for (idx in filteredSubHabits.indices) { + filteredSubHabits[idx] = filteredSubHabits[idx].getFiltered(matcher) + } } @Synchronized @@ -272,6 +314,11 @@ class HabitCardListCache @Inject constructor( val uuidToHabitGroup: HashMap = HashMap() val habits: MutableList val habitGroups: MutableList + val subHabits: MutableList + val uuidToPosition: HashMap + val positionTypes: MutableList + val positionToHabit: HashMap + val positionToHabitGroup: HashMap val checkmarks: HashMap val scores: HashMap val notes: HashMap> @@ -327,14 +374,186 @@ class HabitCardListCache @Inject constructor( for (h in filteredHabits) { if (h.uuid == null) continue habits.add(h) - uuidToHabit[h.uuid] = h } for (hgr in filteredHabitGroups) { if (hgr.uuid == null) continue habitGroups.add(hgr) + val habitList = hgr.habitList + subHabits.add(habitList) + + for (h in habitList) { + if (h.uuid == null) continue + } + } + } + + @Synchronized + fun rebuildPositions() { + positionToHabit.clear() + positionToHabitGroup.clear() + uuidToPosition.clear() + positionTypes.clear() + var position = 0 + for (h in habits) { + uuidToHabit[h.uuid] = h + uuidToPosition[h.uuid] = position + positionToHabit[position] = h + positionTypes.add(STANDALONE_HABIT) + position++ + } + + for ((idx, hgr) in habitGroups.withIndex()) { uuidToHabitGroup[hgr.uuid] = hgr + uuidToPosition[hgr.uuid] = position + positionToHabitGroup[position] = hgr + positionTypes.add(HABIT_GROUP) + val habitList = subHabits[idx] + position++ + + for (h in habitList) { + uuidToHabit[h.uuid] = h + uuidToPosition[h.uuid] = position + positionToHabit[position] = h + positionTypes.add(SUB_HABIT) + position++ + } + } + } + + @Synchronized + fun isValidInsert(habit: Habit, position: Int): Boolean { + if (habit.parentUUID == null) { + return position <= habits.size + } else { + val parent = uuidToHabitGroup[habit.parentUUID] + if (parent == null) { + return false + } + val parentPosition = uuidToPosition[habit.parentUUID]!! + val parentIndex = habitGroups.indexOf(parent) + val nextGroup = habitGroups.getOrNull(parentIndex + 1) + val nextGroupPosition = uuidToPosition[nextGroup?.uuid] + return (position > parentPosition && position <= positionTypes.size) && (nextGroupPosition == null || position <= nextGroupPosition) + } + } + + @Synchronized + fun isValidInsert(habitGroup: HabitGroup, position: Int): Boolean { + return (position == positionTypes.size) || (positionTypes[position] == HABIT_GROUP) + } + + @Synchronized + fun incrementPositions(from: Int, to: Int) { + for (pos in positionToHabit.keys.sortedByDescending { it }) { + if (pos in from..to) { + positionToHabit[pos + 1] = positionToHabit[pos]!! + positionToHabit.remove(pos) + } + } + for (pos in positionToHabitGroup.keys.sortedByDescending { it }) { + if (pos in from..to) { + positionToHabitGroup[pos + 1] = positionToHabitGroup[pos]!! + positionToHabitGroup.remove(pos) + } + } + for ((key, pos) in uuidToPosition.entries) { + if (pos in from..to) { + uuidToPosition[key] = pos + 1 + } + } + } + + @Synchronized + fun decrementPositions(fromPosition: Int, toPosition: Int) { + positionTypes.removeAt(fromPosition) + for (pos in positionToHabit.keys.sortedBy { it }) { + if (pos in fromPosition..toPosition) { + positionToHabit[pos - 1] = positionToHabit[pos]!! + positionToHabit.remove(pos) + } + } + for (pos in positionToHabitGroup.keys.sortedBy { it }) { + if (pos in fromPosition..toPosition) { + positionToHabitGroup[pos - 1] = positionToHabitGroup[pos]!! + positionToHabitGroup.remove(pos) + } + } + for ((key, pos) in uuidToPosition.entries) { + if (pos in fromPosition..toPosition) { + uuidToPosition[key] = pos - 1 + } + } + } + + @Synchronized + fun performMove( + habit: Habit, + fromPosition: Int, + toPosition: Int + ) { + val type = positionTypes[fromPosition] + if (type == HABIT_GROUP) return + + // Workaround for https://github.com/iSoron/uhabits/issues/968 + val checkedToPosition = if (toPosition > positionTypes.size) { + logger.error("performMove: $toPosition for habit is strictly higher than ${habits.size}") + positionTypes.size + } else { + toPosition + } + + val verifyPosition = if (fromPosition > checkedToPosition) checkedToPosition else checkedToPosition + 1 + if (!isValidInsert(habit, verifyPosition)) return + + if (type == STANDALONE_HABIT) { + habits.removeAt(fromPosition) + if (fromPosition < checkedToPosition) { + decrementPositions(fromPosition + 1, checkedToPosition) + } else { + incrementPositions(toPosition, fromPosition - 1) + } + habits.add(checkedToPosition, habit) + positionTypes.add(checkedToPosition, STANDALONE_HABIT) + } else { + val hgr = uuidToHabitGroup[habit.parentUUID] + val hgrIdx = habitGroups.indexOf(hgr) + val h = positionToHabit[fromPosition]!! + subHabits[hgrIdx].remove(h) + if (fromPosition < checkedToPosition) { + decrementPositions(fromPosition + 1, checkedToPosition) + } else { + incrementPositions(toPosition, fromPosition - 1) + } + subHabits[hgrIdx].add(checkedToPosition - uuidToPosition[hgr!!.uuid]!! - 1, habit) + positionTypes.add(checkedToPosition, SUB_HABIT) } + + positionToHabit[checkedToPosition] = habit + uuidToPosition[habit.uuid] = checkedToPosition + listener.onItemMoved(fromPosition, checkedToPosition) + } + + @Synchronized + fun performMove( + habitGroup: HabitGroup, + fromPosition: Int, + toPosition: Int + ) { + if (positionTypes[fromPosition] != HABIT_GROUP) return + if (!isValidInsert(habitGroup, toPosition)) return + val fromIdx = habitGroups.indexOf(habitGroup) + val habitList = subHabits[fromIdx] + val toIdx = habitGroups.indexOf(positionToHabitGroup[toPosition]) - (if (fromPosition < toPosition) 1 else 0) + + habitGroups.removeAt(fromIdx) + subHabits.removeAt(fromIdx) + + habitGroups.add(toIdx, habitGroup) + subHabits.add(toIdx, habitList) + + rebuildPositions() + listener.onItemMoved(fromPosition, toPosition) } /** @@ -343,6 +562,11 @@ class HabitCardListCache @Inject constructor( init { habits = LinkedList() habitGroups = LinkedList() + subHabits = LinkedList() + positionTypes = LinkedList() + uuidToPosition = HashMap() + positionToHabit = HashMap() + positionToHabitGroup = HashMap() checkmarks = HashMap() scores = HashMap() notes = HashMap() @@ -374,35 +598,35 @@ class HabitCardListCache @Inject constructor( @Synchronized override fun doInBackground() { newData.fetchHabits() + newData.rebuildPositions() newData.copyScoresFrom(data) newData.copyCheckmarksFrom(data) newData.copyNoteIndicatorsFrom(data) val today = getTodayWithOffset() val dateFrom = today.minus(checkmarkCount - 1) if (runner != null) runner!!.publishProgress(this, -1) - for (position in newData.habits.indices) { + for ((position, type) in newData.positionTypes.withIndex()) { if (isCancelled) return - val habit = newData.habits[position] - if (targetUUID != null && targetUUID != habit.uuid) continue - newData.scores[habit.uuid] = habit.scores[today].value - val list: MutableList = ArrayList() - val notes: MutableList = ArrayList() - for ((_, value, note) in habit.computedEntries.getByInterval(dateFrom, today)) { - list.add(value) - notes.add(note) + if (type == STANDALONE_HABIT || type == SUB_HABIT) { + val habit = newData.positionToHabit[position]!! + if (targetUUID != null && targetUUID != habit.uuid) continue + newData.scores[habit.uuid] = habit.scores[today].value + val list: MutableList = ArrayList() + val notes: MutableList = ArrayList() + for ((_, value, note) in habit.computedEntries.getByInterval(dateFrom, today)) { + list.add(value) + notes.add(note) + } + val entries = list.toTypedArray() + newData.checkmarks[habit.uuid] = ArrayUtils.toPrimitive(entries) + newData.notes[habit.uuid] = notes.toTypedArray() + runner!!.publishProgress(this, position) + } else if (type == HABIT_GROUP) { + val habitGroup = newData.positionToHabitGroup[position]!! + if (targetUUID != null && targetUUID != habitGroup.uuid) continue + newData.scores[habitGroup.uuid] = habitGroup.scores[today].value + runner!!.publishProgress(this, position) } - val entries = list.toTypedArray() - newData.checkmarks[habit.uuid] = ArrayUtils.toPrimitive(entries) - newData.notes[habit.uuid] = notes.toTypedArray() - runner!!.publishProgress(this, position) - } - - for (position in newData.habitGroups.indices) { - if (isCancelled) return - val hgr = newData.habitGroups[position] - if (targetUUID != null && targetUUID != hgr.uuid) continue - newData.scores[hgr.uuid] = hgr.scores[today].value - runner!!.publishProgress(this, position + newData.habits.size) } } @@ -424,8 +648,21 @@ class HabitCardListCache @Inject constructor( @Synchronized private fun performInsert(habit: Habit, position: Int) { + if (!data.isValidInsert(habit, position)) return val uuid = habit.uuid - data.habits.add(position, habit) + if (habit.parentUUID == null) { + data.habits.add(position, habit) + data.positionTypes.add(position, STANDALONE_HABIT) + } else { +// val parent = data.uuidToHabitGroup[habit.parentUUID] +// val parentIdx = data.habitGroups.indexOf(parent) +// val parentPosition = data.uuidToPosition[habit.parentUUID]!! +// data.subHabits[parentIdx].add(position - parentPosition - 1, habit) + data.positionTypes.add(position, SUB_HABIT) + } + data.incrementPositions(position, data.positionTypes.size - 1) + data.positionToHabit[position] = habit + data.uuidToPosition[uuid] = position data.uuidToHabit[uuid] = habit data.scores[uuid] = newData.scores[uuid]!! data.checkmarks[uuid] = newData.checkmarks[uuid]!! @@ -435,62 +672,23 @@ class HabitCardListCache @Inject constructor( @Synchronized private fun performInsert(habitGroup: HabitGroup, position: Int) { - val newPosition = if (position < data.habits.size) { - data.habits.size - } else { - position - } + if (!data.isValidInsert(habitGroup, position)) return val uuid = habitGroup.uuid - data.habitGroups.add(newPosition - data.habits.size, habitGroup) - data.uuidToHabitGroup[uuid] = habitGroup - data.scores[uuid] = newData.scores[uuid]!! - listener.onItemInserted(newPosition) - } - - @Synchronized - private fun performMove( - habit: Habit, - fromPosition: Int, - toPosition: Int - ) { - data.habits.removeAt(fromPosition) + val prevIdx = newData.habitGroups.indexOf(habitGroup) + val habitList = newData.subHabits[prevIdx] + var idx = data.habitGroups.indexOf(data.positionToHabitGroup[position]) + if (idx < 0) idx = data.habitGroups.size - // Workaround for https://github.com/iSoron/uhabits/issues/968 - val checkedToPosition = if (toPosition > data.habits.size) { - logger.error("performMove: $toPosition for habit is strictly higher than ${data.habits.size}") - data.habits.size - } else { - toPosition - } - - data.habits.add(checkedToPosition, habit) - listener.onItemMoved(fromPosition, checkedToPosition) - } - - private fun performMove( - habitGroup: HabitGroup, - fromPosition: Int, - toPosition: Int - ) { - if (fromPosition < data.habits.size || fromPosition > data.habits.size + data.habitGroups.size) { - logger.error("performMove: $fromPosition for habit group is out of bounds") - return - } - data.habitGroups.removeAt(fromPosition - data.habits.size) - - // Workaround for https://github.com/iSoron/uhabits/issues/968 - val checkedToPosition = if (toPosition < data.habits.size) { - logger.error("performMove: $toPosition for habit group is strictly lower than ${data.habits.size}") - data.habits.size - } else if (toPosition > data.habits.size + data.habitGroups.size) { - logger.error("performMove: $toPosition for habit group is strictly higher than ${data.habits.size + data.habitGroups.size}") - data.habits.size + data.habitGroups.size - } else { - toPosition + data.habitGroups.add(idx, habitGroup) + data.subHabits.add(prevIdx, habitList) + data.scores[uuid] = newData.scores[uuid]!! + for (h in habitList) { + data.scores[h.uuid] = newData.scores[h.uuid]!! + data.checkmarks[h.uuid] = newData.checkmarks[h.uuid]!! + data.notes[h.uuid] = newData.notes[h.uuid]!! } - - data.habitGroups.add(checkedToPosition - data.habits.size, habitGroup) - listener.onItemMoved(fromPosition, checkedToPosition) + data.rebuildPositions() + listener.onItemInserted(position) } @Synchronized @@ -500,7 +698,7 @@ class HabitCardListCache @Inject constructor( val newScore = newData.scores[uuid]!! if (oldScore != newScore) unchanged = false - if (position < data.habits.size) { + if (data.positionTypes[position] != HABIT_GROUP) { val oldCheckmarks = data.checkmarks[uuid] val newCheckmarks = newData.checkmarks[uuid]!! val oldNoteIndicators = data.notes[uuid] @@ -519,38 +717,45 @@ class HabitCardListCache @Inject constructor( @Synchronized private fun processPosition(currentPosition: Int) { - if (currentPosition < newData.habits.size) { - val habit = newData.habits[currentPosition] - val uuid = habit.uuid - val prevPosition = data.habits.indexOf(habit) + val type = newData.positionTypes[currentPosition] + + if (type == STANDALONE_HABIT || type == SUB_HABIT) { + val habit = newData.positionToHabit[currentPosition]!! + val uuid = habit.uuid ?: throw NullPointerException() + val prevPosition = data.uuidToPosition[uuid] ?: -1 + val newPosition = if (type == STANDALONE_HABIT) { + currentPosition + } else { + val hgr = data.uuidToHabitGroup[habit.parentUUID] + val hgrIdx = data.habitGroups.indexOf(hgr) + newData.subHabits[hgrIdx].indexOf(habit) + data.uuidToPosition[hgr!!.uuid]!! + 1 + } if (prevPosition < 0) { - performInsert(habit, currentPosition) + performInsert(habit, newPosition) } else { - if (prevPosition != currentPosition) { - performMove( + if (prevPosition != newPosition) { + data.performMove( habit, prevPosition, - currentPosition + newPosition ) } - if (uuid == null) throw NullPointerException() performUpdate(uuid, currentPosition) } - } else { - val habitGroup = newData.habitGroups[currentPosition - data.habits.size] - val uuid = habitGroup.uuid - val prevPosition = data.habitGroups.indexOf(habitGroup) + data.habits.size - if (prevPosition < data.habits.size) { + } else if (type == HABIT_GROUP) { + val habitGroup = newData.positionToHabitGroup[currentPosition]!! + val uuid = habitGroup.uuid ?: throw NullPointerException() + val prevPosition = data.uuidToPosition[uuid] ?: -1 + if (prevPosition < 0) { performInsert(habitGroup, currentPosition) } else { if (prevPosition != currentPosition) { - performMove( + data.performMove( habitGroup, prevPosition, currentPosition ) } - if (uuid == null) throw NullPointerException() performUpdate(uuid, currentPosition) } } @@ -558,27 +763,29 @@ class HabitCardListCache @Inject constructor( @Synchronized private fun processRemovedHabits() { - val before: Set = data.uuidToHabit.keys - val after: Set = newData.uuidToHabit.keys + val before: Set = (data.uuidToHabit.keys).union(data.uuidToHabitGroup.keys) + val after: Set = (newData.uuidToHabit.keys).union(newData.uuidToHabitGroup.keys) val removed: MutableSet = TreeSet(before) removed.removeAll(after) - for (uuid in removed) remove(uuid!!) - processRemovedHabitGroups() + for (uuid in removed.sortedBy { uuid -> data.uuidToPosition[uuid] }) remove(uuid!!) } + } - @Synchronized - private fun processRemovedHabitGroups() { - val before: Set = data.uuidToHabitGroup.keys - val after: Set = newData.uuidToHabitGroup.keys - val removed: MutableSet = TreeSet(before) - removed.removeAll(after) - for (uuid in removed) remove(uuid!!) - } + companion object { + const val STANDALONE_HABIT = 0 + const val HABIT_GROUP = 1 + const val SUB_HABIT = 2 } init { - filteredHabits = allHabits - filteredHabitGroups = allHabitGroups + filteredHabits = habits + filteredHabitGroups = habitGroups + filteredSubHabits = LinkedList() + for (hgr in habitGroups) { + val subList = hgr.habitList + filteredSubHabits.add(subList) + } + this.taskRunner = taskRunner listener = object : Listener {} data = CacheData() diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/BaseUnitTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/BaseUnitTest.kt index b8fd183db..ca5ca5b6c 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/BaseUnitTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/BaseUnitTest.kt @@ -24,6 +24,7 @@ import org.isoron.uhabits.core.database.Database import org.isoron.uhabits.core.database.DatabaseOpener import org.isoron.uhabits.core.database.JdbcDatabase import org.isoron.uhabits.core.database.MigrationHelper +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.Timestamp @@ -52,6 +53,7 @@ import java.sql.SQLException @RunWith(MockitoJUnitRunner::class) open class BaseUnitTest { protected open lateinit var habitList: HabitList + protected open lateinit var habitGroupList: HabitGroupList protected lateinit var fixtures: HabitFixtures protected lateinit var modelFactory: ModelFactory protected lateinit var taskRunner: SingleThreadTaskRunner @@ -80,6 +82,7 @@ open class BaseUnitTest { setStartDayOffset(0, 0) val memoryModelFactory = MemoryModelFactory() habitList = spy(memoryModelFactory.buildHabitList()) + habitGroupList = spy(memoryModelFactory.buildHabitGroupList()) fixtures = HabitFixtures(memoryModelFactory, habitList) modelFactory = memoryModelFactory taskRunner = SingleThreadTaskRunner() diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCacheTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCacheTest.kt index e3ff88c18..264a64c6c 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCacheTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCacheTest.kt @@ -43,7 +43,7 @@ class HabitCardListCacheTest : BaseUnitTest() { for (i in 0..9) { if (i == 3) habitList.add(fixtures.createLongHabit()) else habitList.add(fixtures.createShortHabit()) } - cache = HabitCardListCache(habitList, commandRunner, taskRunner, mock()) + cache = HabitCardListCache(habitList, habitGroupList, commandRunner, taskRunner, mock()) cache.setCheckmarkCount(10) cache.refreshAllHabits() cache.onAttached() @@ -82,8 +82,8 @@ class HabitCardListCacheTest : BaseUnitTest() { val h = habitList.getByPosition(3) val score = h.scores[today].value assertThat(cache.getHabitByPosition(3), equalTo(h)) - assertThat(cache.getScore(h.id!!), equalTo(score)) - val actualCheckmarks = cache.getCheckmarks(h.id!!) + assertThat(cache.getScore(h.uuid!!), equalTo(score)) + val actualCheckmarks = cache.getCheckmarks(h.uuid!!) val expectedCheckmarks = h .computedEntries diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehaviorTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehaviorTest.kt index edb08d934..5f5d86696 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehaviorTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehaviorTest.kt @@ -140,6 +140,7 @@ class ListHabitsSelectionMenuBehaviorTest : BaseUnitTest() { habitList.add(habit3) behavior = ListHabitsSelectionMenuBehavior( habitList, + habitGroupList, screen, adapter, commandRunner