diff --git a/uhabits-android/build.gradle.kts b/uhabits-android/build.gradle.kts index b163e8075..afb4ca261 100644 --- a/uhabits-android/build.gradle.kts +++ b/uhabits-android/build.gradle.kts @@ -129,6 +129,7 @@ dependencies { implementation("androidx.legacy:legacy-support-v4:1.0.0") implementation("com.google.android.material:material:1.11.0") implementation("com.opencsv:opencsv:5.9") + implementation("nl.dionsegijn:konfetti-xml:2.0.2") implementation(project(":uhabits-core")) kapt("com.google.dagger:dagger-compiler:$daggerVersion") kaptAndroidTest("com.google.dagger:dagger-compiler:$daggerVersion") diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/CheckmarkDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/CheckmarkDialog.kt index 5df8ffd7b..13e5f55f6 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/CheckmarkDialog.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/CheckmarkDialog.kt @@ -33,17 +33,19 @@ import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL import org.isoron.uhabits.databinding.CheckmarkPopupBinding import org.isoron.uhabits.utils.InterfaceUtils.getFontAwesome +import org.isoron.uhabits.utils.getCenter import org.isoron.uhabits.utils.sres class CheckmarkDialog : AppCompatDialogFragment() { - var onToggle: (Int, String) -> Unit = { _, _ -> } + var onToggle: (Int, String, Float, Float) -> Unit = { _, _, _, _ -> } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val appComponent = (requireActivity().application as HabitsApplication).component val prefs = appComponent.preferences val view = CheckmarkPopupBinding.inflate(LayoutInflater.from(context)) + val color = requireArguments().getInt("color") arrayOf(view.yesBtn, view.skipBtn).forEach { - it.setTextColor(requireArguments().getInt("color")) + it.setTextColor(color) } arrayOf(view.noBtn, view.unknownBtn).forEach { it.setTextColor(view.root.sres.getColor(R.attr.contrast60)) @@ -62,7 +64,8 @@ class CheckmarkDialog : AppCompatDialogFragment() { } fun onClick(v: Int) { val notes = view.notes.text.toString().trim() - onToggle(v, notes) + val location = view.yesBtn.getCenter() + onToggle(v, notes, location.x, location.y) requireDialog().dismiss() } view.yesBtn.setOnClickListener { onClick(YES_MANUAL) } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/NumberDialog.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/NumberDialog.kt index 5b749b8b6..d8ba1ed59 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/NumberDialog.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/common/dialogs/NumberDialog.kt @@ -15,6 +15,7 @@ import org.isoron.uhabits.R import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.databinding.CheckmarkPopupBinding import org.isoron.uhabits.utils.InterfaceUtils +import org.isoron.uhabits.utils.getCenter import org.isoron.uhabits.utils.requestFocusWithKeyboard import org.isoron.uhabits.utils.sres import java.text.DecimalFormat @@ -24,7 +25,7 @@ import java.text.ParseException class NumberDialog : AppCompatDialogFragment() { - var onToggle: (Double, String) -> Unit = { _, _ -> } + var onToggle: (Double, String, Float, Float) -> Unit = { _, _, _, _ -> } var onDismiss: () -> Unit = {} private var originalNotes: String = "" @@ -113,7 +114,8 @@ class NumberDialog : AppCompatDialogFragment() { // NOP } val notes = view.notes.text.toString() - onToggle(value, notes) + val location = view.saveBtn.getCenter() + onToggle(value, notes, location.x, location.y) requireDialog().dismiss() } } 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 62fe3f00c..f0a542a0d 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 @@ -23,6 +23,7 @@ import android.content.Context import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import android.widget.RelativeLayout +import nl.dionsegijn.konfetti.xml.KonfettiView import org.isoron.uhabits.R import org.isoron.uhabits.activities.common.views.ScrollableChart import org.isoron.uhabits.activities.common.views.TaskProgressBar @@ -69,6 +70,9 @@ class ListHabitsRootView @Inject constructor( val listView: HabitCardListView = habitCardListViewFactory.create() val llEmpty = EmptyListView(context) val tbar = buildToolbar() + val konfettiView = KonfettiView(context).apply { + translationZ = 10f + } val progressBar = TaskProgressBar(context, runner) val hintView: HintView val header = HeaderView(context, preferences, midnightTimer) @@ -80,6 +84,7 @@ class ListHabitsRootView @Inject constructor( val rootView = RelativeLayout(context).apply { background = sres.getDrawable(R.attr.windowBackgroundColor) + addAtTop(konfettiView) addAtTop(tbar) addBelow(header, tbar) addBelow(listView, header, height = MATCH_PARENT) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt index c525e624a..e1f0418a3 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/list/ListHabitsScreen.kt @@ -25,6 +25,9 @@ import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import dagger.Lazy +import nl.dionsegijn.konfetti.core.Party +import nl.dionsegijn.konfetti.core.Position +import nl.dionsegijn.konfetti.core.emitter.Emitter import org.isoron.platform.gui.toInt import org.isoron.uhabits.R import org.isoron.uhabits.activities.common.dialogs.CheckmarkDialog @@ -63,6 +66,7 @@ import org.isoron.uhabits.intents.IntentFactory import org.isoron.uhabits.tasks.ExportDBTaskFactory import org.isoron.uhabits.tasks.ImportDataTask import org.isoron.uhabits.tasks.ImportDataTaskFactory +import org.isoron.uhabits.utils.ColorUtils import org.isoron.uhabits.utils.copyTo import org.isoron.uhabits.utils.currentTheme import org.isoron.uhabits.utils.dismissCurrentAndShow @@ -72,6 +76,7 @@ import org.isoron.uhabits.utils.showSendEmailScreen import org.isoron.uhabits.utils.showSendFileScreen import java.io.File import java.io.IOException +import java.util.concurrent.TimeUnit import javax.inject.Inject const val RESULT_IMPORT_DATA = 101 @@ -218,6 +223,28 @@ class ListHabitsScreen activity.showSendFileScreen(filename) } + override fun showConfetti(color: PaletteColor, x: Float, y: Float) { + val baseColor = themeSwitcher.currentTheme!!.color(color).toInt() + rootView.get().konfettiView.start( + Party( + speed = 0f, + maxSpeed = 16f, + damping = 0.9f, + spread = 360, + angle = 0, + colors = listOf( + ColorUtils.changeHue(baseColor, 180f), + ColorUtils.changeHue(baseColor, 20f), + ColorUtils.changeHue(baseColor, -20f), + baseColor + ), + position = Position.Absolute(x, y), + emitter = Emitter(duration = 25, TimeUnit.MILLISECONDS).max(25), + timeToLive = 0 + ) + ) + } + override fun showSettingsScreen() { val intent = intentFactory.startSettingsActivity(activity) activity.startActivityForResult(intent, REQUEST_SETTINGS) @@ -240,7 +267,7 @@ class ListHabitsScreen putDouble("value", value) putString("notes", notes) } - dialog.onToggle = { v, n -> callback.onNumberPicked(v, n) } + dialog.onToggle = { v, n, x, y -> callback.onNumberPicked(v, n, x, y) } dialog.dismissCurrentAndShow(fm, "numberDialog") } @@ -258,7 +285,7 @@ class ListHabitsScreen putInt("value", selectedValue) putString("notes", notes) } - dialog.onToggle = { v, n -> callback.onNotesSaved(v, n) } + dialog.onToggle = { v, n, x, y -> callback.onNotesSaved(v, n, x, y) } dialog.dismissCurrentAndShow(fm, "checkmarkDialog") } 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 8e5e9d21e..84bd001fc 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 @@ -20,6 +20,7 @@ package org.isoron.uhabits.activities.habits.list.views import android.content.Context +import android.graphics.PointF import android.graphics.text.LineBreaker.BREAK_STRATEGY_BALANCED import android.os.Build import android.os.Build.VERSION.SDK_INT @@ -154,7 +155,17 @@ class HabitCardView( checkmarkPanel = checkmarkPanelFactory.create().apply { onToggle = { timestamp, value, notes -> triggerRipple(timestamp) - habit?.let { behavior.onToggle(it, timestamp, value, notes) } + val location = getAbsoluteButtonLocation(timestamp) + habit?.let { + behavior.onToggle( + it, + timestamp, + value, + notes, + location.x, + location.y + ) + } } onEdit = { timestamp -> triggerRipple(timestamp) @@ -206,12 +217,27 @@ class HabitCardView( } fun triggerRipple(timestamp: Timestamp) { + val location = getRelativeButtonLocation(timestamp) + triggerRipple(location.x, location.y) + } + + private fun getRelativeButtonLocation(timestamp: Timestamp): PointF { val today = DateUtils.getTodayWithOffset() val offset = timestamp.daysUntil(today) - dataOffset val button = checkmarkPanel.buttons[offset] val y = button.height / 2.0f val x = checkmarkPanel.x + button.x + (button.width / 2).toFloat() - triggerRipple(x, y) + return PointF(x, y) + } + + private fun getAbsoluteButtonLocation(timestamp: Timestamp): PointF { + val containerLocation = IntArray(2) + this.getLocationOnScreen(containerLocation) + val relButtonLocation = getRelativeButtonLocation(timestamp) + return PointF( + containerLocation[0].toFloat() + relButtonLocation.x, + containerLocation[1].toFloat() - relButtonLocation.y + ) } override fun onAttachedToWindow() { diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt index 21289a0bd..14baee148 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/activities/habits/show/ShowHabitActivity.kt @@ -179,7 +179,7 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener { putDouble("value", value) putString("notes", notes) } - dialog.onToggle = { v, n -> callback.onNumberPicked(v, n) } + dialog.onToggle = { v, n, x, y -> callback.onNumberPicked(v, n, x, y) } dialog.dismissCurrentAndShow(supportFragmentManager, "numberDialog") } @@ -196,7 +196,7 @@ class ShowHabitActivity : AppCompatActivity(), CommandRunner.Listener { putInt("value", selectedValue) putString("notes", notes) } - dialog.onToggle = { v, n -> callback.onNotesSaved(v, n) } + dialog.onToggle = { v, n, x, y -> callback.onNotesSaved(v, n, x, y) } dialog.dismissCurrentAndShow(supportFragmentManager, "checkmarkDialog") } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/utils/ColorUtils.kt b/uhabits-android/src/main/java/org/isoron/uhabits/utils/ColorUtils.kt index c3806f5c6..b993a5bc2 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/utils/ColorUtils.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/utils/ColorUtils.kt @@ -36,6 +36,13 @@ object ColorUtils { return a or r or g or b } + fun changeHue(color: Int, delta: Float): Int { + val hsv = FloatArray(3) + Color.colorToHSV(color, hsv) + hsv[0] = (hsv[0] + delta).mod(360f) + return Color.HSVToColor(hsv) + } + @JvmStatic fun setAlpha(color: Int, newAlpha: Float): Int { val intAlpha = (newAlpha * 255).toInt() diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/utils/ViewExtensions.kt b/uhabits-android/src/main/java/org/isoron/uhabits/utils/ViewExtensions.kt index abf98970c..3c92e7e1b 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/utils/ViewExtensions.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/utils/ViewExtensions.kt @@ -26,6 +26,7 @@ import android.content.Intent import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.PointF import android.graphics.drawable.ColorDrawable import android.os.Handler import android.os.SystemClock @@ -135,7 +136,11 @@ fun Activity.startActivitySafely(intent: Intent) { } } -fun Activity.showSendEmailScreen(@StringRes toId: Int, @StringRes subjectId: Int, content: String?) { +fun Activity.showSendEmailScreen( + @StringRes toId: Int, + @StringRes subjectId: Int, + content: String? +) { val to = this.getString(toId) val subject = this.getString(subjectId) this.startActivity( @@ -232,3 +237,11 @@ fun View.requestFocusWithKeyboard() { dispatchTouchEvent(MotionEvent.obtain(time, time, MotionEvent.ACTION_UP, 0f, 0f, 0)) }, 250) } + +fun View.getCenter(): PointF { + val viewLocation = IntArray(2) + this.getLocationOnScreen(viewLocation) + viewLocation[0] += this.width / 2 + viewLocation[1] -= this.height / 2 + return PointF(viewLocation[0].toFloat(), viewLocation[1].toFloat()) +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt index 3db3cdbaf..b66b08be6 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehavior.kt @@ -20,9 +20,12 @@ package org.isoron.uhabits.core.ui.screens.habits.list import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CreateRepetitionCommand +import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitType +import org.isoron.uhabits.core.models.NumericalHabitType.AT_LEAST +import org.isoron.uhabits.core.models.NumericalHabitType.AT_MOST import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.models.Timestamp import org.isoron.uhabits.core.preferences.Preferences @@ -52,8 +55,16 @@ open class ListHabitsBehavior @Inject constructor( val entry = habit.computedEntries.get(timestamp!!) if (habit.type == HabitType.NUMERICAL) { val oldValue = entry.value.toDouble() / 1000 - screen.showNumberPopup(oldValue, entry.notes) { newValue: Double, newNotes: String -> + screen.showNumberPopup(oldValue, entry.notes) { newValue: Double, newNotes: String, x: Float, y: Float -> val value = (newValue * 1000).roundToInt() + if (newValue != oldValue) { + if ( + (habit.targetType == AT_LEAST && newValue >= habit.targetValue) || + (habit.targetType == AT_MOST && newValue <= habit.targetValue) + ) { + screen.showConfetti(habit.color, x, y) + } + } commandRunner.run(CreateRepetitionCommand(habitList, habit, timestamp, value, newNotes)) } } else { @@ -61,7 +72,8 @@ open class ListHabitsBehavior @Inject constructor( entry.value, entry.notes, habit.color - ) { newValue, newNotes -> + ) { newValue: Int, newNotes: String, x: Float, y: Float -> + if (newValue != entry.value && newValue == YES_MANUAL) screen.showConfetti(habit.color, x, y) commandRunner.run(CreateRepetitionCommand(habitList, habit, timestamp, newValue, newNotes)) } } @@ -117,10 +129,11 @@ open class ListHabitsBehavior @Inject constructor( if (prefs.isFirstRun) onFirstRun() } - fun onToggle(habit: Habit, timestamp: Timestamp, value: Int, notes: String) { + fun onToggle(habit: Habit, timestamp: Timestamp, value: Int, notes: String, x: Float, y: Float) { commandRunner.run( CreateRepetitionCommand(habitList, habit, timestamp, value, notes) ) + if (value == YES_MANUAL) screen.showConfetti(habit.color, x, y) } enum class Message { @@ -144,12 +157,22 @@ open class ListHabitsBehavior @Inject constructor( } fun interface NumberPickerCallback { - fun onNumberPicked(newValue: Double, notes: String) + fun onNumberPicked( + newValue: Double, + notes: String, + x: Float, + y: Float + ) fun onNumberPickerDismissed() {} } fun interface CheckMarkDialogCallback { - fun onNotesSaved(value: Int, notes: String) + fun onNotesSaved( + value: Int, + notes: String, + x: Float, + y: Float + ) fun onNotesDismissed() {} } @@ -170,5 +193,6 @@ open class ListHabitsBehavior @Inject constructor( ) fun showSendBugReportToDeveloperScreen(log: String) fun showSendFileScreen(filename: String) + fun showConfetti(color: PaletteColor, x: Float, y: Float) } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt index cfbe5e0f3..0a28c801f 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/ui/screens/habits/show/views/HistoryCard.kt @@ -98,7 +98,7 @@ class HistoryCardPresenter( entry.value, entry.notes, habit.color - ) { newValue, newNotes -> + ) { newValue, newNotes, _: Float, _: Float -> commandRunner.run( CreateRepetitionCommand( habitList, @@ -135,7 +135,7 @@ class HistoryCardPresenter( screen.showNumberPopup( value = oldValue / 1000.0, notes = entry.notes - ) { newValue: Double, newNotes: String -> + ) { newValue: Double, newNotes: String, _: Float, _: Float -> val thousands = (newValue * 1000).roundToInt() commandRunner.run( CreateRepetitionCommand( diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt index ead82c1e9..26dd82df8 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt @@ -84,7 +84,7 @@ class ListHabitsBehaviorTest : BaseUnitTest() { eq(""), picker.capture() ) - picker.lastValue.onNumberPicked(100.0, "") + picker.lastValue.onNumberPicked(100.0, "", 0f, 0f) val today = getTodayWithOffset() assertThat(habit2.computedEntries.get(today).value, equalTo(100000)) } @@ -168,7 +168,9 @@ class ListHabitsBehaviorTest : BaseUnitTest() { habit = habit1, timestamp = getToday(), value = Entry.NO, - notes = "" + notes = "", + x = 0f, + y = 0f ) assertFalse(habit1.isCompletedToday()) }