From 506086f003d63295a2479df87dfd2771f0a5eb2a Mon Sep 17 00:00:00 2001 From: Dharanish Date: Mon, 1 Jul 2024 23:33:38 +0200 Subject: [PATCH] Can show habit group without interaction / scrolling --- .../habits/list/views/HabitCardViewTest.kt | 2 +- .../habits/list/ListHabitsRootView.kt | 2 +- .../habits/list/ListHabitsSelectionMenu.kt | 4 +- .../habits/list/views/AddButtonView.kt | 72 ++++ .../habits/list/views/HabitCardListAdapter.kt | 111 ++++-- .../habits/list/views/HabitCardListView.kt | 29 +- .../habits/list/views/HabitCardView.kt | 30 +- .../habits/list/views/HabitCardViewHolder.kt | 2 +- .../habits/list/views/HabitGroupCardView.kt | 13 +- .../list/views/HabitGroupCardViewHolder.kt | 5 - .../src/main/res/values/fontawesome.xml | 1 + .../isoron/uhabits/core/models/ScoreList.kt | 2 +- .../screens/habits/list/HabitCardListCache.kt | 333 +++++++++++++----- .../list/ListHabitsSelectionMenuBehavior.kt | 3 + 14 files changed, 477 insertions(+), 132 deletions(-) create mode 100644 uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/AddButtonView.kt delete mode 100644 uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardViewHolder.kt diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt index 20474090a..ad237f630 100644 --- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewTest.kt @@ -53,7 +53,7 @@ class HabitCardViewTest : BaseViewTest() { .getByInterval(today.minus(300), today) .map { it.value }.toIntArray() - view = component.getHabitCardViewFactory().create().apply { + view = component.getHabitCardViewFactory().createHabitCard().apply { habit = habit1 values = entries score = habit1.scores[today].value diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt index f0a542a0d..35de927d4 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsRootView.kt @@ -148,7 +148,7 @@ class ListHabitsRootView @Inject constructor( private fun updateEmptyView() { if (listAdapter.itemCount == 0) { - if (listAdapter.hasNoHabit()) { + if (listAdapter.hasNoHabit() && listAdapter.hasNoHabitGroup()) { llEmpty.showEmpty() } else { llEmpty.showDone() diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt index ce5bc45a0..03e50fa6d 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsSelectionMenu.kt @@ -82,7 +82,7 @@ class ListHabitsSelectionMenu @Inject constructor( itemArchive.isVisible = behavior.canArchive() itemUnarchive.isVisible = behavior.canUnarchive() itemNotify.isVisible = prefs.isDeveloper - activeActionMode?.title = listAdapter.selected.size.toString() + activeActionMode?.title = listAdapter.selectedHabits.size.toString() return true } override fun onDestroyActionMode(mode: ActionMode?) { @@ -117,7 +117,7 @@ class ListHabitsSelectionMenu @Inject constructor( } R.id.action_notify -> { - for (h in listAdapter.selected) + for (h in listAdapter.selectedHabits) notificationTray.show(h, DateUtils.getToday(), 0) return true } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/AddButtonView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/AddButtonView.kt new file mode 100644 index 000000000..04477f889 --- /dev/null +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/AddButtonView.kt @@ -0,0 +1,72 @@ +package org.isoron.uhabits.activities.habits.list.views + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.text.TextPaint +import android.view.View +import android.view.View.MeasureSpec.EXACTLY +import org.isoron.uhabits.R +import org.isoron.uhabits.utils.getFontAwesome +import org.isoron.uhabits.utils.sp +import org.isoron.uhabits.utils.sres +import org.isoron.uhabits.utils.toMeasureSpec + +class AddButtonView( + context: Context +) : View(context), + View.OnClickListener { + + var onEdit: () -> Unit = { } + + private var drawer = Drawer() + + init { + setOnClickListener(this) + } + + override fun onClick(v: View) { + onEdit() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + drawer.draw(canvas) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val height = resources.getDimensionPixelSize(R.dimen.checkmarkHeight) + val width = resources.getDimensionPixelSize(R.dimen.checkmarkWidth) + super.onMeasure( + width.toMeasureSpec(EXACTLY), + height.toMeasureSpec(EXACTLY) + ) + } + + private inner class Drawer { + private val rect = RectF() + private val highContrastColor = sres.getColor(R.attr.contrast100) + + private val paint = TextPaint().apply { + typeface = getFontAwesome() + isAntiAlias = true + textAlign = Paint.Align.CENTER + } + + fun draw(canvas: Canvas) { + paint.color = highContrastColor + val id = R.string.fa_plus + paint.textSize = sp(12.0f) + paint.strokeWidth = 0f + paint.style = Paint.Style.FILL + + val label = resources.getString(id) + val em = paint.measureText("m") + + rect.set(0f, 0f, width.toFloat(), height.toFloat()) + rect.offset(0f, 0.4f * em) + canvas.drawText(label, rect.centerX(), rect.centerY(), paint) + } + } +} 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 b9c6ffaa4..6f266a207 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 @@ -19,9 +19,10 @@ package org.isoron.uhabits.activities.habits.list.views import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Adapter import org.isoron.uhabits.activities.habits.list.MAX_CHECKMARK_COUNT import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitMatcher import org.isoron.uhabits.core.models.ModelObservable @@ -32,6 +33,7 @@ import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsSelectionMenuBeh import org.isoron.uhabits.core.utils.MidnightTimer import org.isoron.uhabits.inject.ActivityScope import java.util.LinkedList +import java.util.UUID import javax.inject.Inject /** @@ -46,14 +48,16 @@ class HabitCardListAdapter @Inject constructor( private val cache: HabitCardListCache, private val preferences: Preferences, private val midnightTimer: MidnightTimer -) : RecyclerView.Adapter(), +) : Adapter(), HabitCardListCache.Listener, MidnightTimer.MidnightListener, ListHabitsMenuBehavior.Adapter, ListHabitsSelectionMenuBehavior.Adapter { val observable: ModelObservable = ModelObservable() private var listView: HabitCardListView? = null - val selected: LinkedList = LinkedList() + val selectedHabits: LinkedList = LinkedList() + val selectedHabitGroups: LinkedList = LinkedList() + override fun atMidnight() { cache.refreshAllHabits() } @@ -66,17 +70,25 @@ class HabitCardListAdapter @Inject constructor( return cache.hasNoHabit() } + fun hasNoHabitGroup(): Boolean { + return cache.hasNoHabitGroup() + } + /** * Sets all items as not selected. */ override fun clearSelection() { - selected.clear() + selectedHabits.clear() notifyDataSetChanged() observable.notifyListeners() } override fun getSelected(): List { - return ArrayList(selected) + return ArrayList(selectedHabits) + } + + override fun getSelectedHabitGroups(): List { + return ArrayList(selectedHabitGroups) } /** @@ -91,11 +103,38 @@ class HabitCardListAdapter @Inject constructor( } override fun getItemCount(): Int { - return cache.habitCount + return cache.itemCount } override fun getItemId(position: Int): Long { - return getItem(position)!!.id!! + val uuidString = getItemUUID(position) + return if (uuidString != null) { + val formattedUUIDString = formatUUID(uuidString) + val uuid = UUID.fromString(formattedUUIDString) + uuid.mostSignificantBits and Long.MAX_VALUE + } else { + -1 + } + } + + 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) + "-" + + uuidString.substring(12, 16) + "-" + + uuidString.substring(16, 20) + "-" + + uuidString.substring(20, 32) } /** @@ -104,7 +143,7 @@ class HabitCardListAdapter @Inject constructor( * @return true if selection is empty, false otherwise */ val isSelectionEmpty: Boolean - get() = selected.isEmpty() + get() = selectedHabits.isEmpty() && selectedHabitGroups.isEmpty() val isSortable: Boolean get() = cache.primaryOrder == HabitList.Order.BY_POSITION @@ -122,11 +161,18 @@ class HabitCardListAdapter @Inject constructor( ) { if (listView == null) return val habit = cache.getHabitByPosition(position) - val score = cache.getScore(habit!!.id!!) - val checkmarks = cache.getCheckmarks(habit.id!!) - val notes = cache.getNotes(habit.id!!) - val selected = selected.contains(habit) - listView!!.bindCardView(holder, habit, score, checkmarks, notes, selected) + if (habit != null) { + val score = cache.getScore(habit.uuid!!) + val checkmarks = cache.getCheckmarks(habit.uuid!!) + val notes = cache.getNotes(habit.uuid!!) + val selected = selectedHabits.contains(habit) + listView!!.bindCardView(holder, habit, score, checkmarks, notes, selected) + } else { + val habitGroup = cache.getHabitGroupByPosition(position) + val score = cache.getScore(habitGroup!!.uuid!!) + val selected = selectedHabitGroups.contains(habitGroup) + listView!!.bindGroupCardView(holder, habitGroup, score, selected) + } } override fun onViewAttachedToWindow(holder: HabitCardViewHolder) { @@ -141,8 +187,22 @@ class HabitCardListAdapter @Inject constructor( parent: ViewGroup, viewType: Int ): HabitCardViewHolder { - val view = listView!!.createHabitCardView() - return HabitCardViewHolder(view) + if (viewType == 0) { + val view = listView!!.createHabitCardView() + return HabitCardViewHolder(view, null) + } else { + val view = listView!!.createHabitGroupCardView() + return HabitCardViewHolder(null, view) + } + } + + // 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) { + 0 + } else { + 1 + } } /** @@ -190,7 +250,11 @@ class HabitCardListAdapter @Inject constructor( * @param selected list of habits to be removed */ override fun performRemove(selected: List) { - for (habit in selected) cache.remove(habit.id!!) + for (habit in selected) cache.remove(habit.uuid!!) + } + + override fun performRemoveHabitGroup(selected: List) { + for (hgr in selected) cache.remove(hgr.uuid!!) } /** @@ -250,10 +314,17 @@ class HabitCardListAdapter @Inject constructor( * @param position position of the item to be toggled */ fun toggleSelection(position: Int) { - val h = getItem(position) ?: return - val k = selected.indexOf(h) - if (k < 0) selected.add(h) else selected.remove(h) - notifyDataSetChanged() + val h = cache.getHabitByPosition(position) + val hgr = cache.getHabitGroupByPosition(position) + if (h != null) { + val k = selectedHabits.indexOf(h) + if (k < 0) selectedHabits.add(h) else selectedHabits.remove(h) + notifyDataSetChanged() + } else if (hgr != null) { + val k = selectedHabitGroups.indexOf(hgr) + if (k < 0) selectedHabitGroups.add(hgr) else selectedHabitGroups.remove(hgr) + notifyDataSetChanged() + } } init { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt index fd6df425f..ceb20855c 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardListView.kt @@ -36,6 +36,7 @@ import dagger.Lazy import org.isoron.uhabits.R import org.isoron.uhabits.activities.common.views.BundleSavedState import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.inject.ActivityContext import javax.inject.Inject @@ -79,7 +80,11 @@ class HabitCardListView( } fun createHabitCardView(): HabitCardView { - return cardViewFactory.create() + return cardViewFactory.createHabitCard() + } + + fun createHabitGroupCardView(): HabitGroupCardView { + return cardViewFactory.createHabitGroupCard() } fun bindCardView( @@ -110,8 +115,28 @@ class HabitCardListView( return cardView } + fun bindGroupCardView( + holder: HabitCardViewHolder, + habitGroup: HabitGroup, + score: Double, + selected: Boolean + ): View { + val cardView = holder.itemView as HabitGroupCardView + cardView.habitGroup = habitGroup + cardView.isSelected = selected + cardView.score = score + + val detector = GestureDetector(context, CardViewGestureDetector(holder)) + cardView.setOnTouchListener { _, ev -> + detector.onTouchEvent(ev) + true + } + + return cardView + } + fun attachCardView(holder: HabitCardViewHolder) { - (holder.itemView as HabitCardView).dataOffset = dataOffset + (holder.itemView as? HabitCardView)?.dataOffset = dataOffset attachedHolders.add(holder) } 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 84bd001fc..c2e0ad8f6 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 @@ -38,6 +38,7 @@ import org.isoron.platform.gui.toInt import org.isoron.uhabits.R import org.isoron.uhabits.activities.common.views.RingView import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.ModelObservable import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior @@ -55,7 +56,8 @@ class HabitCardViewFactory private val numberPanelFactory: NumberPanelViewFactory, private val behavior: ListHabitsBehavior ) { - fun create() = HabitCardView(context, checkmarkPanelFactory, numberPanelFactory, behavior) + fun createHabitCard() = HabitCardView(context, checkmarkPanelFactory, numberPanelFactory, behavior) + fun createHabitGroupCard() = HabitGroupCardView(context, behavior) } class HabitCardView( @@ -285,6 +287,32 @@ class HabitCardView( } } + private fun copyAttributesFrom(hgr: HabitGroup) { + fun getActiveColor(habitGroup: HabitGroup): Int { + return when (habitGroup.isArchived) { + true -> sres.getColor(R.attr.contrast60) + false -> currentTheme().color(habitGroup.color).toInt() + } + } + + val c = getActiveColor(hgr) + label.apply { + text = hgr.name + setTextColor(c) + } + scoreRing.apply { + setColor(c) + } + checkmarkPanel.apply { + color = c + visibility = View.GONE + } + numberPanel.apply { + color = c + visibility = View.GONE + } + } + private fun triggerRipple(x: Float, y: Float) { val background = innerFrame.background background.setHotspot(x, y) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt index e01159360..84fc26a2b 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitCardViewHolder.kt @@ -21,4 +21,4 @@ package org.isoron.uhabits.activities.habits.list.views import androidx.recyclerview.widget.RecyclerView -class HabitCardViewHolder(itemView: HabitCardView) : RecyclerView.ViewHolder(itemView) +class HabitCardViewHolder(itemView1: HabitCardView?, itemView2: HabitGroupCardView?) : RecyclerView.ViewHolder(itemView1 ?: itemView2!!) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardView.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardView.kt index 12832b4e4..bc7495e40 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardView.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardView.kt @@ -23,15 +23,6 @@ import org.isoron.uhabits.inject.ActivityContext import org.isoron.uhabits.utils.currentTheme import org.isoron.uhabits.utils.dp import org.isoron.uhabits.utils.sres -import javax.inject.Inject - -class HabitGroupCardViewFactory -@Inject constructor( - @ActivityContext val context: Context, - private val behavior: ListHabitsBehavior -) { - fun create() = HabitGroupCardView(context, behavior) -} class HabitGroupCardView( @ActivityContext context: Context, @@ -56,6 +47,7 @@ class HabitGroupCardView( scoreRing.setPrecision(1.0f / 16) } + var addButtonView: AddButtonView private var innerFrame: LinearLayout private var label: TextView private var scoreRing: RingView @@ -83,6 +75,8 @@ class HabitGroupCardView( } } + addButtonView = AddButtonView(context) + innerFrame = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL orientation = LinearLayout.HORIZONTAL @@ -91,6 +85,7 @@ class HabitGroupCardView( addView(scoreRing) addView(label) + addView(addButtonView) setOnTouchListener { v, event -> v.background.setHotspot(event.x, event.y) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardViewHolder.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardViewHolder.kt deleted file mode 100644 index 740d85009..000000000 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/views/HabitGroupCardViewHolder.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.isoron.uhabits.activities.habits.list.views - -import androidx.recyclerview.widget.RecyclerView - -class HabitGroupCardViewHolder(itemView: HabitGroupCardView) : RecyclerView.ViewHolder(itemView) diff --git a/uhabits-android/src/main/res/values/fontawesome.xml b/uhabits-android/src/main/res/values/fontawesome.xml index 0ff190382..53e20cc46 100644 --- a/uhabits-android/src/main/res/values/fontawesome.xml +++ b/uhabits-android/src/main/res/values/fontawesome.xml @@ -24,6 +24,7 @@ + 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 bc6d139a1..89fff0dfc 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 @@ -146,7 +146,7 @@ class ScoreList { var current = to while (current >= from) { val habitScores = habitList.map { it.scores[current].value } - val averageScore = habitScores.average() + val averageScore = if (habitScores.isNotEmpty()) habitScores.average() else 0.0 map[current] = Score(current, averageScore) current = current.minus(1) } 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 c7d861813..9b33b64f9 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 @@ -25,15 +25,15 @@ import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CreateRepetitionCommand import org.isoron.uhabits.core.io.Logging import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup +import org.isoron.uhabits.core.models.HabitGroupList import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitList.Order import org.isoron.uhabits.core.models.HabitMatcher import org.isoron.uhabits.core.tasks.Task import org.isoron.uhabits.core.tasks.TaskRunner import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset -import java.util.ArrayList import java.util.Arrays -import java.util.HashMap import java.util.LinkedList import java.util.TreeSet import javax.inject.Inject @@ -54,6 +54,7 @@ import javax.inject.Inject @AppScope class HabitCardListCache @Inject constructor( private val allHabits: HabitList, + private val allHabitGroups: HabitGroupList, private val commandRunner: CommandRunner, taskRunner: TaskRunner, logging: Logging @@ -66,6 +67,7 @@ class HabitCardListCache @Inject constructor( private var listener: Listener private val data: CacheData private var filteredHabits: HabitList + private var filteredHabitGroups: HabitGroupList private val taskRunner: TaskRunner @Synchronized @@ -74,13 +76,13 @@ class HabitCardListCache @Inject constructor( } @Synchronized - fun getCheckmarks(habitId: Long): IntArray { - return data.checkmarks[habitId]!! + fun getCheckmarks(habitUUID: String): IntArray { + return data.checkmarks[habitUUID]!! } @Synchronized - fun getNotes(habitId: Long): Array { - return data.notes[habitId]!! + fun getNotes(habitUUID: String): Array { + return data.notes[habitUUID]!! } @Synchronized @@ -88,21 +90,53 @@ class HabitCardListCache @Inject constructor( return allHabits.isEmpty } + @Synchronized + fun hasNoHabitGroup(): Boolean { + return allHabitGroups.isEmpty + } + /** * Returns the habits that occupies a certain position on the list. * - * @param position the position of the habit + * @param position the position of the list of habits and groups * @return the habit at given position or null if position is invalid */ @Synchronized fun getHabitByPosition(position: Int): Habit? { - return if (position < 0 || position >= data.habits.size) null else data.habits[position] + return if (position < 0 || position >= data.habits.size) { + null + } else { + data.habits[position] + } + } + + /** + * Returns the habit groups that occupies a certain position on the list. + * + * @param position the position of the list of habits and groups + * @return the habit group at given position or null if position is invalid + */ + @Synchronized + fun getHabitGroupByPosition(position: Int): HabitGroup? { + return if (position < data.habits.size || position >= data.habits.size + data.habitGroups.size) { + null + } else { + data.habitGroups[position - data.habits.size] + } } + @get:Synchronized + val itemCount: Int + get() = habitCount + habitGroupCount + @get:Synchronized val habitCount: Int get() = data.habits.size + @get:Synchronized + val habitGroupCount: Int + get() = data.habitGroups.size + @get:Synchronized @set:Synchronized var primaryOrder: Order @@ -110,6 +144,8 @@ class HabitCardListCache @Inject constructor( set(order) { allHabits.primaryOrder = order filteredHabits.primaryOrder = order + allHabitGroups.primaryOrder = order + filteredHabitGroups.primaryOrder = order refreshAllHabits() } @@ -120,12 +156,14 @@ class HabitCardListCache @Inject constructor( set(order) { allHabits.secondaryOrder = order filteredHabits.secondaryOrder = order + allHabitGroups.secondaryOrder = order + filteredHabitGroups.secondaryOrder = order refreshAllHabits() } @Synchronized - fun getScore(habitId: Long): Double { - return data.scores[habitId]!! + fun getScore(habitUUID: String): Double { + return data.scores[habitUUID]!! } @Synchronized @@ -137,7 +175,7 @@ class HabitCardListCache @Inject constructor( @Synchronized override fun onCommandFinished(command: Command) { if (command is CreateRepetitionCommand) { - command.habit.id?.let { refreshHabit(it) } + command.habit.uuid?.let { refreshHabit(it) } } else { refreshAllHabits() } @@ -157,27 +195,47 @@ class HabitCardListCache @Inject constructor( } @Synchronized - fun refreshHabit(id: Long) { - taskRunner.execute(RefreshTask(id)) + fun refreshHabit(uuid: String) { + taskRunner.execute(RefreshTask(uuid)) } @Synchronized - fun remove(id: Long) { - val h = data.idToHabit[id] ?: return - val position = data.habits.indexOf(h) - data.habits.removeAt(position) - data.idToHabit.remove(id) - data.checkmarks.remove(id) - data.notes.remove(id) - data.scores.remove(id) - listener.onItemRemoved(position) + 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 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) + } + } } @Synchronized fun reorder(from: Int, to: Int) { - val fromHabit = data.habits[from] - data.habits.removeAt(from) - data.habits.add(to, fromHabit) + 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) + } else { + val fromHabitGroup = data.habitGroups[from] + data.habitGroups.removeAt(from - data.habits.size) + data.habitGroups.add(to - data.habits.size, fromHabitGroup) + } listener.onItemMoved(from, to) } @@ -189,6 +247,7 @@ class HabitCardListCache @Inject constructor( @Synchronized fun setFilter(matcher: HabitMatcher) { filteredHabits = allHabits.getFiltered(matcher) + filteredHabitGroups = allHabitGroups.getFiltered(matcher) } @Synchronized @@ -209,21 +268,23 @@ class HabitCardListCache @Inject constructor( } private inner class CacheData { - val idToHabit: HashMap = HashMap() + val uuidToHabit: HashMap = HashMap() + val uuidToHabitGroup: HashMap = HashMap() val habits: MutableList - val checkmarks: HashMap - val scores: HashMap - val notes: HashMap> + val habitGroups: MutableList + val checkmarks: HashMap + val scores: HashMap + val notes: HashMap> @Synchronized fun copyCheckmarksFrom(oldData: CacheData) { val empty = IntArray(checkmarkCount) - for (id in idToHabit.keys) { - if (oldData.checkmarks.containsKey(id)) { - checkmarks[id] = - oldData.checkmarks[id]!! + for (uuid in uuidToHabit.keys) { + if (oldData.checkmarks.containsKey(uuid)) { + checkmarks[uuid] = + oldData.checkmarks[uuid]!! } else { - checkmarks[id] = empty + checkmarks[uuid] = empty } } } @@ -231,24 +292,32 @@ class HabitCardListCache @Inject constructor( @Synchronized fun copyNoteIndicatorsFrom(oldData: CacheData) { val empty = (0..checkmarkCount).map { "" }.toTypedArray() - for (id in idToHabit.keys) { - if (oldData.notes.containsKey(id)) { - notes[id] = - oldData.notes[id]!! + for (uuid in uuidToHabit.keys) { + if (oldData.notes.containsKey(uuid)) { + notes[uuid] = + oldData.notes[uuid]!! } else { - notes[id] = empty + notes[uuid] = empty } } } @Synchronized fun copyScoresFrom(oldData: CacheData) { - for (id in idToHabit.keys) { - if (oldData.scores.containsKey(id)) { - scores[id] = - oldData.scores[id]!! + for (uuid in uuidToHabit.keys) { + if (oldData.scores.containsKey(uuid)) { + scores[uuid] = + oldData.scores[uuid]!! } else { - scores[id] = 0.0 + scores[uuid] = 0.0 + } + } + for (uuid in uuidToHabitGroup.keys) { + if (oldData.scores.containsKey(uuid)) { + scores[uuid] = + oldData.scores[uuid]!! + } else { + scores[uuid] = 0.0 } } } @@ -256,9 +325,15 @@ class HabitCardListCache @Inject constructor( @Synchronized fun fetchHabits() { for (h in filteredHabits) { - if (h.id == null) continue + if (h.uuid == null) continue habits.add(h) - idToHabit[h.id] = h + uuidToHabit[h.uuid] = h + } + + for (hgr in filteredHabitGroups) { + if (hgr.uuid == null) continue + habitGroups.add(hgr) + uuidToHabitGroup[hgr.uuid] = hgr } } @@ -267,6 +342,7 @@ class HabitCardListCache @Inject constructor( */ init { habits = LinkedList() + habitGroups = LinkedList() checkmarks = HashMap() scores = HashMap() notes = HashMap() @@ -275,19 +351,19 @@ class HabitCardListCache @Inject constructor( private inner class RefreshTask : Task { private val newData: CacheData - private val targetId: Long? + private val targetUUID: String? private var isCancelled = false private var runner: TaskRunner? = null constructor() { newData = CacheData() - targetId = null + targetUUID = null isCancelled = false } - constructor(targetId: Long) { + constructor(targetUUID: String) { newData = CacheData() - this.targetId = targetId + this.targetUUID = targetUUID } @Synchronized @@ -307,8 +383,8 @@ class HabitCardListCache @Inject constructor( for (position in newData.habits.indices) { if (isCancelled) return val habit = newData.habits[position] - if (targetId != null && targetId != habit.id) continue - newData.scores[habit.id] = habit.scores[today].value + 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)) { @@ -316,10 +392,18 @@ class HabitCardListCache @Inject constructor( notes.add(note) } val entries = list.toTypedArray() - newData.checkmarks[habit.id] = ArrayUtils.toPrimitive(entries) - newData.notes[habit.id] = notes.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) + } } @Synchronized @@ -340,15 +424,29 @@ class HabitCardListCache @Inject constructor( @Synchronized private fun performInsert(habit: Habit, position: Int) { - val id = habit.id + val uuid = habit.uuid data.habits.add(position, habit) - data.idToHabit[id] = habit - data.scores[id] = newData.scores[id]!! - data.checkmarks[id] = newData.checkmarks[id]!! - data.notes[id] = newData.notes[id]!! + data.uuidToHabit[uuid] = habit + data.scores[uuid] = newData.scores[uuid]!! + data.checkmarks[uuid] = newData.checkmarks[uuid]!! + data.notes[uuid] = newData.notes[uuid]!! listener.onItemInserted(position) } + @Synchronized + private fun performInsert(habitGroup: HabitGroup, position: Int) { + val newPosition = if (position < data.habits.size) { + data.habits.size + } else { + position + } + 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, @@ -359,7 +457,7 @@ class HabitCardListCache @Inject constructor( // Workaround for https://github.com/iSoron/uhabits/issues/968 val checkedToPosition = if (toPosition > data.habits.size) { - logger.error("performMove: $toPosition is strictly higher than ${data.habits.size}") + logger.error("performMove: $toPosition for habit is strictly higher than ${data.habits.size}") data.habits.size } else { toPosition @@ -369,57 +467,114 @@ class HabitCardListCache @Inject constructor( listener.onItemMoved(fromPosition, checkedToPosition) } + private fun performMove( + habitGroup: HabitGroup, + fromPosition: Int, + toPosition: Int + ) { + data.habitGroups.removeAt(fromPosition) + + // 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(checkedToPosition - data.habits.size, habitGroup) + listener.onItemMoved(fromPosition, checkedToPosition) + } + @Synchronized - private fun performUpdate(id: Long, position: Int) { - val oldScore = data.scores[id]!! - val oldCheckmarks = data.checkmarks[id] - val oldNoteIndicators = data.notes[id] - val newScore = newData.scores[id]!! - val newCheckmarks = newData.checkmarks[id]!! - val newNoteIndicators = newData.notes[id]!! + private fun performUpdate(uuid: String, position: Int) { var unchanged = true + val oldScore = data.scores[uuid]!! + val newScore = newData.scores[uuid]!! if (oldScore != newScore) unchanged = false - if (!Arrays.equals(oldCheckmarks, newCheckmarks)) unchanged = false - if (!Arrays.equals(oldNoteIndicators, newNoteIndicators)) unchanged = false + + if (position < data.habits.size) { + val oldCheckmarks = data.checkmarks[uuid] + val newCheckmarks = newData.checkmarks[uuid]!! + val oldNoteIndicators = data.notes[uuid] + val newNoteIndicators = newData.notes[uuid]!! + if (!Arrays.equals(oldCheckmarks, newCheckmarks)) unchanged = false + if (!Arrays.equals(oldNoteIndicators, newNoteIndicators)) unchanged = false + if (unchanged) return + data.checkmarks[uuid] = newCheckmarks + data.notes[uuid] = newNoteIndicators + } + if (unchanged) return - data.scores[id] = newScore - data.checkmarks[id] = newCheckmarks - data.notes[id] = newNoteIndicators + data.scores[uuid] = newScore listener.onItemChanged(position) } @Synchronized private fun processPosition(currentPosition: Int) { - val habit = newData.habits[currentPosition] - val id = habit.id - val prevPosition = data.habits.indexOf(habit) - if (prevPosition < 0) { - performInsert(habit, currentPosition) + if (currentPosition < newData.habits.size) { + val habit = newData.habits[currentPosition] + val uuid = habit.uuid + val prevPosition = data.habits.indexOf(habit) + if (prevPosition < 0) { + performInsert(habit, currentPosition) + } else { + if (prevPosition != currentPosition) { + performMove( + habit, + prevPosition, + currentPosition + ) + } + if (uuid == null) throw NullPointerException() + performUpdate(uuid, currentPosition) + } } else { - if (prevPosition != currentPosition) { - performMove( - habit, - prevPosition, - currentPosition - ) + val habitGroup = newData.habitGroups[currentPosition - data.habits.size] + val uuid = habitGroup.uuid + val prevPosition = data.habitGroups.indexOf(habitGroup) + data.habits.size + if (prevPosition < 0) { + performInsert(habitGroup, currentPosition) + } else { + if (prevPosition != currentPosition) { + performMove( + habitGroup, + prevPosition, + currentPosition + ) + } + if (uuid == null) throw NullPointerException() + performUpdate(uuid, currentPosition) } - if (id == null) throw NullPointerException() - performUpdate(id, currentPosition) } } @Synchronized private fun processRemovedHabits() { - val before: Set = data.idToHabit.keys - val after: Set = newData.idToHabit.keys - val removed: MutableSet = TreeSet(before) + val before: Set = data.uuidToHabit.keys + val after: Set = newData.uuidToHabit.keys + val removed: MutableSet = TreeSet(before) + removed.removeAll(after) + for (uuid in removed) remove(uuid!!) + processRemovedHabitGroups() + } + + @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 (id in removed) remove(id!!) + for (uuid in removed) remove(uuid!!) } } init { filteredHabits = allHabits + filteredHabitGroups = allHabitGroups this.taskRunner = taskRunner listener = object : Listener {} data = CacheData() diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehavior.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehavior.kt index 00d90a1a7..49df8afab 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehavior.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehavior.kt @@ -24,6 +24,7 @@ import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.DeleteHabitsCommand import org.isoron.uhabits.core.commands.UnarchiveHabitsCommand import org.isoron.uhabits.core.models.Habit +import org.isoron.uhabits.core.models.HabitGroup import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.ui.callbacks.OnColorPickedCallback @@ -88,7 +89,9 @@ class ListHabitsSelectionMenuBehavior @Inject constructor( interface Adapter { fun clearSelection() fun getSelected(): List + fun getSelectedHabitGroups(): List fun performRemove(selected: List) + fun performRemoveHabitGroup(selected: List) } interface Screen {