diff --git a/android/android-base/src/main/java/org/isoron/androidbase/utils/ColorUtils.java b/android/android-base/src/main/java/org/isoron/androidbase/utils/ColorUtils.java index 58199d2dd..7225f1c9a 100644 --- a/android/android-base/src/main/java/org/isoron/androidbase/utils/ColorUtils.java +++ b/android/android-base/src/main/java/org/isoron/androidbase/utils/ColorUtils.java @@ -21,15 +21,17 @@ package org.isoron.androidbase.utils; import android.graphics.*; +import static java.lang.Math.abs; + public abstract class ColorUtils { + final static byte ALPHA_CHANNEL = 24; + final static byte RED_CHANNEL = 16; + final static byte GREEN_CHANNEL = 8; + final static byte BLUE_CHANNEL = 0; + public static int mixColors(int color1, int color2, float amount) { - final byte ALPHA_CHANNEL = 24; - final byte RED_CHANNEL = 16; - final byte GREEN_CHANNEL = 8; - final byte BLUE_CHANNEL = 0; - final float inverseAmount = 1.0f - amount; int a = ((int) (((float) (color1 >> ALPHA_CHANNEL & 0xff) * amount) + @@ -48,6 +50,18 @@ public abstract class ColorUtils b << BLUE_CHANNEL; } + public static int interPolateHSV(int col1, int col2, float factor){ + //based on https://www.alanzucconi.com/2016/01/06/colour-interpolation/2/ + float[] a = new float[3]; + float[] b = new float[3]; + Color.colorToHSV(col1, a); + Color.colorToHSV(col2, b); + + float[] out = new float[3]; + androidx.core.graphics.ColorUtils.blendHSL(a, b, factor, out); + return Color.HSVToColor(out); + } + public static int setAlpha(int color, float newAlpha) { int intAlpha = (int) (newAlpha * 255); diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/CurrentStreakWidget.kt b/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/CurrentStreakWidget.kt index b81236a73..75066870b 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/CurrentStreakWidget.kt +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/CurrentStreakWidget.kt @@ -22,9 +22,9 @@ package org.isoron.uhabits.widgets import android.content.* import android.view.* import org.isoron.uhabits.core.models.* -import org.isoron.uhabits.utils.* import org.isoron.uhabits.widgets.views.* + class CurrentStreakWidget( context: Context, widgetId: Int, @@ -41,11 +41,13 @@ class CurrentStreakWidget( } else { "0" } + val timeoutPercent = habit.timeoutPercentage() + (v as CurrentStreakWidgetView).apply { setBackgroundAlpha(preferedBackgroundAlpha) setCurrentStreak(numReps) setPercentage(habit.scores.todayValue.toFloat()) - setActiveColor(PaletteUtils.getColor(context, habit.color)) + setTimeOutPercentage(1 - timeoutPercent) setName(habit.name) setCheckmarkValue(habit.checkmarks.todayValue) refresh() diff --git a/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/views/CurrentStreakWidgetView.java b/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/views/CurrentStreakWidgetView.java index 2dc869830..d8681868f 100644 --- a/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/views/CurrentStreakWidgetView.java +++ b/android/uhabits-android/src/main/java/org/isoron/uhabits/widgets/views/CurrentStreakWidgetView.java @@ -27,18 +27,16 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.isoron.androidbase.utils.ColorUtils; import org.isoron.androidbase.utils.StyledResources; import org.isoron.uhabits.R; import org.isoron.uhabits.activities.common.views.RingView; import org.isoron.uhabits.core.models.Checkmark; -import org.isoron.uhabits.utils.PaletteUtils; import static org.isoron.androidbase.utils.InterfaceUtils.getDimension; public class CurrentStreakWidgetView extends HabitWidgetView { - private int activeColor; - private float percentage; @Nullable @@ -49,7 +47,10 @@ public class CurrentStreakWidgetView extends HabitWidgetView private TextView label; private int checkmarkValue; + private int startColor = 0x388e3c + (255 << 24); // add full alpha + private int endColor = 0xc62828 + (255 << 24); // add full alpha private String currentStreak; + private float timeoutPercentage; public CurrentStreakWidgetView(Context context) { @@ -69,34 +70,30 @@ public class CurrentStreakWidgetView extends HabitWidgetView StyledResources res = new StyledResources(getContext()); - String text; int bgColor; int fgColor; + bgColor = ColorUtils.interPolateHSV(startColor, endColor, timeoutPercentage); switch (checkmarkValue) { case Checkmark.CHECKED_EXPLICITLY: - bgColor = activeColor; fgColor = res.getColor(R.attr.highContrastReverseTextColor); setShadowAlpha(0x4f); - backgroundPaint.setColor(bgColor); - frame.setBackgroundDrawable(background); break; case Checkmark.CHECKED_IMPLICITLY: case Checkmark.UNCHECKED: default: - bgColor = res.getColor(R.attr.cardBgColor); fgColor = res.getColor(R.attr.mediumContrastTextColor); - setShadowAlpha(0x00); break; } - text = currentStreak; + backgroundPaint.setColor(bgColor); + frame.setBackgroundDrawable(background); ring.setPercentage(percentage); ring.setColor(fgColor); ring.setBackgroundColor(bgColor); - ring.setText(text); + ring.setText(currentStreak); label.setText(name); label.setTextColor(fgColor); @@ -105,10 +102,6 @@ public class CurrentStreakWidgetView extends HabitWidgetView postInvalidate(); } - public void setActiveColor(int activeColor) - { - this.activeColor = activeColor; - } public void setCurrentStreak(String currentStreak) { this.currentStreak = currentStreak; @@ -128,6 +121,10 @@ public class CurrentStreakWidgetView extends HabitWidgetView { this.percentage = percentage; } + public void setTimeOutPercentage(float percentage) + { + this.timeoutPercentage = percentage; + } @Override @NonNull @@ -181,7 +178,6 @@ public class CurrentStreakWidgetView extends HabitWidgetView { percentage = 0.75f; name = "Wake up early"; - activeColor = PaletteUtils.getAndroidTestColor(6); checkmarkValue = Checkmark.CHECKED_EXPLICITLY; refresh(); } diff --git a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java index a290b56d9..683f2e352 100644 --- a/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java +++ b/android/uhabits-core/src/main/java/org/isoron/uhabits/core/models/Habit.java @@ -22,12 +22,15 @@ package org.isoron.uhabits.core.models; import androidx.annotation.*; import org.apache.commons.lang3.builder.*; +import org.isoron.uhabits.core.utils.DateUtils; import java.util.*; import javax.annotation.concurrent.*; import javax.inject.*; +import autovalue.shaded.com.google$.common.base.$Function; + import static org.isoron.uhabits.core.models.Checkmark.*; import static org.isoron.uhabits.core.utils.StringUtils.defaultToStringStyle; @@ -334,6 +337,76 @@ public class Habit else return (todayCheckmark != UNCHECKED); } + private boolean activeValue(int value) { + return value == Checkmark.CHECKED_EXPLICITLY || value == Checkmark.CHECKED_IMPLICITLY; + } + + public boolean isActive() { + CheckmarkList cm = getCheckmarks(); + int[] values = cm.getAllValues(); + if (values.length == 0 || isArchived()){ + return false; + } + + return activeValue(values[0]); + } + + public float timeoutPercentage(){ + Timestamp now = DateUtils.getToday(); + CheckmarkList cm = getCheckmarks(); + int[] values = cm.getAllValues(); + + if (values.length == 0) { + return 0f; + } else if (!activeValue(values[0]) && !activeValue(cm.getTodayValue())) { + return 0f; + } else if (cm.getTodayValue() == CHECKED_EXPLICITLY) { + return 1f; + } + + int numDaysLimit = getData().frequency.getDenominator(); + //int numTimesLimit = getData().frequency.getNumerator(); // we assume this is 1, and dont handle multiple now + Timestamp oldest = getRepetitions().getOldest().getTimestamp(); //shouldnt null if we have checkmarks + Timestamp potentialNewest = DateUtils.getToday().plus(numDaysLimit); + + //TODO speedup by not getting ALL checkmarks? + int[] vals = getCheckmarks().getValues(oldest, potentialNewest); + //System.out.println(Arrays.toString(vals)); + + int daysSinceStreakStart = 0; + + boolean isActiveSegment = false; + for (int c : vals){ + if (c != UNCHECKED && !isActiveSegment) isActiveSegment = true; + + if (isActiveSegment){ + if (c == CHECKED_IMPLICITLY || c == UNCHECKED) { + daysSinceStreakStart += 1; + } else { + break; + } + } + } + + int streakDaysRemain = numDaysLimit - daysSinceStreakStart; + + //TODO can this fail if we completed the streak TOO often the last few days? + + //TODO we handle this differently if numerator is higher than 1? + //TODO just dont handle multicase? + //TODO Need to change CheckmarkList.getValues(from, to) to fix this? + + Timestamp trueEnd = now.plus(streakDaysRemain + 1); // a streak will always be terminated on the evening of that day? + Timestamp trueStart = now.minus(daysSinceStreakStart); + + long nowDiff = now.getUnixTime() - trueStart.getUnixTime(); + long endDiff = trueEnd.getUnixTime() - trueStart.getUnixTime(); + + //System.out.println("n: " + nowDiff + " e: " + endDiff + " start: " + daysSinceStreakStart + " remain: " + (streakDaysRemain+1)); + + return 1f - ((float) nowDiff / endDiff); + } + public synchronized boolean isNumerical() { return data.type == NUMBER_HABIT; diff --git a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java index 89d39dce5..057308176 100644 --- a/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java +++ b/android/uhabits-core/src/test/java/org/isoron/uhabits/core/models/HabitTest.java @@ -19,6 +19,7 @@ package org.isoron.uhabits.core.models; +import org.hamcrest.number.IsCloseTo; import org.isoron.uhabits.core.*; import org.junit.*; import org.junit.rules.*; @@ -26,6 +27,7 @@ import org.junit.rules.*; import nl.jqno.equalsverifier.*; import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.Matchers.closeTo; import static org.isoron.uhabits.core.utils.DateUtils.*; import static org.junit.Assert.*; @@ -53,6 +55,96 @@ public class HabitTest extends BaseUnitTest assertNotNull(habit.getCheckmarks()); } + @Test + public void testActiveState_archived() + { + Habit model = modelFactory.buildHabit(); + model.setArchived(true); + model.getRepetitions().toggle(getToday()); + + assertFalse(model.isActive()); + } + + @Test + public void testActiveState() + { + Habit model = modelFactory.buildHabit(); + + assertFalse(model.isActive()); + model.getRepetitions().toggle(getToday()); + assertTrue(model.isActive()); + } + + @Test + public void testTimeoutPercentage() + { + Habit model = modelFactory.buildHabit(); + assertThat(model.timeoutPercentage(), is(0f)); + + model.getRepetitions().toggle(getToday()); + assertThat(model.timeoutPercentage(), is(1f)); + } + + @Test + public void testTimeoutPercentage_once_per_period() + { + Habit model = modelFactory.buildHabit(); + + Timestamp day = getToday().minus(2); + model.getRepetitions().toggle(day); + + model.setFrequency(new Frequency(1, 3)); + assertThat((double) model.timeoutPercentage(), is(closeTo(0.25f, 0.01f))); + + model.setFrequency(new Frequency(1, 5)); + assertThat((double) model.timeoutPercentage(), is(closeTo(0.5f, 0.01f))); + } + + @Test + public void testTimeoutPercentage_several_hits() + { + Habit model = modelFactory.buildHabit(); + + Timestamp day = getToday().minus(2); + model.getRepetitions().toggle(day); + model.getRepetitions().toggle(day.minus(2)); + + model.setFrequency(new Frequency(1, 3)); + assertThat((double) model.timeoutPercentage(), is(closeTo(0.25f, 0.01f))); + } + + @Test + public void testTimeoutPercentage_every_day() + { + Habit model = modelFactory.buildHabit(); + model.setFrequency(new Frequency(1, 1)); + + Timestamp day = getToday().minus(1); + model.getRepetitions().toggle(day); + + assertThat((double) model.timeoutPercentage(), is(closeTo(0.5f, 0.01f))); + } + + @Test + @Ignore + public void testTimeoutPercentage_multiple_per_period() + { + Habit model = modelFactory.buildHabit(); + model.setFrequency(new Frequency(2, 5)); + + Timestamp day = getToday().minus(2); + + model.getRepetitions().toggle(day); + model.getRepetitions().toggle(day.minus(2)); + // if we have 2 triggered, this validates 5 days, starting at first day + // But, current code doesn't handle telling us when the last day we can still chain. + //TODO Need to change CheckmarkList.getValues(from, to) to fix this? + assertThat((double) model.timeoutPercentage(), is(closeTo(0.25f, 0.01f))); + + model.setFrequency(new Frequency(1, 5)); + assertThat((double) model.timeoutPercentage(), is(closeTo(0.5f, 0.01f))); + } + @Test public void test_copyAttributes() {