Merge pull request #716 from hiqua/dev

pull/718/head
Alinson S. Xavier 5 years ago committed by GitHub
commit 4ffd78545d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -85,6 +85,7 @@ dependencies {
androidTestImplementation("androidx.test.ext:junit:1.1.2")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0")
androidTestImplementation("androidx.test:rules:1.3.0")
androidTestImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
compileOnly("javax.annotation:jsr250-api:1.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.1")
implementation("com.github.paolorotolo:appintro:3.4.0")

@ -1,289 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits;
import android.appwidget.*;
import android.content.*;
import android.content.res.*;
import android.os.*;
import android.util.*;
import androidx.annotation.*;
import androidx.test.filters.*;
import androidx.test.platform.app.*;
import androidx.test.uiautomator.*;
import junit.framework.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.tasks.*;
import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.inject.*;
import org.isoron.uhabits.utils.*;
import org.junit.*;
import java.io.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import static androidx.test.platform.app.InstrumentationRegistry.*;
import static androidx.test.uiautomator.UiDevice.*;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.*;
@MediumTest
public class BaseAndroidTest extends TestCase
{
// 8:00am, January 25th, 2015 (UTC)
public static final long FIXED_LOCAL_TIME = 1422172800000L;
protected Context testContext;
protected Context targetContext;
protected Preferences prefs;
protected HabitList habitList;
protected TaskRunner taskRunner;
protected HabitFixtures fixtures;
protected CountDownLatch latch;
protected HabitsApplicationTestComponent appComponent;
protected ModelFactory modelFactory;
protected HabitsActivityTestComponent component;
private boolean isDone = false;
private UiDevice device;
@Override
@Before
public void setUp()
{
if (Looper.myLooper() == null) Looper.prepare();
device = getInstance(getInstrumentation());
targetContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
testContext = InstrumentationRegistry.getInstrumentation().getContext();
DateUtils.setFixedLocalTime(FIXED_LOCAL_TIME);
DateUtils.setStartDayOffset(0, 0);
setResolution(2.0f);
setTheme(R.style.AppBaseTheme);
setLocale("en", "US");
latch = new CountDownLatch(1);
Context context = targetContext.getApplicationContext();
File dbFile = DatabaseUtils.getDatabaseFile(context);
appComponent = DaggerHabitsApplicationTestComponent
.builder()
.appContextModule(new AppContextModule(context))
.habitsModule(new HabitsModule(dbFile))
.build();
HabitsApplication.Companion.setComponent(appComponent);
prefs = appComponent.getPreferences();
habitList = appComponent.getHabitList();
taskRunner = appComponent.getTaskRunner();
modelFactory = appComponent.getModelFactory();
prefs.clear();
fixtures = new HabitFixtures(modelFactory, habitList);
fixtures.purgeHabits(appComponent.getHabitList());
Habit habit = fixtures.createEmptyHabit();
component = DaggerHabitsActivityTestComponent
.builder()
.activityContextModule(new ActivityContextModule(targetContext))
.habitsApplicationComponent(appComponent)
.build();
}
protected void assertWidgetProviderIsInstalled(Class componentClass)
{
ComponentName provider =
new ComponentName(targetContext, componentClass);
AppWidgetManager manager = AppWidgetManager.getInstance(targetContext);
List<ComponentName> installedProviders = new LinkedList<>();
for (AppWidgetProviderInfo info : manager.getInstalledProviders())
installedProviders.add(info.provider);
assertThat(installedProviders, hasItems(provider));
}
protected void awaitLatch() throws InterruptedException
{
assertTrue(latch.await(1, TimeUnit.SECONDS));
}
protected void setLocale(@NonNull String language, @NonNull String country)
{
Locale locale = new Locale(language, country);
Locale.setDefault(locale);
Resources res = targetContext.getResources();
Configuration config = res.getConfiguration();
config.setLocale(locale);
}
protected void setResolution(float r)
{
DisplayMetrics dm = targetContext.getResources().getDisplayMetrics();
dm.density = r;
dm.scaledDensity = r;
InterfaceUtils.setFixedResolution(r);
}
protected void runConcurrently(Runnable... runnableList) throws Exception
{
isDone = false;
ExecutorService executor = Executors.newFixedThreadPool(100);
List<Future> futures = new LinkedList<>();
for (Runnable r : runnableList)
futures.add(executor.submit(() ->
{
while (!isDone) r.run();
return null;
}));
Thread.sleep(3000);
isDone = true;
executor.shutdown();
for(Future f : futures) f.get();
while (!executor.isTerminated()) Thread.sleep(50);
}
protected void setTheme(@StyleRes int themeId)
{
targetContext.setTheme(themeId);
StyledResources.setFixedTheme(themeId);
}
protected void sleep(int time)
{
try
{
Thread.sleep(time);
}
catch (InterruptedException e)
{
fail();
}
}
public long timestamp(int year, int month, int day)
{
GregorianCalendar cal = DateUtils.getStartOfTodayCalendar();
cal.set(year, month, day);
return cal.getTimeInMillis();
}
protected void startTracing()
{
File dir = new AndroidDirFinder(targetContext).getFilesDir("Profile");
assertNotNull(dir);
String tracePath = dir.getAbsolutePath() + "/performance.trace";
Log.d("PerformanceTest", String.format("Saving trace file to %s", tracePath));
Debug.startMethodTracingSampling(tracePath, 0, 1000);
}
protected void stopTracing()
{
Debug.stopMethodTracing();
}
protected Timestamp day(int offset)
{
return DateUtils.getToday().minus(offset);
}
public void setSystemTime(String tz,
int year,
int javaMonth,
int day,
int hourOfDay,
int minute) throws Exception
{
GregorianCalendar cal = new GregorianCalendar();
cal.set(Calendar.SECOND, 0);
cal.set(year, javaMonth, day, hourOfDay, minute);
cal.setTimeZone(TimeZone.getTimeZone(tz));
setSystemTime(cal);
}
private void setSystemTime(GregorianCalendar cal) throws Exception
{
ZoneId tz = cal.getTimeZone().toZoneId();
// Set time zone (temporary)
String command = String.format("service call alarm 3 s16 %s", tz);
device.executeShellCommand(command);
// Set time zone (permanent)
command = String.format("setprop persist.sys.timezone %s", tz);
device.executeShellCommand(command);
// Set time
String date = String.format("%02d%02d%02d%02d%02d.%02d",
cal.get(Calendar.MONTH) + 1,
cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY),
cal.get(Calendar.MINUTE),
cal.get(Calendar.YEAR),
cal.get(Calendar.SECOND));
// Set time (method 1)
// Run twice to override daylight saving time
device.executeShellCommand("date " + date);
device.executeShellCommand("date " + date);
// Set time (method 2)
// Run in addition to the method above because one of these mail fail, depending
// on the Android API version.
command = String.format("date -u @%d", cal.getTimeInMillis() / 1000);
device.executeShellCommand(command);
// Wait for system events to settle
Thread.sleep(1000);
}
private GregorianCalendar savedCalendar = null;
public void saveSystemTime()
{
savedCalendar = new GregorianCalendar();
}
public void restoreSystemTime() throws Exception
{
if (savedCalendar == null) throw new NullPointerException();
setSystemTime(savedCalendar);
}
}

@ -0,0 +1,218 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.os.Looper
import androidx.annotation.StyleRes
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import junit.framework.TestCase
import org.hamcrest.CoreMatchers.hasItems
import org.hamcrest.MatcherAssert.assertThat
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.tasks.TaskRunner
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
import org.isoron.uhabits.core.utils.DateUtils.Companion.setFixedLocalTime
import org.isoron.uhabits.core.utils.DateUtils.Companion.setStartDayOffset
import org.isoron.uhabits.inject.ActivityContextModule
import org.isoron.uhabits.inject.AppContextModule
import org.isoron.uhabits.inject.HabitsModule
import org.isoron.uhabits.utils.DatabaseUtils.getDatabaseFile
import org.isoron.uhabits.utils.InterfaceUtils.setFixedResolution
import org.isoron.uhabits.utils.StyledResources.Companion.setFixedTheme
import org.isoron.uhabits.widgets.BaseWidgetProvider
import org.junit.Before
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.LinkedList
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.CountDownLatch
@MediumTest
abstract class BaseAndroidTest : TestCase() {
@JvmField
protected var testContext: Context = InstrumentationRegistry.getInstrumentation().context
@JvmField
protected var targetContext: Context =
InstrumentationRegistry.getInstrumentation().targetContext
protected lateinit var prefs: Preferences
protected lateinit var habitList: HabitList
protected lateinit var taskRunner: TaskRunner
protected lateinit var fixtures: HabitFixtures
protected lateinit var latch: CountDownLatch
protected lateinit var appComponent: HabitsApplicationTestComponent
protected lateinit var modelFactory: ModelFactory
protected lateinit var component: HabitsActivityTestComponent
private lateinit var device: UiDevice
@Before
public override fun setUp() {
if (Looper.myLooper() == null) Looper.prepare()
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
setFixedLocalTime(FIXED_LOCAL_TIME)
setStartDayOffset(0, 0)
setResolution(2.0f)
setTheme(R.style.AppBaseTheme)
setLocale("en", "US")
latch = CountDownLatch(1)
val context = targetContext.applicationContext
val dbFile = getDatabaseFile(context)
appComponent = DaggerHabitsApplicationTestComponent
.builder()
.appContextModule(AppContextModule(context))
.habitsModule(HabitsModule(dbFile))
.build()
HabitsApplication.component = appComponent
prefs = appComponent.preferences
habitList = appComponent.habitList
taskRunner = appComponent.taskRunner
modelFactory = appComponent.modelFactory
prefs.clear()
fixtures = HabitFixtures(modelFactory, habitList)
fixtures.purgeHabits(appComponent.habitList)
fixtures.createEmptyHabit()
component = DaggerHabitsActivityTestComponent
.builder()
.activityContextModule(ActivityContextModule(targetContext))
.habitsApplicationComponent(appComponent)
.build()
}
protected fun assertWidgetProviderIsInstalled(componentClass: Class<out BaseWidgetProvider?>?) {
val provider = ComponentName(targetContext, componentClass!!)
val manager = AppWidgetManager.getInstance(targetContext)
val installedProviders: MutableList<ComponentName> = LinkedList()
for (info in manager.installedProviders) installedProviders.add(info.provider)
assertThat<List<ComponentName>>(
installedProviders,
hasItems(provider)
)
}
protected fun setLocale(language: String, country: String) {
val locale = Locale(language, country)
Locale.setDefault(locale)
val res = targetContext.resources
val config = res.configuration
config.setLocale(locale)
}
protected fun setResolution(r: Float) {
val dm = targetContext.resources.displayMetrics
dm.density = r
dm.scaledDensity = r
setFixedResolution(r)
}
protected fun setTheme(@StyleRes themeId: Int) {
targetContext.setTheme(themeId)
setFixedTheme(themeId)
}
protected fun sleep(time: Int) {
try {
Thread.sleep(time.toLong())
} catch (e: InterruptedException) {
fail()
}
}
protected fun day(offset: Int): Timestamp {
return getToday().minus(offset)
}
@Throws(Exception::class)
fun setSystemTime(
tz: String?,
year: Int,
javaMonth: Int,
day: Int,
hourOfDay: Int,
minute: Int
) {
val cal = GregorianCalendar()
cal[Calendar.SECOND] = 0
cal[year, javaMonth, day, hourOfDay] = minute
cal.timeZone = TimeZone.getTimeZone(tz)
setSystemTime(cal)
}
@Throws(Exception::class)
private fun setSystemTime(cal: GregorianCalendar) {
val tz = cal.timeZone.toZoneId()
// Set time zone (temporary)
var command = String.format("service call alarm 3 s16 %s", tz)
device.executeShellCommand(command)
// Set time zone (permanent)
command = String.format("setprop persist.sys.timezone %s", tz)
device.executeShellCommand(command)
// Set time
val date = String.format(
"%02d%02d%02d%02d%02d.%02d",
cal[Calendar.MONTH] + 1,
cal[Calendar.DAY_OF_MONTH],
cal[Calendar.HOUR_OF_DAY],
cal[Calendar.MINUTE],
cal[Calendar.YEAR],
cal[Calendar.SECOND]
)
// Set time (method 1)
// Run twice to override daylight saving time
device.executeShellCommand("date $date")
device.executeShellCommand("date $date")
// Set time (method 2)
// Run in addition to the method above because one of these mail fail, depending
// on the Android API version.
command = String.format("date -u @%d", cal.timeInMillis / 1000)
device.executeShellCommand(command)
// Wait for system events to settle
Thread.sleep(1000)
}
private lateinit var savedCalendar: GregorianCalendar
fun saveSystemTime() {
savedCalendar = GregorianCalendar()
}
@Throws(Exception::class)
fun restoreSystemTime() {
setSystemTime(savedCalendar)
}
companion object {
// 8:00am, January 25th, 2015 (UTC)
const val FIXED_LOCAL_TIME = 1422172800000L
}
}

@ -1,133 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits;
import android.content.*;
import androidx.test.uiautomator.*;
import com.linkedin.android.testbutler.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.ui.screens.habits.list.*;
import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.inject.*;
import org.junit.*;
import static androidx.test.core.app.ApplicationProvider.*;
import static androidx.test.platform.app.InstrumentationRegistry.*;
import static androidx.test.uiautomator.UiDevice.*;
public class BaseUserInterfaceTest
{
private static final String PKG = "org.isoron.uhabits";
public static final String EMPTY_DESCRIPTION_HABIT_NAME = "Read books";
public static UiDevice device;
private HabitsApplicationComponent component;
private HabitList habitList;
private Preferences prefs;
private HabitFixtures fixtures;
private HabitCardListCache cache;
public static void startActivity(Class cls)
{
Intent intent = new Intent();
intent.setComponent(new ComponentName(PKG, cls.getCanonicalName()));
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
getApplicationContext().startActivity(intent);
}
@Before
public void setUp() throws Exception
{
device = getInstance(getInstrumentation());
TestButler.setup(getApplicationContext());
TestButler.verifyAnimationsDisabled(getApplicationContext());
HabitsApplication app =
(HabitsApplication) getApplicationContext().getApplicationContext();
component = app.getComponent();
habitList = component.getHabitList();
prefs = component.getPreferences();
cache = component.getHabitCardListCache();
fixtures = new HabitFixtures(component.getModelFactory(), habitList);
resetState();
}
@After
public void tearDown() throws Exception
{
for (int i = 0; i < 10; i++) device.pressBack();
TestButler.teardown(getApplicationContext());
}
private void resetState() throws Exception
{
prefs.clear();
prefs.setFirstRun(false);
prefs.updateLastHint(100, DateUtils.getToday());
habitList.removeAll();
cache.refreshAllHabits();
Thread.sleep(1000);
Habit h1 = fixtures.createEmptyHabit();
h1.setName("Wake up early");
h1.setQuestion("Did you wake up early today?");
h1.setDescription("test description 1");
h1.setColor(new PaletteColor(5));
habitList.update(h1);
Habit h2 = fixtures.createShortHabit();
h2.setName("Track time");
h2.setQuestion("Did you track your time?");
h2.setDescription("test description 2");
h2.setColor(new PaletteColor(5));
habitList.update(h2);
Habit h3 = fixtures.createLongHabit();
h3.setName("Meditate");
h3.setQuestion("Did meditate today?");
h3.setDescription("test description 3");
h3.setColor(new PaletteColor(10));
habitList.update(h3);
Habit h4 = fixtures.createEmptyHabit();
h4.setName(EMPTY_DESCRIPTION_HABIT_NAME);
h4.setQuestion("Did you read books today?");
h4.setDescription("");
h4.setColor(new PaletteColor(2));
habitList.update(h4);
}
protected void rotateDevice() throws Exception
{
device.setOrientationLeft();
device.setOrientationNatural();
}
}

@ -0,0 +1,118 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import com.linkedin.android.testbutler.TestButler
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.ui.screens.habits.list.HabitCardListCache
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
import org.isoron.uhabits.inject.HabitsApplicationComponent
import org.junit.After
import org.junit.Before
open class BaseUserInterfaceTest {
private lateinit var component: HabitsApplicationComponent
private lateinit var habitList: HabitList
private lateinit var prefs: Preferences
private lateinit var fixtures: HabitFixtures
private lateinit var cache: HabitCardListCache
@Before
@Throws(Exception::class)
fun setUp() {
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
TestButler.setup(ApplicationProvider.getApplicationContext())
TestButler.verifyAnimationsDisabled(ApplicationProvider.getApplicationContext())
val app =
ApplicationProvider.getApplicationContext<Context>().applicationContext as HabitsApplication
component = app.component
habitList = component.habitList
prefs = component.preferences
cache = component.habitCardListCache
fixtures = HabitFixtures(component.modelFactory, habitList)
resetState()
}
@After
@Throws(Exception::class)
fun tearDown() {
for (i in 0..9) device.pressBack()
TestButler.teardown(ApplicationProvider.getApplicationContext())
}
@Throws(Exception::class)
private fun resetState() {
prefs.clear()
prefs.isFirstRun = false
prefs.updateLastHint(100, getToday())
habitList.removeAll()
cache.refreshAllHabits()
Thread.sleep(1000)
val h1 = fixtures.createEmptyHabit()
h1.name = "Wake up early"
h1.question = "Did you wake up early today?"
h1.description = "test description 1"
h1.color = PaletteColor(5)
habitList.update(h1)
val h2 = fixtures.createShortHabit()
h2.name = "Track time"
h2.question = "Did you track your time?"
h2.description = "test description 2"
h2.color = PaletteColor(5)
habitList.update(h2)
val h3 = fixtures.createLongHabit()
h3.name = "Meditate"
h3.question = "Did meditate today?"
h3.description = "test description 3"
h3.color = PaletteColor(10)
habitList.update(h3)
val h4 = fixtures.createEmptyHabit()
h4.name = EMPTY_DESCRIPTION_HABIT_NAME
h4.question = "Did you read books today?"
h4.description = ""
h4.color = PaletteColor(2)
habitList.update(h4)
}
@Throws(Exception::class)
protected fun rotateDevice() {
device.setOrientationLeft()
device.setOrientationNatural()
}
companion object {
private const val PKG = "org.isoron.uhabits"
const val EMPTY_DESCRIPTION_HABIT_NAME = "Read books"
lateinit var device: UiDevice
fun startActivity(cls: Class<*>) {
val intent = Intent()
intent.component = ComponentName(PKG, cls.canonicalName!!)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
ApplicationProvider.getApplicationContext<Context>().startActivity(intent)
}
}
}

@ -1,198 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits;
import android.graphics.*;
import android.view.*;
import android.widget.*;
import androidx.annotation.*;
import androidx.test.platform.app.*;
import org.isoron.uhabits.utils.*;
import org.isoron.uhabits.widgets.*;
import java.io.*;
import java.util.*;
import static android.view.View.MeasureSpec.*;
public class BaseViewTest extends BaseAndroidTest
{
public double similarityCutoff = 0.00018;
@Override
public void setUp()
{
super.setUp();
}
protected void assertRenders(View view, String expectedImagePath)
throws IOException
{
Bitmap actual = renderView(view);
if(actual == null) throw new IllegalStateException("actual is null");
assertRenders(actual, expectedImagePath);
}
protected void assertRenders(Bitmap actual, String expectedImagePath) throws IOException {
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
expectedImagePath = "views/" + expectedImagePath;
try
{
Bitmap expected = getBitmapFromAssets(expectedImagePath);
double distance = distance(actual, expected);
if (distance > similarityCutoff)
{
saveBitmap(expectedImagePath, ".expected", expected);
String path = saveBitmap(expectedImagePath, "", actual);
fail(String.format("Image differs from expected " +
"(distance=%f). Actual rendered " +
"image saved to %s", distance, path));
}
expected.recycle();
}
catch (IOException e)
{
String path = saveBitmap(expectedImagePath, "", actual);
fail(String.format("Could not open expected image. Actual " +
"rendered image saved to %s", path));
throw e;
}
}
@NonNull
protected FrameLayout convertToView(BaseWidget widget,
int width,
int height)
{
widget.setDimensions(
new WidgetDimensions(width, height, width, height));
FrameLayout view = new FrameLayout(targetContext);
RemoteViews remoteViews = widget.getPortraitRemoteViews();
view.addView(remoteViews.apply(targetContext, view));
measureView(view, width, height);
return view;
}
protected float dpToPixels(int dp)
{
return InterfaceUtils.dpToPixels(targetContext, dp);
}
protected void measureView(View view, float width, float height)
{
int specWidth = makeMeasureSpec((int) width, View.MeasureSpec.EXACTLY);
int specHeight = makeMeasureSpec((int) height, View.MeasureSpec.EXACTLY);
view.setLayoutParams(new ViewGroup.LayoutParams((int) width, (int) height));
view.measure(specWidth, specHeight);
view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight());
}
protected void skipAnimation(View view)
{
ViewPropertyAnimator animator = view.animate();
animator.setDuration(0);
animator.start();
}
private int[] colorToArgb(int c1)
{
return new int[]{
(c1 >> 24) & 0xff, //alpha
(c1 >> 16) & 0xff, //red
(c1 >> 8) & 0xff, //green
(c1) & 0xff //blue
};
}
private double distance(Bitmap b1, Bitmap b2)
{
if (b1.getWidth() != b2.getWidth()) return 1.0;
if (b1.getHeight() != b2.getHeight()) return 1.0;
Random random = new Random();
double distance = 0.0;
for (int x = 0; x < b1.getWidth(); x++)
{
for (int y = 0; y < b1.getHeight(); y++)
{
if (random.nextInt(4) != 0) continue;
int[] argb1 = colorToArgb(b1.getPixel(x, y));
int[] argb2 = colorToArgb(b2.getPixel(x, y));
distance += Math.abs(argb1[0] - argb2[0]);
distance += Math.abs(argb1[1] - argb2[1]);
distance += Math.abs(argb1[2] - argb2[2]);
distance += Math.abs(argb1[3] - argb2[3]);
}
}
distance /= 255.0 * 16 * b1.getWidth() * b1.getHeight();
return distance;
}
private Bitmap getBitmapFromAssets(String path) throws IOException
{
InputStream stream = testContext.getAssets().open(path);
return BitmapFactory.decodeStream(stream);
}
private String saveBitmap(String filename, String suffix, Bitmap bitmap)
throws IOException
{
File dir = FileUtils.getSDCardDir("test-screenshots");
if (dir == null)
dir = new AndroidDirFinder(targetContext).getFilesDir("test-screenshots");
if (dir == null) throw new RuntimeException(
"Could not find suitable dir for screenshots");
filename = filename.replaceAll("\\.png$", suffix + ".png");
String absolutePath =
String.format("%s/%s", dir.getAbsolutePath(), filename);
File parent = new File(absolutePath).getParentFile();
if (!parent.exists() && !parent.mkdirs()) throw new RuntimeException(
String.format("Could not create dir: %s",
parent.getAbsolutePath()));
FileOutputStream out = new FileOutputStream(absolutePath);
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
return absolutePath;
}
public Bitmap renderView(View view)
{
int width = view.getMeasuredWidth();
int height = view.getMeasuredHeight();
if(view.isLayoutRequested())
measureView(view, width, height);
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
view.invalidate();
view.draw(canvas);
return bitmap;
}
}

@ -0,0 +1,183 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.test.platform.app.InstrumentationRegistry
import org.isoron.uhabits.utils.FileUtils.getSDCardDir
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
import org.isoron.uhabits.widgets.BaseWidget
import org.isoron.uhabits.widgets.WidgetDimensions
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.Random
import kotlin.math.abs
open class BaseViewTest : BaseAndroidTest() {
var similarityCutoff = 0.00018
@Throws(IOException::class)
protected fun assertRenders(view: View, expectedImagePath: String) {
val actual = renderView(view)
assertRenders(actual, expectedImagePath)
}
@Throws(IOException::class)
protected fun assertRenders(actual: Bitmap, expectedImagePath: String) {
var expectedImagePath = expectedImagePath
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
expectedImagePath = "views/$expectedImagePath"
try {
val expected = getBitmapFromAssets(expectedImagePath)
val distance = distance(actual, expected)
if (distance > similarityCutoff) {
saveBitmap(expectedImagePath, ".expected", expected)
val path = saveBitmap(expectedImagePath, "", actual)
fail(
String.format(
"Image differs from expected " +
"(distance=%f). Actual rendered " +
"image saved to %s",
distance,
path
)
)
}
expected.recycle()
} catch (e: IOException) {
val path = saveBitmap(expectedImagePath, "", actual)
fail(
String.format(
"Could not open expected image. Actual " +
"rendered image saved to %s",
path
)
)
throw e
}
}
protected fun convertToView(
widget: BaseWidget,
width: Int,
height: Int
): FrameLayout {
widget.setDimensions(
WidgetDimensions(width, height, width, height)
)
val view = FrameLayout(targetContext)
val remoteViews = widget.portraitRemoteViews
view.addView(remoteViews.apply(targetContext, view))
measureView(view, width.toFloat(), height.toFloat())
return view
}
protected fun dpToPixels(dp: Int): Float {
return dpToPixels(targetContext, dp.toFloat())
}
protected fun measureView(view: View, width: Float, height: Float) {
val specWidth = MeasureSpec.makeMeasureSpec(width.toInt(), MeasureSpec.EXACTLY)
val specHeight = MeasureSpec.makeMeasureSpec(height.toInt(), MeasureSpec.EXACTLY)
view.layoutParams = ViewGroup.LayoutParams(width.toInt(), height.toInt())
view.measure(specWidth, specHeight)
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
}
protected fun skipAnimation(view: View) {
val animator = view.animate()
animator.duration = 0
animator.start()
}
private fun colorToArgb(c1: Int): IntArray {
return intArrayOf(
c1 shr 24 and 0xff, // alpha
c1 shr 16 and 0xff, // red
c1 shr 8 and 0xff, // green
c1 and 0xff // blue
)
}
private fun distance(b1: Bitmap, b2: Bitmap): Double {
if (b1.width != b2.width) return 1.0
if (b1.height != b2.height) return 1.0
val random = Random()
var distance = 0.0
for (x in 0 until b1.width) {
for (y in 0 until b1.height) {
if (random.nextInt(4) != 0) continue
val argb1 = colorToArgb(b1.getPixel(x, y))
val argb2 = colorToArgb(b2.getPixel(x, y))
distance += abs(argb1[0] - argb2[0]).toDouble()
distance += abs(argb1[1] - argb2[1]).toDouble()
distance += abs(argb1[2] - argb2[2]).toDouble()
distance += abs(argb1[3] - argb2[3]).toDouble()
}
}
distance /= 255.0 * 16 * b1.width * b1.height
return distance
}
@Throws(IOException::class)
private fun getBitmapFromAssets(path: String): Bitmap {
val stream = testContext.assets.open(path)
return BitmapFactory.decodeStream(stream)
}
@Throws(IOException::class)
private fun saveBitmap(filename: String, suffix: String, bitmap: Bitmap): String {
var filename = filename
var dir = getSDCardDir("test-screenshots")
if (dir == null) dir = AndroidDirFinder(targetContext).getFilesDir("test-screenshots")
if (dir == null) throw RuntimeException(
"Could not find suitable dir for screenshots"
)
filename = filename.replace("\\.png$".toRegex(), "$suffix.png")
val absolutePath = String.format("%s/%s", dir.absolutePath, filename)
val parent = File(absolutePath).parentFile
if (!parent.exists() && !parent.mkdirs()) throw RuntimeException(
String.format(
"Could not create dir: %s",
parent.absolutePath
)
)
val out = FileOutputStream(absolutePath)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
return absolutePath
}
fun renderView(view: View): Bitmap {
val width = view.measuredWidth
val height = view.measuredHeight
if (view.isLayoutRequested) measureView(view, width.toFloat(), height.toFloat())
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
view.invalidate()
view.draw(canvas)
return bitmap
}
}

@ -1,164 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.utils.DateUtils;
import static org.isoron.uhabits.core.models.Entry.*;
public class HabitFixtures
{
public boolean LONG_HABIT_ENTRIES[] = {
true, false, false, true, true, true, false, false, true, true
};
public int LONG_NUMERICAL_HABIT_ENTRIES[] = {
200000, 0, 150000, 137000, 0, 0, 500000, 30000, 100000, 0, 300000,
100000, 0, 100000
};
private ModelFactory modelFactory;
private final HabitList habitList;
public HabitFixtures(ModelFactory modelFactory, HabitList habitList)
{
this.modelFactory = modelFactory;
this.habitList = habitList;
}
public Habit createEmptyHabit()
{
return createEmptyHabit(null);
}
public Habit createEmptyHabit(Long id)
{
Habit habit = modelFactory.buildHabit();
habit.setName("Meditate");
habit.setQuestion("Did you meditate this morning?");
habit.setDescription("This is a test description");
habit.setColor(new PaletteColor(5));
habit.setFrequency(Frequency.DAILY);
habit.setId(id);
habitList.add(habit);
return habit;
}
public Habit createLongHabit()
{
Habit habit = createEmptyHabit();
habit.setFrequency(new Frequency(3, 7));
habit.setColor(new PaletteColor(7));
Timestamp today = DateUtils.getToday();
int marks[] = { 0, 1, 3, 5, 7, 8, 9, 10, 12, 14, 15, 17, 19, 20, 26, 27,
28, 50, 51, 52, 53, 54, 58, 60, 63, 65, 70, 71, 72, 73, 74, 75, 80,
81, 83, 89, 90, 91, 95, 102, 103, 108, 109, 120};
for (int mark : marks)
habit.getOriginalEntries().add(new Entry(today.minus(mark), YES_MANUAL));
habit.recompute();
return habit;
}
public Habit createVeryLongHabit()
{
Habit habit = createEmptyHabit();
habit.setFrequency(new Frequency(1, 2));
habit.setColor(new PaletteColor(11));
Timestamp today = DateUtils.getToday();
int marks[] = {0, 3, 5, 6, 7, 10, 13, 14, 15, 18, 21, 22, 23, 24, 27, 28, 30, 31, 34, 37,
39, 42, 43, 46, 47, 48, 51, 52, 54, 55, 57, 59, 62, 65, 68, 71, 73, 76, 79,
80, 81, 83, 85, 86, 89, 90, 91, 94, 96, 98, 100, 103, 104, 106, 109, 111,
112, 113, 115, 117, 120, 123, 126, 129, 132, 134, 136, 139, 141, 142, 145,
148, 149, 151, 152, 154, 156, 157, 159, 161, 162, 163, 164, 166, 168, 170,
172, 173, 174, 175, 176, 178, 180, 181, 184, 185, 188, 189, 190, 191, 194,
195, 197, 198, 199, 200, 202, 205, 208, 211, 213, 215, 216, 218, 220, 222,
223, 225, 227, 228, 230, 231, 232, 234, 235, 238, 241, 242, 244, 247, 250,
251, 253, 254, 257, 260, 261, 263, 264, 266, 269, 272, 273, 276, 279, 281,
284, 285, 288, 291, 292, 294, 296, 297, 299, 300, 301, 303, 306, 307, 308,
309, 310, 313, 316, 319, 322, 324, 326, 329, 330, 332, 334, 335, 337, 338,
341, 344, 345, 346, 347, 350, 352, 355, 358, 360, 361, 362, 363, 365, 368,
371, 373, 374, 376, 379, 380, 382, 384, 385, 387, 389, 390, 392, 393, 395,
396, 399, 401, 404, 407, 410, 411, 413, 414, 416, 417, 419, 420, 423, 424,
427, 429, 431, 433, 436, 439, 440, 442, 445, 447, 450, 453, 454, 456, 459,
460, 461, 464, 466, 468, 470, 473, 474, 475, 477, 479, 481, 482, 483, 486,
489, 491, 493, 495, 497, 498, 500, 503, 504, 507, 510, 511, 512, 515, 518,
519, 521, 522, 525, 528, 531, 532, 534, 537, 539, 541, 543, 544, 547, 550,
551, 554, 556, 557, 560, 561, 564, 567, 568, 569, 570, 572, 575, 576, 579,
582, 583, 584, 586, 589};
for (int mark : marks)
habit.getOriginalEntries().add(new Entry(today.minus(mark), YES_MANUAL));
habit.recompute();
return habit;
}
public Habit createLongNumericalHabit()
{
Habit habit = modelFactory.buildHabit();
habit.setName("Read");
habit.setQuestion("How many pages did you walk today?");
habit.setType(Habit.NUMBER_HABIT);
habit.setTargetType(Habit.AT_LEAST);
habit.setTargetValue(200.0);
habit.setUnit("pages");
habitList.add(habit);
Timestamp timestamp = DateUtils.getToday();
for (int value : LONG_NUMERICAL_HABIT_ENTRIES)
{
habit.getOriginalEntries().add(new Entry(timestamp, value));
timestamp = timestamp.minus(1);
}
habit.recompute();
return habit;
}
public Habit createShortHabit()
{
Habit habit = modelFactory.buildHabit();
habit.setName("Wake up early");
habit.setQuestion("Did you wake up before 6am?");
habit.setFrequency(new Frequency(2, 3));
habitList.add(habit);
Timestamp timestamp = DateUtils.getToday();
for (boolean c : LONG_HABIT_ENTRIES)
{
if (c) habit.getOriginalEntries().add(new Entry(timestamp, YES_MANUAL));
timestamp = timestamp.minus(1);
}
habit.recompute();
return habit;
}
public synchronized void purgeHabits(HabitList habitList)
{
habitList.removeAll();
}
}

@ -0,0 +1,140 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL
import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.Frequency.Companion.DAILY
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.Habit.Companion.AT_LEAST
import org.isoron.uhabits.core.models.Habit.Companion.NUMBER_HABIT
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
class HabitFixtures(private val modelFactory: ModelFactory, private val habitList: HabitList) {
var LONG_HABIT_ENTRIES = booleanArrayOf(
true, false, false, true, true, true, false, false, true, true
)
var LONG_NUMERICAL_HABIT_ENTRIES = intArrayOf(
200000, 0, 150000, 137000, 0, 0, 500000, 30000, 100000, 0, 300000,
100000, 0, 100000
)
fun createEmptyHabit(): Habit {
val habit = modelFactory.buildHabit()
habit.name = "Meditate"
habit.question = "Did you meditate this morning?"
habit.description = "This is a test description"
habit.color = PaletteColor(5)
habit.frequency = DAILY
habitList.add(habit)
return habit
}
fun createLongHabit(): Habit {
val habit = createEmptyHabit()
habit.frequency = Frequency(3, 7)
habit.color = PaletteColor(7)
val today: Timestamp = getToday()
val marks = intArrayOf(
0, 1, 3, 5, 7, 8, 9, 10, 12, 14, 15, 17, 19, 20, 26, 27,
28, 50, 51, 52, 53, 54, 58, 60, 63, 65, 70, 71, 72, 73, 74, 75, 80,
81, 83, 89, 90, 91, 95, 102, 103, 108, 109, 120
)
for (mark in marks) habit.originalEntries.add(Entry(today.minus(mark), YES_MANUAL))
habit.recompute()
return habit
}
fun createVeryLongHabit(): Habit {
val habit = createEmptyHabit()
habit.frequency = Frequency(1, 2)
habit.color = PaletteColor(11)
val today: Timestamp = getToday()
val marks = intArrayOf(
0, 3, 5, 6, 7, 10, 13, 14, 15, 18, 21, 22, 23, 24, 27, 28, 30, 31, 34, 37,
39, 42, 43, 46, 47, 48, 51, 52, 54, 55, 57, 59, 62, 65, 68, 71, 73, 76, 79,
80, 81, 83, 85, 86, 89, 90, 91, 94, 96, 98, 100, 103, 104, 106, 109, 111,
112, 113, 115, 117, 120, 123, 126, 129, 132, 134, 136, 139, 141, 142, 145,
148, 149, 151, 152, 154, 156, 157, 159, 161, 162, 163, 164, 166, 168, 170,
172, 173, 174, 175, 176, 178, 180, 181, 184, 185, 188, 189, 190, 191, 194,
195, 197, 198, 199, 200, 202, 205, 208, 211, 213, 215, 216, 218, 220, 222,
223, 225, 227, 228, 230, 231, 232, 234, 235, 238, 241, 242, 244, 247, 250,
251, 253, 254, 257, 260, 261, 263, 264, 266, 269, 272, 273, 276, 279, 281,
284, 285, 288, 291, 292, 294, 296, 297, 299, 300, 301, 303, 306, 307, 308,
309, 310, 313, 316, 319, 322, 324, 326, 329, 330, 332, 334, 335, 337, 338,
341, 344, 345, 346, 347, 350, 352, 355, 358, 360, 361, 362, 363, 365, 368,
371, 373, 374, 376, 379, 380, 382, 384, 385, 387, 389, 390, 392, 393, 395,
396, 399, 401, 404, 407, 410, 411, 413, 414, 416, 417, 419, 420, 423, 424,
427, 429, 431, 433, 436, 439, 440, 442, 445, 447, 450, 453, 454, 456, 459,
460, 461, 464, 466, 468, 470, 473, 474, 475, 477, 479, 481, 482, 483, 486,
489, 491, 493, 495, 497, 498, 500, 503, 504, 507, 510, 511, 512, 515, 518,
519, 521, 522, 525, 528, 531, 532, 534, 537, 539, 541, 543, 544, 547, 550,
551, 554, 556, 557, 560, 561, 564, 567, 568, 569, 570, 572, 575, 576, 579,
582, 583, 584, 586, 589
)
for (mark in marks) habit.originalEntries.add(Entry(today.minus(mark), YES_MANUAL))
habit.recompute()
return habit
}
fun createLongNumericalHabit(): Habit {
val habit = modelFactory.buildHabit().apply {
name = "Read"
question = "How many pages did you walk today?"
type = NUMBER_HABIT
targetType = AT_LEAST
targetValue = 200.0
unit = "pages"
}
habitList.add(habit)
var timestamp: Timestamp = getToday()
for (value in LONG_NUMERICAL_HABIT_ENTRIES) {
habit.originalEntries.add(Entry(timestamp, value))
timestamp = timestamp.minus(1)
}
habit.recompute()
return habit
}
fun createShortHabit(): Habit {
val habit = modelFactory.buildHabit().apply {
name = "Wake up early"
question = "Did you wake up before 6am?"
frequency = Frequency(2, 3)
}
habitList.add(habit)
var timestamp: Timestamp = getToday()
for (c in LONG_HABIT_ENTRIES) {
if (c) habit.originalEntries.add(Entry(timestamp, YES_MANUAL))
timestamp = timestamp.minus(1)
}
habit.recompute()
return habit
}
@Synchronized
fun purgeHabits(habitList: HabitList) {
habitList.removeAll()
}
}

@ -19,6 +19,7 @@
package org.isoron.uhabits
import com.nhaarman.mockitokotlin2.mock
import dagger.Component
import dagger.Module
import dagger.Provides
@ -34,11 +35,11 @@ import org.isoron.uhabits.inject.ActivityScope
import org.isoron.uhabits.inject.HabitModule
import org.isoron.uhabits.inject.HabitsActivityModule
import org.isoron.uhabits.inject.HabitsApplicationComponent
import org.mockito.Mockito.mock
@Module
class TestModule {
@Provides fun listHabitsBehavior(): ListHabitsBehavior = mock(ListHabitsBehavior::class.java)
@Provides
fun listHabitsBehavior(): ListHabitsBehavior = mock()
}
@ActivityScope

@ -16,32 +16,25 @@
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
package org.isoron.uhabits;
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.hamcrest.CoreMatchers.containsString
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import androidx.test.filters.*;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.*;
import org.junit.runner.*;
import java.io.*;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.*;
@RunWith(AndroidJUnit4.class)
@RunWith(AndroidJUnit4::class)
@MediumTest
public class HabitsApplicationTest extends BaseAndroidTest
{
class HabitsApplicationTest : BaseAndroidTest() {
@Test
public void test_getLogcat() throws IOException
{
String msg = "LOGCAT TEST";
new RuntimeException(msg).printStackTrace();
String log = new AndroidBugReporter(targetContext).getLogcat();
assertThat(log, containsString(msg));
@Throws(IOException::class)
fun test_getLogcat() {
val msg = "LOGCAT TEST"
RuntimeException(msg).printStackTrace()
val log = AndroidBugReporter(targetContext).getLogcat()
assertThat(log, containsString(msg))
}
}

@ -1,50 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits;
import org.isoron.uhabits.core.*;
import org.isoron.uhabits.core.tasks.*;
import org.isoron.uhabits.inject.*;
import org.isoron.uhabits.intents.*;
import dagger.*;
@AppScope
@Component(modules = {
AppContextModule.class,
HabitsModule.class,
SingleThreadModule.class,
})
public interface HabitsApplicationTestComponent
extends HabitsApplicationComponent
{
IntentScheduler getIntentScheduler();
}
@dagger.Module
class SingleThreadModule
{
@Provides
@AppScope
static TaskRunner provideTaskRunner()
{
return new SingleThreadTaskRunner();
}
}

@ -16,25 +16,17 @@
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.tasks;
public interface TaskRunner
{
void addListener(Listener listener);
void removeListener(Listener listener);
void execute(Task task);
void publishProgress(Task task, int progress);
int getActiveTaskCount();
interface Listener
{
void onTaskStarted(Task task);
void onTaskFinished(Task task);
}
package org.isoron.uhabits
import dagger.Component
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.inject.AppContextModule
import org.isoron.uhabits.inject.HabitsApplicationComponent
import org.isoron.uhabits.inject.HabitsModule
import org.isoron.uhabits.intents.IntentScheduler
@AppScope
@Component(modules = [AppContextModule::class, HabitsModule::class, SingleThreadModule::class])
interface HabitsApplicationTestComponent : HabitsApplicationComponent {
val intentScheduler: IntentScheduler?
}

@ -0,0 +1,17 @@
package org.isoron.uhabits
import dagger.Module
import dagger.Provides
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.tasks.SingleThreadTaskRunner
import org.isoron.uhabits.core.tasks.TaskRunner
@Module
internal object SingleThreadModule {
@JvmStatic
@Provides
@AppScope
fun provideTaskRunner(): TaskRunner {
return SingleThreadTaskRunner()
}
}

@ -20,7 +20,7 @@
package org.isoron.uhabits.acceptance.steps
import androidx.test.uiautomator.UiSelector
import org.isoron.uhabits.BaseUserInterfaceTest.device
import org.isoron.uhabits.BaseUserInterfaceTest.Companion.device
import org.isoron.uhabits.acceptance.steps.CommonSteps.clickText
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps.MenuItem.SETTINGS
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps.clickMenu

@ -1,84 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import androidx.test.ext.junit.runners.*;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.utils.*;
import org.junit.*;
import org.junit.runner.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class FrequencyChartTest extends BaseViewTest
{
public static final String BASE_PATH = "common/FrequencyChart/";
private FrequencyChart view;
@Override
@Before
public void setUp()
{
super.setUp();
fixtures.purgeHabits(habitList);
Habit habit = fixtures.createLongHabit();
view = new FrequencyChart(targetContext);
view.setFrequency(habit.getOriginalEntries().computeWeekdayFrequency(
habit.isNumerical()
));
view.setColor(PaletteUtilsKt.toFixedAndroidColor(habit.getColor()));
measureView(view, dpToPixels(300), dpToPixels(100));
}
@Test
public void testRender() throws Throwable
{
assertRenders(view, BASE_PATH + "render.png");
}
@Test
public void testRender_withDataOffset() throws Throwable
{
view.onScroll(null, null, -dpToPixels(150), 0);
view.invalidate();
assertRenders(view, BASE_PATH + "renderDataOffset.png");
}
@Test
public void testRender_withDifferentSize() throws Throwable
{
measureView(view, dpToPixels(200), dpToPixels(200));
assertRenders(view, BASE_PATH + "renderDifferentSize.png");
}
@Test
public void testRender_withTransparentBackground() throws Throwable
{
view.setIsBackgroundTransparent(true);
assertRenders(view, BASE_PATH + "renderTransparent.png");
}
}

@ -0,0 +1,77 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.utils.toFixedAndroidColor
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class FrequencyChartTest : BaseViewTest() {
private lateinit var view: FrequencyChart
@Before
override fun setUp() {
super.setUp()
fixtures.purgeHabits(habitList)
val habit = fixtures.createLongHabit()
view = FrequencyChart(targetContext).apply {
setFrequency(habit.originalEntries.computeWeekdayFrequency(habit.isNumerical))
setColor(habit.color.toFixedAndroidColor())
}
measureView(view, dpToPixels(300), dpToPixels(100))
}
@Test
@Throws(Throwable::class)
fun testRender() {
assertRenders(view, BASE_PATH + "render.png")
}
@Test
@Throws(Throwable::class)
fun testRender_withDataOffset() {
view.onScroll(null, null, -dpToPixels(150), 0f)
view.invalidate()
assertRenders(view, BASE_PATH + "renderDataOffset.png")
}
@Test
@Throws(Throwable::class)
fun testRender_withDifferentSize() {
measureView(view, dpToPixels(200), dpToPixels(200))
assertRenders(view, BASE_PATH + "renderDifferentSize.png")
}
@Test
@Throws(Throwable::class)
fun testRender_withTransparentBackground() {
view.setIsBackgroundTransparent(true)
assertRenders(view, BASE_PATH + "renderTransparent.png")
}
companion object {
const val BASE_PATH = "common/FrequencyChart/"
}
}

@ -1,73 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import android.graphics.*;
import androidx.test.filters.*;
import androidx.test.runner.*;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.isoron.uhabits.*;
import org.isoron.uhabits.utils.*;
import org.junit.*;
import org.junit.runner.*;
import java.io.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class RingViewTest extends BaseViewTest
{
private static final String BASE_PATH = "common/RingView/";
private RingView view;
@Override
@Before
public void setUp()
{
super.setUp();
view = new RingView(targetContext);
view.setPercentage(0.6f);
view.setText("60%");
view.setColor(PaletteUtils.getAndroidTestColor(0));
view.setBackgroundColor(Color.WHITE);
view.setThickness(dpToPixels(3));
}
@Test
public void testRender_base() throws IOException
{
measureView(view, dpToPixels(100), dpToPixels(100));
assertRenders(view, BASE_PATH + "render.png");
}
@Test
public void testRender_withDifferentParams() throws IOException
{
view.setPercentage(0.25f);
view.setColor(PaletteUtils.getAndroidTestColor(5));
measureView(view, dpToPixels(200), dpToPixels(200));
assertRenders(view, BASE_PATH + "renderDifferentParams.png");
}
}

@ -0,0 +1,67 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import android.graphics.Color
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.utils.PaletteUtils.getAndroidTestColor
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class)
@MediumTest
class RingViewTest : BaseViewTest() {
private lateinit var view: RingView
@Before
override fun setUp() {
super.setUp()
view = RingView(targetContext).apply {
setPercentage(0.6f)
setText("60%")
setColor(getAndroidTestColor(0))
setBackgroundColor(Color.WHITE)
setThickness(dpToPixels(3))
}
}
@Test
@Throws(IOException::class)
fun testRender_base() {
measureView(view, dpToPixels(100), dpToPixels(100))
assertRenders(view, BASE_PATH + "render.png")
}
@Test
@Throws(IOException::class)
fun testRender_withDifferentParams() {
view.setPercentage(0.25f)
view.setColor(getAndroidTestColor(5))
measureView(view, dpToPixels(200), dpToPixels(200))
assertRenders(view, BASE_PATH + "renderDifferentParams.png")
}
companion object {
private const val BASE_PATH = "common/RingView/"
}
}

@ -1,109 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import androidx.test.ext.junit.runners.*;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.ui.screens.habits.show.views.*;
import org.isoron.uhabits.utils.*;
import org.junit.*;
import org.junit.runner.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class ScoreChartTest extends BaseViewTest
{
private static final String BASE_PATH = "common/ScoreChart/";
private Habit habit;
private ScoreChart view;
@Override
@Before
public void setUp()
{
super.setUp();
fixtures.purgeHabits(habitList);
habit = fixtures.createLongHabit();
ScoreCardState state = ScoreCardPresenter.Companion.buildState(habit, prefs.getFirstWeekdayInt(), 0);
view = new ScoreChart(targetContext);
view.setScores(state.getScores());
view.setColor(PaletteUtilsKt.toFixedAndroidColor(state.getColor()));
view.setBucketSize(state.getBucketSize());
measureView(view, dpToPixels(300), dpToPixels(200));
}
@Test
public void testRender() throws Throwable
{
assertRenders(view, BASE_PATH + "render.png");
}
@Test
public void testRender_withDataOffset() throws Throwable
{
view.onScroll(null, null, -dpToPixels(150), 0);
view.invalidate();
assertRenders(view, BASE_PATH + "renderDataOffset.png");
}
@Test
public void testRender_withDifferentSize() throws Throwable
{
measureView(view, dpToPixels(200), dpToPixels(200));
assertRenders(view, BASE_PATH + "renderDifferentSize.png");
}
@Test
public void testRender_withMonthlyBucket() throws Throwable
{
ScoreCardState model = ScoreCardPresenter.Companion.buildState(habit, prefs.getFirstWeekdayInt(), 2);
view.setScores(model.getScores());
view.setBucketSize(model.getBucketSize());
view.invalidate();
assertRenders(view, BASE_PATH + "renderMonthly.png");
}
@Test
public void testRender_withTransparentBackground() throws Throwable
{
view.setIsTransparencyEnabled(true);
assertRenders(view, BASE_PATH + "renderTransparent.png");
}
@Test
public void testRender_withYearlyBucket() throws Throwable
{
ScoreCardState model = ScoreCardPresenter.Companion.buildState(habit, prefs.getFirstWeekdayInt(), 4);
view.setScores(model.getScores());
view.setBucketSize(model.getBucketSize());
view.invalidate();
assertRenders(view, BASE_PATH + "renderYearly.png");
}
}

@ -0,0 +1,102 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.ui.screens.habits.show.views.ScoreCardPresenter.Companion.buildState
import org.isoron.uhabits.utils.toFixedAndroidColor
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ScoreChartTest : BaseViewTest() {
private lateinit var habit: Habit
private lateinit var view: ScoreChart
@Before
override fun setUp() {
super.setUp()
fixtures.purgeHabits(habitList)
habit = fixtures.createLongHabit()
val state = buildState(habit, prefs.firstWeekdayInt, 0)
view = ScoreChart(targetContext).apply {
setScores(state.scores)
setColor(state.color.toFixedAndroidColor())
setBucketSize(state.bucketSize)
}
measureView(view, dpToPixels(300), dpToPixels(200))
}
@Test
@Throws(Throwable::class)
fun testRender() {
assertRenders(view, BASE_PATH + "render.png")
}
@Test
@Throws(Throwable::class)
fun testRender_withDataOffset() {
view.onScroll(null, null, -dpToPixels(150), 0f)
view.invalidate()
assertRenders(view, BASE_PATH + "renderDataOffset.png")
}
@Test
@Throws(Throwable::class)
fun testRender_withDifferentSize() {
measureView(view, dpToPixels(200), dpToPixels(200))
assertRenders(view, BASE_PATH + "renderDifferentSize.png")
}
@Test
@Throws(Throwable::class)
fun testRender_withMonthlyBucket() {
val (scores, bucketSize) = buildState(habit, prefs.firstWeekdayInt, 2)
view.setScores(scores)
view.setBucketSize(bucketSize)
view.invalidate()
assertRenders(view, BASE_PATH + "renderMonthly.png")
}
@Test
@Throws(Throwable::class)
fun testRender_withTransparentBackground() {
view.setIsTransparencyEnabled(true)
assertRenders(view, BASE_PATH + "renderTransparent.png")
}
@Test
@Throws(Throwable::class)
fun testRender_withYearlyBucket() {
val state = buildState(habit, prefs.firstWeekdayInt, 4)
view.setScores(state.scores)
view.setBucketSize(state.bucketSize)
view.invalidate()
assertRenders(view, BASE_PATH + "renderYearly.png")
}
companion object {
private const val BASE_PATH = "common/ScoreChart/"
}
}

@ -1,75 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import androidx.test.filters.*;
import androidx.test.runner.*;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.utils.*;
import org.junit.*;
import org.junit.runner.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class StreakChartTest extends BaseViewTest
{
private static final String BASE_PATH = "common/StreakChart/";
private StreakChart view;
@Override
@Before
public void setUp()
{
super.setUp();
fixtures.purgeHabits(habitList);
Habit habit = fixtures.createLongHabit();
view = new StreakChart(targetContext);
view.setColor(PaletteUtilsKt.toFixedAndroidColor(habit.getColor()));
view.setStreaks(habit.getStreaks().getBest(5));
measureView(view, dpToPixels(300), dpToPixels(100));
}
@Test
public void testRender() throws Throwable
{
assertRenders(view, BASE_PATH + "render.png");
}
@Test
public void testRender_withSmallSize() throws Throwable
{
measureView(view, dpToPixels(100), dpToPixels(100));
assertRenders(view, BASE_PATH + "renderSmallSize.png");
}
@Test
public void testRender_withTransparentBackground() throws Throwable
{
view.setIsBackgroundTransparent(true);
assertRenders(view, BASE_PATH + "renderTransparent.png");
}
}

@ -0,0 +1,68 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.utils.toFixedAndroidColor
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class StreakChartTest : BaseViewTest() {
private lateinit var view: StreakChart
@Before
override fun setUp() {
super.setUp()
fixtures.purgeHabits(habitList)
val habit = fixtures.createLongHabit()
view = StreakChart(targetContext).apply {
setColor(habit.color.toFixedAndroidColor())
setStreaks(habit.streaks.getBest(5))
}
measureView(view, dpToPixels(300), dpToPixels(100))
}
@Test
@Throws(Throwable::class)
fun testRender() {
assertRenders(view, BASE_PATH + "render.png")
}
@Test
@Throws(Throwable::class)
fun testRender_withSmallSize() {
measureView(view, dpToPixels(100), dpToPixels(100))
assertRenders(view, BASE_PATH + "renderSmallSize.png")
}
@Test
@Throws(Throwable::class)
fun testRender_withTransparentBackground() {
view.setIsBackgroundTransparent(true)
assertRenders(view, BASE_PATH + "renderTransparent.png")
}
companion object {
private const val BASE_PATH = "common/StreakChart/"
}
}

@ -1,80 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.habits.list.views;
import androidx.test.filters.*;
import androidx.test.runner.*;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.utils.*;
import org.junit.*;
import org.junit.runner.*;
import static org.mockito.Mockito.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class HeaderViewTest extends BaseViewTest
{
public static final String PATH = "habits/list/HeaderView/";
private HeaderView view;
private Preferences prefs;
private MidnightTimer midnightTimer;
@Override
@Before
public void setUp()
{
super.setUp();
prefs = mock(Preferences.class);
midnightTimer = mock(MidnightTimer.class);
view = new HeaderView(targetContext, prefs, midnightTimer);
view.setButtonCount(5);
measureView(view, dpToPixels(600), dpToPixels(48));
}
@Test
public void testRender() throws Exception
{
when(prefs.isCheckmarkSequenceReversed()).thenReturn(false);
assertRenders(view, PATH + "render.png");
verify(prefs).isCheckmarkSequenceReversed();
verifyNoMoreInteractions(prefs);
}
@Test
public void testRender_reverse() throws Exception
{
when(prefs.isCheckmarkSequenceReversed()).thenReturn(true);
assertRenders(view, PATH + "render_reverse.png");
verify(prefs).isCheckmarkSequenceReversed();
verifyNoMoreInteractions(prefs);
}
}

@ -0,0 +1,68 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.habits.list.views
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
import com.nhaarman.mockitokotlin2.whenever
import org.isoron.uhabits.BaseViewTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class HeaderViewTest : BaseViewTest() {
private lateinit var view: HeaderView
@Before
override fun setUp() {
super.setUp()
prefs = mock()
view = HeaderView(targetContext, prefs, mock())
view.buttonCount = 5
measureView(view, dpToPixels(600), dpToPixels(48))
}
@Test
@Throws(Exception::class)
fun testRender() {
whenever(prefs.isCheckmarkSequenceReversed).thenReturn(false)
assertRenders(view, PATH + "render.png")
verify(prefs).isCheckmarkSequenceReversed
verifyNoMoreInteractions(prefs)
}
@Test
@Throws(Exception::class)
fun testRender_reverse() {
doReturn(true).whenever(prefs).isCheckmarkSequenceReversed
assertRenders(view, PATH + "render_reverse.png")
verify(prefs).isCheckmarkSequenceReversed
verifyNoMoreInteractions(prefs)
}
companion object {
const val PATH = "habits/list/HeaderView/"
}
}

@ -1,80 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.habits.list.views;
import androidx.test.filters.*;
import androidx.test.runner.*;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.ui.screens.habits.list.*;
import org.junit.*;
import org.junit.runner.*;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.*;
import static org.mockito.Mockito.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class HintViewTest extends BaseViewTest
{
public static final String PATH = "habits/list/HintView/";
private HintView view;
private HintList list;
@Before
@Override
public void setUp()
{
super.setUp();
list = mock(HintList.class);
view = new HintView(targetContext, list);
measureView(view, 400, 200);
String text =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
when(list.shouldShow()).thenReturn(true);
when(list.pop()).thenReturn(text);
view.showNext();
skipAnimation(view);
}
@Test
public void testRender() throws Exception
{
assertRenders(view, PATH + "render.png");
}
@Test
public void testClick() throws Exception
{
assertThat(view.getAlpha(), equalTo(1f));
view.performClick();
skipAnimation(view);
assertThat(view.getAlpha(), equalTo(0f));
}
}

@ -0,0 +1,71 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.habits.list.views
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.core.ui.screens.habits.list.HintList
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class HintViewTest : BaseViewTest() {
private lateinit var view: HintView
private lateinit var list: HintList
@Before
override fun setUp() {
super.setUp()
list = mock()
view = HintView(targetContext, list)
measureView(view, 400f, 200f)
val text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
doReturn(true).whenever(list).shouldShow()
doReturn(text).whenever(list).pop()
view.showNext()
skipAnimation(view)
}
@Test
@Throws(Exception::class)
fun testRender() {
assertRenders(view, PATH + "render.png")
}
@Test
@Throws(Exception::class)
fun testClick() {
assertThat(view.alpha, equalTo(1f))
view.performClick()
skipAnimation(view)
assertThat(view.alpha, equalTo(0f))
}
companion object {
const val PATH = "habits/list/HintView/"
}
}

@ -24,7 +24,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.R
import org.isoron.uhabits.core.ui.screens.habits.show.views.ScoreCardPresenter
import org.isoron.uhabits.core.ui.screens.habits.show.views.ScoreCardPresenter.Companion.buildState
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@ -43,13 +43,7 @@ class ScoreCardViewTest : BaseViewTest() {
.from(targetContext)
.inflate(R.layout.show_habit, null)
.findViewById<View>(R.id.scoreCard) as ScoreCardView
view.setState(
ScoreCardPresenter.buildState(
habit = habit,
firstWeekday = 0,
spinnerPosition = 0,
)
)
view.setState(buildState(habit = habit, firstWeekday = 0, spinnerPosition = 0))
measureView(view, 800f, 600f)
}

@ -1,59 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.database;
import android.database.sqlite.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.database.*;
import org.junit.*;
import java.util.*;
import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.core.IsEqual.*;
public class AndroidDatabaseTest extends BaseAndroidTest
{
private AndroidDatabase db;
@Override
public void setUp()
{
super.setUp();
db = new AndroidDatabase(SQLiteDatabase.create(null), null);
db.execute("create table test(color int, name string)");
}
@Test
public void testInsert() throws Exception
{
HashMap<String, Object> map = new HashMap<>();
map.put("name", "asd");
map.put("color", null);
db.insert("test", map);
Cursor c = db.query("select * from test");
c.moveToNext();
assertNull(c.getInt(0));
assertThat(c.getString(1), equalTo("asd"));
}
}

@ -0,0 +1,46 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.database
import android.database.sqlite.SQLiteDatabase
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.core.IsEqual.equalTo
import org.isoron.uhabits.BaseAndroidTest
import org.isoron.uhabits.core.database.Cursor
import org.junit.Test
class AndroidDatabaseTest : BaseAndroidTest() {
private lateinit var db: AndroidDatabase
override fun setUp() {
super.setUp()
db = AndroidDatabase(SQLiteDatabase.create(null), null)
db.execute("create table test(color int, name string)")
}
@Test
@Throws(Exception::class)
fun testInsert() {
val map = mapOf(Pair("name", "asd"), Pair("color", null))
db.insert("test", map)
val c: Cursor = db.query("select * from test")
c.moveToNext()
c.getInt(0)!!
assertThat(c.getString(1), equalTo("asd"))
}
}

@ -97,7 +97,7 @@ class IntentSchedulerTest : BaseAndroidTest() {
val habit = habitList.getByPosition(0)
val scheduler = appComponent.intentScheduler
assertThat(scheduler.scheduleShowReminder(reminderTime, habit, 0), equalTo(OK))
assertThat(scheduler!!.scheduleShowReminder(reminderTime, habit, 0), equalTo(OK))
setSystemTime("America/Chicago", 2020, JUNE, 2, 22, 44)
assertNull(ReminderReceiver.lastReceivedIntent)
@ -116,7 +116,7 @@ class IntentSchedulerTest : BaseAndroidTest() {
val updateTime = 1591155900000 // 2020-06-02 22:45:00 (America/Chicago)
val scheduler = appComponent.intentScheduler
assertThat(scheduler.scheduleWidgetUpdate(updateTime), equalTo(OK))
assertThat(scheduler!!.scheduleWidgetUpdate(updateTime), equalTo(OK))
setSystemTime("America/Chicago", 2020, JUNE, 2, 22, 44)
assertNull(WidgetReceiver.lastReceivedIntent)

@ -1,78 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.performance;
import androidx.test.ext.junit.runners.*;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.commands.*;
import org.isoron.uhabits.core.database.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.models.sqlite.*;
import org.junit.*;
import org.junit.runner.*;
import static org.isoron.uhabits.core.models.Timestamp.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class PerformanceTest extends BaseAndroidTest
{
private Habit habit;
@Override
public void setUp()
{
super.setUp();
habit = fixtures.createLongHabit();
}
@Ignore
@Test(timeout = 5000)
public void benchmarkCreateHabitCommand()
{
Database db = ((SQLModelFactory) modelFactory).getDatabase();
db.beginTransaction();
for (int i = 0; i < 1_000; i++)
{
Habit model = modelFactory.buildHabit();
new CreateHabitCommand(modelFactory, habitList, model).run();
}
db.setTransactionSuccessful();
db.endTransaction();
}
@Ignore
@Test(timeout = 5000)
public void benchmarkCreateRepetitionCommand()
{
Database db = ((SQLModelFactory) modelFactory).getDatabase();
db.beginTransaction();
Habit habit = fixtures.createEmptyHabit();
for (int i = 0; i < 5_000; i++)
{
Timestamp timestamp = new Timestamp(i * DAY_LENGTH);
new CreateRepetitionCommand(habitList, habit, timestamp, 1).run();
}
db.setTransactionSuccessful();
db.endTransaction();
}
}

@ -0,0 +1,69 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.performance
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseAndroidTest
import org.isoron.uhabits.core.commands.CreateHabitCommand
import org.isoron.uhabits.core.commands.CreateRepetitionCommand
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.models.Timestamp.Companion.DAY_LENGTH
import org.isoron.uhabits.core.models.sqlite.SQLModelFactory
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class PerformanceTest : BaseAndroidTest() {
private var habit: Habit? = null
override fun setUp() {
super.setUp()
habit = fixtures.createLongHabit()
}
@Ignore
@Test(timeout = 5000)
fun benchmarkCreateHabitCommand() {
val db = (modelFactory as SQLModelFactory).database
db.beginTransaction()
for (i in 0..999) {
val model = modelFactory.buildHabit()
CreateHabitCommand(modelFactory, habitList, model).run()
}
db.setTransactionSuccessful()
db.endTransaction()
}
@Ignore
@Test(timeout = 5000)
fun benchmarkCreateRepetitionCommand() {
val db = (modelFactory as SQLModelFactory).database
db.beginTransaction()
val habit = fixtures.createEmptyHabit()
for (i in 0..4999) {
val timestamp: Timestamp = Timestamp(i * DAY_LENGTH)
CreateRepetitionCommand(habitList, habit, timestamp, 1).run()
}
db.setTransactionSuccessful()
db.endTransaction()
}
}

@ -1,95 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets;
import android.widget.*;
import androidx.test.ext.junit.runners.*;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.utils.*;
import org.junit.*;
import org.junit.runner.*;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.*;
import static org.isoron.uhabits.core.models.Entry.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class CheckmarkWidgetTest extends BaseViewTest
{
private static final String PATH = "widgets/CheckmarkWidget/";
private Habit habit;
private EntryList entries;
private FrameLayout view;
private Timestamp today = DateUtils.getTodayWithOffset();
@Override
public void setUp()
{
super.setUp();
setTheme(R.style.WidgetTheme);
prefs.setWidgetOpacity(255);
prefs.setSkipEnabled(true);
habit = fixtures.createVeryLongHabit();
entries = habit.getComputedEntries();
CheckmarkWidget widget = new CheckmarkWidget(targetContext, 0, habit);
view = convertToView(widget, 150, 200);
assertThat(entries.get(today).getValue(), equalTo(YES_MANUAL));
}
@Test
public void testClick() throws Exception
{
Button button = (Button) view.findViewById(R.id.button);
assertThat(button, is(not(nullValue())));
// A better test would be to capture the intent, but it doesn't seem
// possible to capture intents sent to BroadcastReceivers.
button.performClick();
sleep(1000);
assertThat(entries.get(today).getValue(), equalTo(SKIP));
button.performClick();
sleep(1000);
assertThat(entries.get(today).getValue(), equalTo(NO));
}
@Test
public void testIsInstalled()
{
assertWidgetProviderIsInstalled(CheckmarkWidgetProvider.class);
}
@Test
public void testRender() throws Exception
{
assertRenders(view, PATH + "render.png");
}
}

@ -0,0 +1,91 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets
import android.view.View
import android.widget.Button
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.hamcrest.CoreMatchers
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.EntryList
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class CheckmarkWidgetTest : BaseViewTest() {
private lateinit var habit: Habit
private lateinit var entries: EntryList
private lateinit var view: FrameLayout
private val today = getTodayWithOffset()
override fun setUp() {
super.setUp()
setTheme(R.style.WidgetTheme)
prefs.widgetOpacity = 255
prefs.isSkipEnabled = true
habit = fixtures.createVeryLongHabit()
entries = habit.computedEntries
val widget = CheckmarkWidget(targetContext, 0, habit)
view = convertToView(widget, 150, 200)
assertThat(entries.get(today).value, equalTo(Entry.YES_MANUAL))
}
@Test
@Throws(Exception::class)
fun testClick() {
val button = view.findViewById<View>(R.id.button) as Button
assertThat(
button,
`is`(CoreMatchers.not(CoreMatchers.nullValue()))
)
// A better test would be to capture the intent, but it doesn't seem
// possible to capture intents sent to BroadcastReceivers.
button.performClick()
sleep(1000)
assertThat(entries.get(today).value, equalTo(Entry.SKIP))
button.performClick()
sleep(1000)
assertThat(entries.get(today).value, equalTo(Entry.NO))
}
@Test
fun testIsInstalled() {
assertWidgetProviderIsInstalled(CheckmarkWidgetProvider::class.java)
}
@Test
@Throws(Exception::class)
fun testRender() {
assertRenders(view, PATH + "render.png")
}
companion object {
private const val PATH = "widgets/CheckmarkWidget/"
}
}

@ -1,67 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets;
import android.widget.*;
import androidx.test.ext.junit.runners.*;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.junit.*;
import org.junit.runner.*;
import java.util.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class FrequencyWidgetTest extends BaseViewTest
{
private static final String PATH = "widgets/FrequencyWidget/";
private Habit habit;
private FrameLayout view;
@Override
public void setUp()
{
super.setUp();
setTheme(R.style.WidgetTheme);
prefs.setWidgetOpacity(255);
habit = fixtures.createVeryLongHabit();
FrequencyWidget widget = new FrequencyWidget(targetContext, 0, habit, Calendar.SUNDAY);
view = convertToView(widget, 400, 400);
}
@Test
public void testIsInstalled()
{
assertWidgetProviderIsInstalled(FrequencyWidgetProvider.class);
}
@Test
public void testRender() throws Exception
{
assertRenders(view, PATH + "render.png");
}
}

@ -0,0 +1,59 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Habit
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Calendar
@RunWith(AndroidJUnit4::class)
@MediumTest
class FrequencyWidgetTest : BaseViewTest() {
private lateinit var habit: Habit
private lateinit var view: FrameLayout
override fun setUp() {
super.setUp()
setTheme(R.style.WidgetTheme)
prefs.widgetOpacity = 255
habit = fixtures.createVeryLongHabit()
val widget = FrequencyWidget(targetContext, 0, habit, Calendar.SUNDAY)
view = convertToView(widget, 400, 400)
}
@Test
fun testIsInstalled() {
assertWidgetProviderIsInstalled(FrequencyWidgetProvider::class.java)
}
@Test
@Throws(Exception::class)
fun testRender() {
assertRenders(view, PATH + "render.png")
}
companion object {
private const val PATH = "widgets/FrequencyWidget/"
}
}

@ -1,65 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets;
import android.widget.*;
import androidx.test.ext.junit.runners.*;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.junit.*;
import org.junit.runner.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class HistoryWidgetTest extends BaseViewTest
{
private static final String PATH = "widgets/HistoryWidget/";
private Habit habit;
private FrameLayout view;
@Override
public void setUp()
{
super.setUp();
setTheme(R.style.WidgetTheme);
prefs.setWidgetOpacity(255);
habit = fixtures.createVeryLongHabit();
HistoryWidget widget = new HistoryWidget(targetContext, 0, habit);
view = convertToView(widget, 400, 400);
}
@Test
public void testIsInstalled()
{
assertWidgetProviderIsInstalled(HistoryWidgetProvider.class);
}
@Test
public void testRender() throws Exception
{
assertRenders(view, PATH + "render.png");
}
}

@ -0,0 +1,58 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Habit
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class HistoryWidgetTest : BaseViewTest() {
private lateinit var habit: Habit
private lateinit var view: FrameLayout
override fun setUp() {
super.setUp()
setTheme(R.style.WidgetTheme)
prefs.widgetOpacity = 255
habit = fixtures.createVeryLongHabit()
val widget = HistoryWidget(targetContext, 0, habit)
view = convertToView(widget, 400, 400)
}
@Test
fun testIsInstalled() {
assertWidgetProviderIsInstalled(HistoryWidgetProvider::class.java)
}
@Test
@Throws(Exception::class)
fun testRender() {
assertRenders(view, PATH + "render.png")
}
companion object {
private const val PATH = "widgets/HistoryWidget/"
}
}

@ -1,65 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets;
import android.widget.*;
import androidx.test.ext.junit.runners.*;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.junit.*;
import org.junit.runner.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class ScoreWidgetTest extends BaseViewTest
{
private static final String PATH = "widgets/ScoreWidget/";
private Habit habit;
private FrameLayout view;
@Override
public void setUp()
{
super.setUp();
setTheme(R.style.WidgetTheme);
prefs.setWidgetOpacity(255);
habit = fixtures.createVeryLongHabit();
ScoreWidget widget = new ScoreWidget(targetContext, 0, habit);
view = convertToView(widget, 400, 400);
}
@Test
public void testIsInstalled()
{
assertWidgetProviderIsInstalled(ScoreWidgetProvider.class);
}
@Test
public void testRender() throws Exception
{
assertRenders(view, PATH + "render.png");
}
}

@ -0,0 +1,58 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Habit
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ScoreWidgetTest : BaseViewTest() {
private lateinit var habit: Habit
private lateinit var view: FrameLayout
override fun setUp() {
super.setUp()
setTheme(R.style.WidgetTheme)
prefs.widgetOpacity = 255
habit = fixtures.createVeryLongHabit()
val widget = ScoreWidget(targetContext, 0, habit)
view = convertToView(widget, 400, 400)
}
@Test
fun testIsInstalled() {
assertWidgetProviderIsInstalled(ScoreWidgetProvider::class.java)
}
@Test
@Throws(Exception::class)
fun testRender() {
assertRenders(view, PATH + "render.png")
}
companion object {
private const val PATH = "widgets/ScoreWidget/"
}
}

@ -1,65 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets;
import android.widget.*;
import androidx.test.ext.junit.runners.*;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.junit.*;
import org.junit.runner.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class StreakWidgetTest extends BaseViewTest
{
private static final String PATH = "widgets/StreakWidget/";
private Habit habit;
private FrameLayout view;
@Override
public void setUp()
{
super.setUp();
setTheme(R.style.WidgetTheme);
prefs.setWidgetOpacity(255);
habit = fixtures.createVeryLongHabit();
StreakWidget widget = new StreakWidget(targetContext, 0, habit);
view = convertToView(widget, 400, 400);
}
@Test
public void testIsInstalled()
{
assertWidgetProviderIsInstalled(StreakWidgetProvider.class);
}
@Test
public void testRender() throws Exception
{
assertRenders(view, PATH + "render.png");
}
}

@ -0,0 +1,58 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Habit
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class StreakWidgetTest : BaseViewTest() {
private lateinit var habit: Habit
private lateinit var view: FrameLayout
override fun setUp() {
super.setUp()
setTheme(R.style.WidgetTheme)
prefs.widgetOpacity = 255
habit = fixtures.createVeryLongHabit()
val widget = StreakWidget(targetContext, 0, habit)
view = convertToView(widget, 400, 400)
}
@Test
fun testIsInstalled() {
assertWidgetProviderIsInstalled(StreakWidgetProvider::class.java)
}
@Test
@Throws(Exception::class)
fun testRender() {
assertRenders(view, PATH + "render.png")
}
companion object {
private const val PATH = "widgets/StreakWidget/"
}
}

@ -1,68 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets;
import android.widget.*;
import androidx.test.ext.junit.runners.*;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.junit.*;
import org.junit.runner.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class TargetWidgetTest extends BaseViewTest
{
private static final String PATH = "widgets/TargetWidget/";
private Habit habit;
private FrameLayout view;
@Override
public void setUp()
{
super.setUp();
setTheme(R.style.WidgetTheme);
prefs.setWidgetOpacity(255);
habit = fixtures.createLongNumericalHabit();
habit.setColor(new PaletteColor(11));
habit.setFrequency(Frequency.WEEKLY);
habit.recompute();
TargetWidget widget = new TargetWidget(targetContext, 0, habit);
view = convertToView(widget, 400, 400);
}
@Test
public void testIsInstalled()
{
assertWidgetProviderIsInstalled(TargetWidgetProvider.class);
}
@Test
public void testRender() throws Exception
{
assertRenders(view, PATH + "render.png");
}
}

@ -0,0 +1,64 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.PaletteColor
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class TargetWidgetTest : BaseViewTest() {
private lateinit var habit: Habit
private lateinit var view: FrameLayout
override fun setUp() {
super.setUp()
setTheme(R.style.WidgetTheme)
prefs.widgetOpacity = 255
habit = fixtures.createLongNumericalHabit().apply {
color = PaletteColor(11)
frequency = Frequency.WEEKLY
recompute()
}
val widget = TargetWidget(targetContext, 0, habit)
view = convertToView(widget, 400, 400)
}
@Test
fun testIsInstalled() {
assertWidgetProviderIsInstalled(TargetWidgetProvider::class.java)
}
@Test
@Throws(Exception::class)
fun testRender() {
assertRenders(view, PATH + "render.png")
}
companion object {
private const val PATH = "widgets/TargetWidget/"
}
}

@ -1,79 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets.views;
import androidx.test.ext.junit.runners.*;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.utils.*;
import org.junit.*;
import org.junit.runner.*;
import java.io.*;
@RunWith(AndroidJUnit4.class)
@MediumTest
public class CheckmarkWidgetViewTest extends BaseViewTest
{
private static final String PATH = "widgets/CheckmarkWidgetView/";
private CheckmarkWidgetView view;
@Override
@Before
public void setUp()
{
super.setUp();
setTheme(R.style.WidgetTheme);
Habit habit = fixtures.createShortHabit();
Timestamp today = DateUtils.getTodayWithOffset();
view = new CheckmarkWidgetView(targetContext);
double score = habit.getScores().get(today).getValue();
float percentage = (float) score;
view.setActiveColor(PaletteUtils.getAndroidTestColor(0));
view.setEntryState(habit.getComputedEntries().get(today).getValue());
view.setEntryValue(habit.getComputedEntries().get(today).getValue());
view.setPercentage(percentage);
view.setName(habit.getName());
view.refresh();
measureView(view, dpToPixels(100), dpToPixels(200));
}
@Test
public void testRender_checked() throws IOException
{
assertRenders(view, PATH + "checked.png");
}
@Test
public void testRender_largeSize() throws IOException
{
measureView(view, dpToPixels(300), dpToPixels(300));
assertRenders(view, PATH + "large_size.png");
}
}

@ -0,0 +1,72 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets.views
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.isoron.uhabits.BaseViewTest
import org.isoron.uhabits.R
import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset
import org.isoron.uhabits.utils.PaletteUtils.getAndroidTestColor
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class)
@MediumTest
class CheckmarkWidgetViewTest : BaseViewTest() {
private lateinit var view: CheckmarkWidgetView
@Before
override fun setUp() {
super.setUp()
setTheme(R.style.WidgetTheme)
val habit = fixtures.createShortHabit()
val computedEntries = habit.computedEntries
val scores = habit.scores
val today = getTodayWithOffset()
val score = scores[today].value
view = CheckmarkWidgetView(targetContext).apply {
activeColor = getAndroidTestColor(0)
entryState = computedEntries.get(today).value
entryValue = computedEntries.get(today).value
percentage = score.toFloat()
name = habit.name
}
view.refresh()
measureView(view, dpToPixels(100), dpToPixels(200))
}
@Test
@Throws(IOException::class)
fun testRender_checked() {
assertRenders(view, PATH + "checked.png")
}
@Test
@Throws(IOException::class)
fun testRender_largeSize() {
measureView(view, dpToPixels(300), dpToPixels(300))
assertRenders(view, PATH + "large_size.png")
}
companion object {
private const val PATH = "widgets/CheckmarkWidgetView/"
}
}

@ -41,7 +41,7 @@ constructor(
preferences: Preferences,
) : ThemeSwitcher(preferences) {
private var currentTheme: Theme = LightTheme()
override var currentTheme: Theme = LightTheme()
override fun getSystemTheme(): Int {
if (SDK_INT < 29) return THEME_LIGHT
@ -53,10 +53,6 @@ constructor(
}
}
override fun getCurrentTheme(): Theme {
return currentTheme
}
override fun applyDarkTheme() {
currentTheme = DarkTheme()
context.setTheme(R.style.AppBaseThemeDark)

@ -1,71 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import android.os.*;
import androidx.customview.view.*;
public class BundleSavedState extends AbsSavedState
{
public static final Parcelable.Creator<BundleSavedState> CREATOR =
new ClassLoaderCreator<BundleSavedState>()
{
@Override
public BundleSavedState createFromParcel(Parcel source,
ClassLoader loader)
{
return new BundleSavedState(source, loader);
}
@Override
public BundleSavedState createFromParcel(Parcel source)
{
return null;
}
@Override
public BundleSavedState[] newArray(int size)
{
return new BundleSavedState[size];
}
};
public final Bundle bundle;
public BundleSavedState(Parcelable superState, Bundle bundle)
{
super(superState);
this.bundle = bundle;
}
public BundleSavedState(Parcel source, ClassLoader loader)
{
super(source, loader);
this.bundle = source.readBundle(loader);
}
@Override
public void writeToParcel(Parcel out, int flags)
{
super.writeToParcel(out, flags);
out.writeBundle(bundle);
}
}

@ -0,0 +1,62 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import android.os.Parcelable.ClassLoaderCreator
import androidx.customview.view.AbsSavedState
class BundleSavedState : AbsSavedState {
@JvmField val bundle: Bundle?
constructor(superState: Parcelable?, bundle: Bundle?) : super(superState!!) {
this.bundle = bundle
}
constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) {
bundle = source.readBundle(loader)
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeBundle(bundle)
}
companion object {
val CREATOR: Parcelable.Creator<BundleSavedState> =
object : ClassLoaderCreator<BundleSavedState> {
override fun createFromParcel(
source: Parcel,
loader: ClassLoader
): BundleSavedState {
return BundleSavedState(source, loader)
}
override fun createFromParcel(source: Parcel): BundleSavedState? {
return null
}
override fun newArray(size: Int): Array<BundleSavedState?> {
return arrayOfNulls(size)
}
}
}
}

@ -1,347 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import android.content.*;
import android.graphics.*;
import android.util.*;
import androidx.annotation.NonNull;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.utils.*;
import java.text.*;
import java.util.*;
public class FrequencyChart extends ScrollableChart
{
private Paint pGrid;
private float em;
private SimpleDateFormat dfMonth;
private SimpleDateFormat dfYear;
private Paint pText, pGraph;
private RectF rect, prevRect;
private int baseSize;
private int paddingTop;
private float columnWidth;
private int columnHeight;
private int nColumns;
private int textColor;
private int gridColor;
private int[] colors;
private int primaryColor;
private boolean isBackgroundTransparent;
@NonNull
private HashMap<Timestamp, Integer[]> frequency;
private int maxFreq;
private int firstWeekday = Calendar.SUNDAY;
public FrequencyChart(Context context)
{
super(context);
init();
}
public FrequencyChart(Context context, AttributeSet attrs)
{
super(context, attrs);
this.frequency = new HashMap<>();
init();
}
public void setColor(int color)
{
this.primaryColor = color;
initColors();
postInvalidate();
}
public void setFrequency(HashMap<Timestamp, Integer[]> frequency)
{
this.frequency = frequency;
maxFreq = getMaxFreq(frequency);
postInvalidate();
}
public void setFirstWeekday(int firstWeekday)
{
this.firstWeekday = firstWeekday;
postInvalidate();
}
private int getMaxFreq(HashMap<Timestamp, Integer[]> frequency)
{
int maxValue = 1;
for (Integer[] values : frequency.values())
for (Integer value : values)
maxValue = Math.max(value, maxValue);
return maxValue;
}
public void setIsBackgroundTransparent(boolean isBackgroundTransparent)
{
this.isBackgroundTransparent = isBackgroundTransparent;
initColors();
}
protected void initPaints()
{
pText = new Paint();
pText.setAntiAlias(true);
pGraph = new Paint();
pGraph.setTextAlign(Paint.Align.CENTER);
pGraph.setAntiAlias(true);
pGrid = new Paint();
pGrid.setAntiAlias(true);
}
@Override
protected void onDraw(Canvas canvas)
{
super.onDraw(canvas);
rect.set(0, 0, nColumns * columnWidth, columnHeight);
rect.offset(0, paddingTop);
drawGrid(canvas, rect);
pText.setTextAlign(Paint.Align.CENTER);
pText.setColor(textColor);
pGraph.setColor(primaryColor);
prevRect.setEmpty();
GregorianCalendar currentDate = DateUtils.getStartOfTodayCalendarWithOffset();
currentDate.set(Calendar.DAY_OF_MONTH, 1);
currentDate.add(Calendar.MONTH, -nColumns + 2 - getDataOffset());
for (int i = 0; i < nColumns - 1; i++)
{
rect.set(0, 0, columnWidth, columnHeight);
rect.offset(i * columnWidth, 0);
drawColumn(canvas, rect, currentDate);
currentDate.add(Calendar.MONTH, 1);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
setMeasuredDimension(width, height);
}
@Override
protected void onSizeChanged(int width,
int height,
int oldWidth,
int oldHeight)
{
if (height < 9) height = 200;
baseSize = height / 8;
setScrollerBucketSize(baseSize);
pText.setTextSize(baseSize * 0.4f);
pGraph.setTextSize(baseSize * 0.4f);
pGraph.setStrokeWidth(baseSize * 0.1f);
pGrid.setStrokeWidth(baseSize * 0.05f);
em = pText.getFontSpacing();
columnWidth = baseSize;
columnWidth = Math.max(columnWidth, getMaxMonthWidth() * 1.2f);
columnHeight = 8 * baseSize;
nColumns = (int) (width / columnWidth);
paddingTop = 0;
}
private void drawColumn(Canvas canvas, RectF rect, GregorianCalendar date)
{
Integer[] values = frequency.get(new Timestamp(date));
float rowHeight = rect.height() / 8.0f;
prevRect.set(rect);
Integer[] localeWeekdayList = DateUtils.getWeekdaySequence(firstWeekday);
for (int j = 0; j < localeWeekdayList.length; j++)
{
rect.set(0, 0, baseSize, baseSize);
rect.offset(prevRect.left, prevRect.top + baseSize * j);
int i = localeWeekdayList[j] % 7;
if (values != null) drawMarker(canvas, rect, values[i]);
rect.offset(0, rowHeight);
}
drawFooter(canvas, rect, date);
}
private void drawFooter(Canvas canvas, RectF rect, GregorianCalendar date)
{
Date time = date.getTime();
canvas.drawText(dfMonth.format(time), rect.centerX(),
rect.centerY() - 0.1f * em, pText);
if (date.get(Calendar.MONTH) == 1)
canvas.drawText(dfYear.format(time), rect.centerX(),
rect.centerY() + 0.9f * em, pText);
}
private void drawGrid(Canvas canvas, RectF rGrid)
{
int nRows = 7;
float rowHeight = rGrid.height() / (nRows + 1);
pText.setTextAlign(Paint.Align.LEFT);
pText.setColor(textColor);
pGrid.setColor(gridColor);
for (String day : DateUtils.getShortWeekdayNames(firstWeekday))
{
canvas.drawText(day, rGrid.right - columnWidth,
rGrid.top + rowHeight / 2 + 0.25f * em, pText);
pGrid.setStrokeWidth(1f);
canvas.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top,
pGrid);
rGrid.offset(0, rowHeight);
}
canvas.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top, pGrid);
}
private void drawMarker(Canvas canvas, RectF rect, Integer value)
{
float padding = rect.height() * 0.2f;
// maximal allowed mark radius
float maxRadius = (rect.height() - 2 * padding) / 2.0f;
// the real mark radius is scaled down by a factor depending on the maximal frequency
float scale = 1.0f/maxFreq * value;
float radius = maxRadius * scale;
int colorIndex = Math.min(colors.length - 1, Math.round((colors.length - 1) * scale));
pGraph.setColor(colors[colorIndex]);
canvas.drawCircle(rect.centerX(), rect.centerY(), radius, pGraph);
}
private float getMaxMonthWidth()
{
float maxMonthWidth = 0;
GregorianCalendar day = DateUtils.getStartOfTodayCalendarWithOffset();
for (int i = 0; i < 12; i++)
{
day.set(Calendar.MONTH, i);
float monthWidth = pText.measureText(dfMonth.format(day.getTime()));
maxMonthWidth = Math.max(maxMonthWidth, monthWidth);
}
return maxMonthWidth;
}
private void init()
{
initPaints();
initColors();
initDateFormats();
initRects();
}
private void initColors()
{
StyledResources res = new StyledResources(getContext());
textColor = res.getColor(R.attr.mediumContrastTextColor);
gridColor = res.getColor(R.attr.lowContrastTextColor);
colors = new int[4];
colors[0] = gridColor;
colors[3] = primaryColor;
colors[1] = ColorUtils.mixColors(colors[0], colors[3], 0.66f);
colors[2] = ColorUtils.mixColors(colors[0], colors[3], 0.33f);
}
private void initDateFormats()
{
if (isInEditMode())
{
dfMonth = new SimpleDateFormat("MMM", Locale.getDefault());
dfYear = new SimpleDateFormat("yyyy", Locale.getDefault());
}
else
{
dfMonth = DateExtensionsKt.toSimpleDataFormat("MMM");
dfYear = DateExtensionsKt.toSimpleDataFormat("yyyy");
}
}
private void initRects()
{
rect = new RectF();
prevRect = new RectF();
}
public void populateWithRandomData()
{
GregorianCalendar date = DateUtils.getStartOfTodayCalendar();
date.set(Calendar.DAY_OF_MONTH, 1);
Random rand = new Random();
frequency.clear();
for (int i = 0; i < 40; i++)
{
Integer values[] = new Integer[7];
for (int j = 0; j < 7; j++)
values[j] = rand.nextInt(5);
frequency.put(new Timestamp(date), values);
date.add(Calendar.MONTH, -1);
}
maxFreq = getMaxFreq(frequency);
}
}

@ -0,0 +1,294 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils.Companion.getShortWeekdayNames
import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayCalendar
import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayCalendarWithOffset
import org.isoron.uhabits.core.utils.DateUtils.Companion.getWeekdaySequence
import org.isoron.uhabits.utils.ColorUtils.mixColors
import org.isoron.uhabits.utils.StyledResources
import org.isoron.uhabits.utils.toSimpleDataFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.Locale
import java.util.Random
import kotlin.collections.HashMap
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
class FrequencyChart : ScrollableChart {
private var pGrid: Paint? = null
private var em = 0f
private var dfMonth: SimpleDateFormat? = null
private var dfYear: SimpleDateFormat? = null
private var pText: Paint? = null
private var pGraph: Paint? = null
private var rect: RectF? = null
private var prevRect: RectF? = null
private var baseSize = 0
private var internalPaddingTop = 0
private var columnWidth = 0f
private var columnHeight = 0
private var nColumns = 0
private var textColor = 0
private var gridColor = 0
private lateinit var colors: IntArray
private var primaryColor = 0
private var isBackgroundTransparent = false
private lateinit var frequency: HashMap<Timestamp, Array<Int>>
private var maxFreq = 0
private var firstWeekday = Calendar.SUNDAY
constructor(context: Context?) : super(context) {
init()
}
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
frequency = HashMap()
init()
}
fun setColor(color: Int) {
primaryColor = color
initColors()
postInvalidate()
}
fun setFrequency(frequency: java.util.HashMap<Timestamp, Array<Int>>) {
this.frequency = frequency
maxFreq = getMaxFreq(frequency)
postInvalidate()
}
fun setFirstWeekday(firstWeekday: Int) {
this.firstWeekday = firstWeekday
postInvalidate()
}
private fun getMaxFreq(frequency: HashMap<Timestamp, Array<Int>>): Int {
var maxValue = 1
for (values in frequency.values) for (value in values) maxValue = max(
value,
maxValue
)
return maxValue
}
fun setIsBackgroundTransparent(isBackgroundTransparent: Boolean) {
this.isBackgroundTransparent = isBackgroundTransparent
initColors()
}
private fun initPaints() {
pText = Paint()
pText!!.isAntiAlias = true
pGraph = Paint()
pGraph!!.textAlign = Paint.Align.CENTER
pGraph!!.isAntiAlias = true
pGrid = Paint()
pGrid!!.isAntiAlias = true
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
rect!![0f, 0f, nColumns * columnWidth] = columnHeight.toFloat()
rect!!.offset(0f, internalPaddingTop.toFloat())
drawGrid(canvas, rect)
pText!!.textAlign = Paint.Align.CENTER
pText!!.color = textColor
pGraph!!.color = primaryColor
prevRect!!.setEmpty()
val currentDate: GregorianCalendar =
getStartOfTodayCalendarWithOffset()
currentDate[Calendar.DAY_OF_MONTH] = 1
currentDate.add(Calendar.MONTH, -nColumns + 2 - dataOffset)
for (i in 0 until nColumns - 1) {
rect!![0f, 0f, columnWidth] = columnHeight.toFloat()
rect!!.offset(i * columnWidth, 0f)
drawColumn(canvas, rect, currentDate)
currentDate.add(Calendar.MONTH, 1)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(width, height)
}
override fun onSizeChanged(
width: Int,
height: Int,
oldWidth: Int,
oldHeight: Int
) {
var height = height
if (height < 9) height = 200
baseSize = height / 8
setScrollerBucketSize(baseSize)
pText!!.textSize = baseSize * 0.4f
pGraph!!.textSize = baseSize * 0.4f
pGraph!!.strokeWidth = baseSize * 0.1f
pGrid!!.strokeWidth = baseSize * 0.05f
em = pText!!.fontSpacing
columnWidth = baseSize.toFloat()
columnWidth = max(columnWidth, maxMonthWidth * 1.2f)
columnHeight = 8 * baseSize
nColumns = (width / columnWidth).toInt()
internalPaddingTop = 0
}
private fun drawColumn(canvas: Canvas, rect: RectF?, date: GregorianCalendar) {
val values = frequency[Timestamp(date)]
val rowHeight = rect!!.height() / 8.0f
prevRect!!.set(rect)
val localeWeekdayList: Array<Int> = getWeekdaySequence(firstWeekday)
for (j in localeWeekdayList.indices) {
rect[0f, 0f, baseSize.toFloat()] = baseSize.toFloat()
rect.offset(prevRect!!.left, prevRect!!.top + baseSize * j)
val i = localeWeekdayList[j] % 7
if (values != null) drawMarker(canvas, rect, values[i])
rect.offset(0f, rowHeight)
}
drawFooter(canvas, rect, date)
}
private fun drawFooter(canvas: Canvas, rect: RectF?, date: GregorianCalendar) {
val time = date.time
canvas.drawText(
dfMonth!!.format(time),
rect!!.centerX(),
rect.centerY() - 0.1f * em,
pText!!
)
if (date[Calendar.MONTH] == 1) canvas.drawText(
dfYear!!.format(time),
rect.centerX(),
rect.centerY() + 0.9f * em,
pText!!
)
}
private fun drawGrid(canvas: Canvas, rGrid: RectF?) {
val nRows = 7
val rowHeight = rGrid!!.height() / (nRows + 1)
pText!!.textAlign = Paint.Align.LEFT
pText!!.color = textColor
pGrid!!.color = gridColor
for (day in getShortWeekdayNames(firstWeekday)) {
canvas.drawText(
day,
rGrid.right - columnWidth,
rGrid.top + rowHeight / 2 + 0.25f * em,
pText!!
)
pGrid!!.strokeWidth = 1f
canvas.drawLine(
rGrid.left,
rGrid.top,
rGrid.right,
rGrid.top,
pGrid!!
)
rGrid.offset(0f, rowHeight)
}
canvas.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top, pGrid!!)
}
private fun drawMarker(canvas: Canvas, rect: RectF?, value: Int?) {
val padding = rect!!.height() * 0.2f
// maximal allowed mark radius
val maxRadius = (rect.height() - 2 * padding) / 2.0f
// the real mark radius is scaled down by a factor depending on the maximal frequency
val scale = 1.0f / maxFreq * value!!
val radius = maxRadius * scale
val colorIndex = min((colors.size - 1), ((colors.size - 1) * scale).roundToInt())
pGraph!!.color = colors[colorIndex]
canvas.drawCircle(rect.centerX(), rect.centerY(), radius, pGraph!!)
}
private val maxMonthWidth: Float
get() {
var maxMonthWidth = 0f
val day: GregorianCalendar =
getStartOfTodayCalendarWithOffset()
for (i in 0..11) {
day[Calendar.MONTH] = i
val monthWidth = pText!!.measureText(dfMonth!!.format(day.time))
maxMonthWidth = max(maxMonthWidth, monthWidth)
}
return maxMonthWidth
}
private fun init() {
initPaints()
initColors()
initDateFormats()
initRects()
}
private fun initColors() {
val res = StyledResources(context)
textColor = res.getColor(R.attr.mediumContrastTextColor)
gridColor = res.getColor(R.attr.lowContrastTextColor)
colors = IntArray(4)
colors[0] = gridColor
colors[3] = primaryColor
colors[1] = mixColors(colors[0], colors[3], 0.66f)
colors[2] = mixColors(colors[0], colors[3], 0.33f)
}
private fun initDateFormats() {
if (isInEditMode) {
dfMonth = SimpleDateFormat("MMM", Locale.getDefault())
dfYear = SimpleDateFormat("yyyy", Locale.getDefault())
} else {
dfMonth = "MMM".toSimpleDataFormat()
dfYear = "yyyy".toSimpleDataFormat()
}
}
private fun initRects() {
rect = RectF()
prevRect = RectF()
}
fun populateWithRandomData() {
val date: GregorianCalendar = getStartOfTodayCalendar()
date[Calendar.DAY_OF_MONTH] = 1
val rand = Random()
frequency.clear()
for (i in 0..39) {
val values = IntArray(7) { rand.nextInt(5) }.toTypedArray()
frequency[Timestamp(date)] = values
date.add(Calendar.MONTH, -1)
}
maxFreq = getMaxFreq(frequency)
}
}

@ -16,10 +16,11 @@
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
package org.isoron.uhabits.core.ui.callbacks;
import org.isoron.uhabits.core.models.Habit
public interface OnConfirmedCallback
{
void onConfirmed();
interface HabitChart {
fun setHabit(habit: Habit?)
fun refreshData()
}

@ -1,272 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import android.content.*;
import android.graphics.*;
import android.text.*;
import android.util.*;
import android.view.*;
import androidx.annotation.Nullable;
import org.isoron.uhabits.*;
import org.isoron.uhabits.utils.*;
import static org.isoron.uhabits.utils.AttributeSetUtils.*;
import static org.isoron.uhabits.utils.InterfaceUtils.*;
public class RingView extends View
{
public static final PorterDuffXfermode XFERMODE_CLEAR =
new PorterDuffXfermode(PorterDuff.Mode.CLEAR);
private int color;
private float precision;
private float percentage;
private int diameter;
private float thickness;
private RectF rect;
private TextPaint pRing;
private Integer backgroundColor;
private Integer inactiveColor;
private float em;
private String text;
private float textSize;
private boolean enableFontAwesome;
@Nullable
private Bitmap drawingCache;
private Canvas cacheCanvas;
private boolean isTransparencyEnabled;
public RingView(Context context)
{
super(context);
percentage = 0.0f;
precision = 0.01f;
color = PaletteUtils.getAndroidTestColor(0);
thickness = dpToPixels(getContext(), 2);
text = "";
textSize = getDimension(context, R.dimen.smallTextSize);
init();
}
public RingView(Context ctx, AttributeSet attrs)
{
super(ctx, attrs);
percentage = getFloatAttribute(ctx, attrs, "percentage", 0);
precision = getFloatAttribute(ctx, attrs, "precision", 0.01f);
color = getColorAttribute(ctx, attrs, "color", 0);
backgroundColor = getColorAttribute(ctx, attrs, "backgroundColor", null);
inactiveColor = getColorAttribute(ctx, attrs, "inactiveColor", null);
thickness = getFloatAttribute(ctx, attrs, "thickness", 0);
thickness = dpToPixels(ctx, thickness);
float defaultTextSize = getDimension(ctx, R.dimen.smallTextSize);
textSize = getFloatAttribute(ctx, attrs, "textSize", defaultTextSize);
textSize = spToPixels(ctx, textSize);
text = getAttribute(ctx, attrs, "text", "");
enableFontAwesome =
getBooleanAttribute(ctx, attrs, "enableFontAwesome", false);
init();
}
@Override
public void setBackgroundColor(int backgroundColor)
{
this.backgroundColor = backgroundColor;
invalidate();
}
public void setColor(int color)
{
this.color = color;
invalidate();
}
public int getColor()
{
return color;
}
public void setIsTransparencyEnabled(boolean isTransparencyEnabled)
{
this.isTransparencyEnabled = isTransparencyEnabled;
}
public void setPercentage(float percentage)
{
this.percentage = percentage;
invalidate();
}
public void setPrecision(float precision)
{
this.precision = precision;
invalidate();
}
public void setText(String text)
{
this.text = text;
invalidate();
}
public void setTextSize(float textSize)
{
this.textSize = textSize;
}
public void setThickness(float thickness)
{
this.thickness = thickness;
invalidate();
}
@Override
protected void onDraw(Canvas canvas)
{
super.onDraw(canvas);
Canvas activeCanvas;
if (isTransparencyEnabled)
{
if (drawingCache == null) reallocateCache();
activeCanvas = cacheCanvas;
drawingCache.eraseColor(Color.TRANSPARENT);
}
else
{
activeCanvas = canvas;
}
pRing.setColor(color);
rect.set(0, 0, diameter, diameter);
float angle = 360 * Math.round(percentage / precision) * precision;
activeCanvas.drawArc(rect, -90, angle, true, pRing);
pRing.setColor(inactiveColor);
activeCanvas.drawArc(rect, angle - 90, 360 - angle, true, pRing);
if (thickness > 0)
{
if (isTransparencyEnabled) pRing.setXfermode(XFERMODE_CLEAR);
else pRing.setColor(backgroundColor);
rect.inset(thickness, thickness);
activeCanvas.drawArc(rect, 0, 360, true, pRing);
pRing.setXfermode(null);
pRing.setColor(color);
pRing.setTextSize(textSize);
if (enableFontAwesome)
pRing.setTypeface(getFontAwesome(getContext()));
activeCanvas.drawText(text, rect.centerX(),
rect.centerY() + 0.4f * em, pRing);
}
if (activeCanvas != canvas) canvas.drawBitmap(drawingCache, 0, 0, null);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
diameter = Math.min(height, width);
pRing.setTextSize(textSize);
em = pRing.measureText("M");
setMeasuredDimension(diameter, diameter);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh)
{
super.onSizeChanged(w, h, oldw, oldh);
if (isTransparencyEnabled) reallocateCache();
}
private void init()
{
pRing = new TextPaint();
pRing.setAntiAlias(true);
pRing.setColor(color);
pRing.setTextAlign(Paint.Align.CENTER);
StyledResources res = new StyledResources(getContext());
if (backgroundColor == null)
backgroundColor = res.getColor(R.attr.cardBgColor);
if (inactiveColor == null)
inactiveColor = res.getColor(R.attr.highContrastTextColor);
inactiveColor = ColorUtils.setAlpha(inactiveColor, 0.1f);
rect = new RectF();
}
private void reallocateCache()
{
if (drawingCache != null) drawingCache.recycle();
drawingCache =
Bitmap.createBitmap(diameter, diameter, Bitmap.Config.ARGB_8888);
cacheCanvas = new Canvas(drawingCache);
}
public float getPercentage()
{
return percentage;
}
public float getPrecision()
{
return precision;
}
}

@ -0,0 +1,213 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import org.isoron.uhabits.R
import org.isoron.uhabits.utils.AttributeSetUtils.getAttribute
import org.isoron.uhabits.utils.AttributeSetUtils.getBooleanAttribute
import org.isoron.uhabits.utils.AttributeSetUtils.getColorAttribute
import org.isoron.uhabits.utils.AttributeSetUtils.getFloatAttribute
import org.isoron.uhabits.utils.ColorUtils.setAlpha
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
import org.isoron.uhabits.utils.InterfaceUtils.getDimension
import org.isoron.uhabits.utils.InterfaceUtils.getFontAwesome
import org.isoron.uhabits.utils.InterfaceUtils.spToPixels
import org.isoron.uhabits.utils.PaletteUtils.getAndroidTestColor
import org.isoron.uhabits.utils.StyledResources
import kotlin.math.min
import kotlin.math.roundToLong
class RingView : View {
private var color: Int
private var precision: Float
private var percentage: Float
private var diameter = 0
private var thickness: Float
private var rect: RectF? = null
private var pRing: TextPaint? = null
private var backgroundColor: Int? = null
private var inactiveColor: Int? = null
private var em = 0f
private var text: String?
private var textSize: Float
private var enableFontAwesome = false
private var internalDrawingCache: Bitmap? = null
private var cacheCanvas: Canvas? = null
private var isTransparencyEnabled = false
constructor(context: Context?) : super(context) {
percentage = 0.0f
precision = 0.01f
color = getAndroidTestColor(0)
thickness = dpToPixels(getContext(), 2f)
text = ""
textSize = getDimension(context!!, R.dimen.smallTextSize)
init()
}
constructor(ctx: Context?, attrs: AttributeSet?) : super(ctx, attrs) {
percentage = getFloatAttribute(ctx!!, attrs!!, "percentage", 0f)
precision = getFloatAttribute(ctx, attrs, "precision", 0.01f)
color = getColorAttribute(ctx, attrs, "color", 0)!!
backgroundColor = getColorAttribute(ctx, attrs, "backgroundColor", null)
inactiveColor = getColorAttribute(ctx, attrs, "inactiveColor", null)
thickness = getFloatAttribute(ctx, attrs, "thickness", 0f)
thickness = dpToPixels(ctx, thickness)
val defaultTextSize = getDimension(ctx, R.dimen.smallTextSize)
textSize = getFloatAttribute(ctx, attrs, "textSize", defaultTextSize)
textSize = spToPixels(ctx, textSize)
text = getAttribute(ctx, attrs, "text", "")
enableFontAwesome = getBooleanAttribute(ctx, attrs, "enableFontAwesome", false)
init()
}
override fun setBackgroundColor(backgroundColor: Int) {
this.backgroundColor = backgroundColor
invalidate()
}
fun setColor(color: Int) {
this.color = color
invalidate()
}
fun getColor(): Int {
return color
}
fun setIsTransparencyEnabled(isTransparencyEnabled: Boolean) {
this.isTransparencyEnabled = isTransparencyEnabled
}
fun setPercentage(percentage: Float) {
this.percentage = percentage
invalidate()
}
fun setPrecision(precision: Float) {
this.precision = precision
invalidate()
}
fun setText(text: String?) {
this.text = text
invalidate()
}
fun setTextSize(textSize: Float) {
this.textSize = textSize
}
fun setThickness(thickness: Float) {
this.thickness = thickness
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val activeCanvas: Canvas?
if (isTransparencyEnabled) {
if (internalDrawingCache == null) reallocateCache()
activeCanvas = cacheCanvas
internalDrawingCache!!.eraseColor(Color.TRANSPARENT)
} else {
activeCanvas = canvas
}
pRing!!.color = color
rect!![0f, 0f, diameter.toFloat()] = diameter.toFloat()
val angle = 360 * (percentage / precision).roundToLong() * precision
activeCanvas!!.drawArc(rect!!, -90f, angle, true, pRing!!)
pRing!!.color = inactiveColor!!
activeCanvas.drawArc(rect!!, angle - 90, 360 - angle, true, pRing!!)
if (thickness > 0) {
if (isTransparencyEnabled) pRing!!.xfermode = XFERMODE_CLEAR else pRing!!.color =
backgroundColor!!
rect!!.inset(thickness, thickness)
activeCanvas.drawArc(rect!!, 0f, 360f, true, pRing!!)
pRing!!.xfermode = null
pRing!!.color = color
pRing!!.textSize = textSize
if (enableFontAwesome) pRing!!.typeface = getFontAwesome(context)
activeCanvas.drawText(
text!!,
rect!!.centerX(),
rect!!.centerY() + 0.4f * em,
pRing!!
)
}
if (activeCanvas !== canvas) canvas.drawBitmap(internalDrawingCache!!, 0f, 0f, null)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
diameter = min(height, width)
pRing!!.textSize = textSize
em = pRing!!.measureText("M")
setMeasuredDimension(diameter, diameter)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (isTransparencyEnabled) reallocateCache()
}
private fun init() {
pRing = TextPaint()
pRing!!.isAntiAlias = true
pRing!!.color = color
pRing!!.textAlign = Paint.Align.CENTER
val res = StyledResources(context)
if (backgroundColor == null) backgroundColor = res.getColor(R.attr.cardBgColor)
if (inactiveColor == null) inactiveColor = res.getColor(R.attr.highContrastTextColor)
inactiveColor = setAlpha(inactiveColor!!, 0.1f)
rect = RectF()
}
private fun reallocateCache() {
if (internalDrawingCache != null) internalDrawingCache!!.recycle()
val newDrawingCache = Bitmap.createBitmap(diameter, diameter, Bitmap.Config.ARGB_8888)
internalDrawingCache = newDrawingCache
cacheCanvas = Canvas(newDrawingCache)
}
fun getPercentage(): Float {
return percentage
}
fun getPrecision(): Float {
return precision
}
companion object {
val XFERMODE_CLEAR = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
}
}

@ -1,452 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import android.content.*;
import android.graphics.*;
import android.util.*;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.utils.*;
import java.text.*;
import java.util.*;
import static org.isoron.uhabits.utils.InterfaceUtils.*;
public class ScoreChart extends ScrollableChart
{
private static final PorterDuffXfermode XFERMODE_CLEAR =
new PorterDuffXfermode(PorterDuff.Mode.CLEAR);
private static final PorterDuffXfermode XFERMODE_SRC =
new PorterDuffXfermode(PorterDuff.Mode.SRC);
private Paint pGrid;
private float em;
private SimpleDateFormat dfMonth;
private SimpleDateFormat dfDay;
private SimpleDateFormat dfYear;
private Paint pText, pGraph;
private RectF rect, prevRect;
private int baseSize;
private int paddingTop;
private float columnWidth;
private int columnHeight;
private int nColumns;
private int textColor;
private int gridColor;
@Nullable
private List<Score> scores;
private int primaryColor;
@Deprecated
private int bucketSize = 7;
private int backgroundColor;
private Bitmap drawingCache;
private Canvas cacheCanvas;
private boolean isTransparencyEnabled;
private int skipYear = 0;
private String previousYearText;
private String previousMonthText;
public ScoreChart(Context context)
{
super(context);
init();
}
public ScoreChart(Context context, AttributeSet attrs)
{
super(context, attrs);
init();
}
public void populateWithRandomData()
{
Random random = new Random();
scores = new LinkedList<>();
double previous = 0.5f;
Timestamp timestamp = DateUtils.getToday();
for (int i = 1; i < 100; i++)
{
double step = 0.1f;
double current = previous + random.nextDouble() * step * 2 - step;
current = Math.max(0, Math.min(1.0f, current));
scores.add(new Score(timestamp.minus(i), current));
previous = current;
}
}
public void setBucketSize(int bucketSize)
{
this.bucketSize = bucketSize;
postInvalidate();
}
public void setIsTransparencyEnabled(boolean enabled)
{
this.isTransparencyEnabled = enabled;
postInvalidate();
}
public void setColor(int primaryColor)
{
this.primaryColor = primaryColor;
postInvalidate();
}
public void setScores(@NonNull List<Score> scores)
{
this.scores = scores;
postInvalidate();
}
@Override
protected void onDraw(Canvas canvas)
{
super.onDraw(canvas);
Canvas activeCanvas;
if (isTransparencyEnabled)
{
if (drawingCache == null) initCache(getWidth(), getHeight());
activeCanvas = cacheCanvas;
drawingCache.eraseColor(Color.TRANSPARENT);
}
else
{
activeCanvas = canvas;
}
if (scores == null) return;
rect.set(0, 0, nColumns * columnWidth, columnHeight);
rect.offset(0, paddingTop);
drawGrid(activeCanvas, rect);
pText.setColor(textColor);
pGraph.setColor(primaryColor);
prevRect.setEmpty();
previousMonthText = "";
previousYearText = "";
skipYear = 0;
for (int k = 0; k < nColumns; k++)
{
int offset = nColumns - k - 1 + getDataOffset();
if (offset >= scores.size()) continue;
double score = scores.get(offset).getValue();
Timestamp timestamp = scores.get(offset).getTimestamp();
int height = (int) (columnHeight * score);
rect.set(0, 0, baseSize, baseSize);
rect.offset(k * columnWidth + (columnWidth - baseSize) / 2,
paddingTop + columnHeight - height - baseSize / 2);
if (!prevRect.isEmpty())
{
drawLine(activeCanvas, prevRect, rect);
drawMarker(activeCanvas, prevRect);
}
if (k == nColumns - 1) drawMarker(activeCanvas, rect);
prevRect.set(rect);
rect.set(0, 0, columnWidth, columnHeight);
rect.offset(k * columnWidth, paddingTop);
drawFooter(activeCanvas, rect, timestamp);
}
if (activeCanvas != canvas) canvas.drawBitmap(drawingCache, 0, 0, null);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
setMeasuredDimension(width, height);
}
@Override
protected void onSizeChanged(int width,
int height,
int oldWidth,
int oldHeight)
{
if (height < 9) height = 200;
float maxTextSize = getDimension(getContext(), R.dimen.tinyTextSize);
float textSize = height * 0.06f;
pText.setTextSize(Math.min(textSize, maxTextSize));
em = pText.getFontSpacing();
int footerHeight = (int) (3 * em);
paddingTop = (int) (em);
baseSize = (height - footerHeight - paddingTop) / 8;
columnWidth = baseSize;
columnWidth = Math.max(columnWidth, getMaxDayWidth() * 1.5f);
columnWidth = Math.max(columnWidth, getMaxMonthWidth() * 1.2f);
nColumns = (int) (width / columnWidth);
columnWidth = (float) width / nColumns;
setScrollerBucketSize((int) columnWidth);
columnHeight = 8 * baseSize;
float minStrokeWidth = dpToPixels(getContext(), 1);
pGraph.setTextSize(baseSize * 0.5f);
pGraph.setStrokeWidth(baseSize * 0.1f);
pGrid.setStrokeWidth(Math.min(minStrokeWidth, baseSize * 0.05f));
if (isTransparencyEnabled) initCache(width, height);
}
private void drawFooter(Canvas canvas, RectF rect, Timestamp currentDate)
{
String yearText = dfYear.format(currentDate.toJavaDate());
String monthText = dfMonth.format(currentDate.toJavaDate());
String dayText = dfDay.format(currentDate.toJavaDate());
GregorianCalendar calendar = currentDate.toCalendar();
String text;
int year = calendar.get(Calendar.YEAR);
boolean shouldPrintYear = true;
if (yearText.equals(previousYearText)) shouldPrintYear = false;
if (bucketSize >= 365 && (year % 2) != 0) shouldPrintYear = false;
if (skipYear > 0)
{
skipYear--;
shouldPrintYear = false;
}
if (shouldPrintYear)
{
previousYearText = yearText;
previousMonthText = "";
pText.setTextAlign(Paint.Align.CENTER);
canvas.drawText(yearText, rect.centerX(), rect.bottom + em * 2.2f,
pText);
skipYear = 1;
}
if (bucketSize < 365)
{
if (!monthText.equals(previousMonthText))
{
previousMonthText = monthText;
text = monthText;
}
else
{
text = dayText;
}
pText.setTextAlign(Paint.Align.CENTER);
canvas.drawText(text, rect.centerX(), rect.bottom + em * 1.2f,
pText);
}
}
private void drawGrid(Canvas canvas, RectF rGrid)
{
int nRows = 5;
float rowHeight = rGrid.height() / nRows;
pText.setTextAlign(Paint.Align.LEFT);
pText.setColor(textColor);
pGrid.setColor(gridColor);
for (int i = 0; i < nRows; i++)
{
canvas.drawText(String.format("%d%%", (100 - i * 100 / nRows)),
rGrid.left + 0.5f * em, rGrid.top + 1f * em, pText);
canvas.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top,
pGrid);
rGrid.offset(0, rowHeight);
}
canvas.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top, pGrid);
}
private void drawLine(Canvas canvas, RectF rectFrom, RectF rectTo)
{
pGraph.setColor(primaryColor);
canvas.drawLine(rectFrom.centerX(), rectFrom.centerY(),
rectTo.centerX(), rectTo.centerY(), pGraph);
}
private void drawMarker(Canvas canvas, RectF rect)
{
rect.inset(baseSize * 0.225f, baseSize * 0.225f);
setModeOrColor(pGraph, XFERMODE_CLEAR, backgroundColor);
canvas.drawOval(rect, pGraph);
rect.inset(baseSize * 0.1f, baseSize * 0.1f);
setModeOrColor(pGraph, XFERMODE_SRC, primaryColor);
canvas.drawOval(rect, pGraph);
// rect.inset(baseSize * 0.1f, baseSize * 0.1f);
// setModeOrColor(pGraph, XFERMODE_CLEAR, backgroundColor);
// canvas.drawOval(rect, pGraph);
if (isTransparencyEnabled) pGraph.setXfermode(XFERMODE_SRC);
}
private float getMaxDayWidth()
{
float maxDayWidth = 0;
GregorianCalendar day = DateUtils.getStartOfTodayCalendarWithOffset();
for (int i = 0; i < 28; i++)
{
day.set(Calendar.DAY_OF_MONTH, i);
float monthWidth = pText.measureText(dfMonth.format(day.getTime()));
maxDayWidth = Math.max(maxDayWidth, monthWidth);
}
return maxDayWidth;
}
private float getMaxMonthWidth()
{
float maxMonthWidth = 0;
GregorianCalendar day = DateUtils.getStartOfTodayCalendarWithOffset();
for (int i = 0; i < 12; i++)
{
day.set(Calendar.MONTH, i);
float monthWidth = pText.measureText(dfMonth.format(day.getTime()));
maxMonthWidth = Math.max(maxMonthWidth, monthWidth);
}
return maxMonthWidth;
}
private void init()
{
initPaints();
initColors();
initDateFormats();
initRects();
}
private void initCache(int width, int height)
{
if (drawingCache != null) drawingCache.recycle();
drawingCache =
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
cacheCanvas = new Canvas(drawingCache);
}
private void initColors()
{
StyledResources res = new StyledResources(getContext());
primaryColor = Color.BLACK;
textColor = res.getColor(R.attr.mediumContrastTextColor);
gridColor = res.getColor(R.attr.lowContrastTextColor);
backgroundColor = res.getColor(R.attr.cardBgColor);
}
private void initDateFormats()
{
if (isInEditMode())
{
dfMonth = new SimpleDateFormat("MMM", Locale.getDefault());
dfYear = new SimpleDateFormat("yyyy", Locale.getDefault());
dfDay = new SimpleDateFormat("d", Locale.getDefault());
}
else
{
dfMonth = DateExtensionsKt.toSimpleDataFormat("MMM");
dfYear = DateExtensionsKt.toSimpleDataFormat("yyyy");
dfDay = DateExtensionsKt.toSimpleDataFormat("d");
}
}
private void initPaints()
{
pText = new Paint();
pText.setAntiAlias(true);
pGraph = new Paint();
pGraph.setTextAlign(Paint.Align.CENTER);
pGraph.setAntiAlias(true);
pGrid = new Paint();
pGrid.setAntiAlias(true);
}
private void initRects()
{
rect = new RectF();
prevRect = new RectF();
}
private void setModeOrColor(Paint p, PorterDuffXfermode mode, int color)
{
if (isTransparencyEnabled) p.setXfermode(mode);
else p.setColor(color);
}
}

@ -0,0 +1,377 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.util.AttributeSet
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Score
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayCalendarWithOffset
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
import org.isoron.uhabits.utils.InterfaceUtils.getDimension
import org.isoron.uhabits.utils.StyledResources
import org.isoron.uhabits.utils.toSimpleDataFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.LinkedList
import java.util.Locale
import java.util.Random
import kotlin.math.max
import kotlin.math.min
class ScoreChart : ScrollableChart {
private var pGrid: Paint? = null
private var em = 0f
private var dfMonth: SimpleDateFormat? = null
private var dfDay: SimpleDateFormat? = null
private var dfYear: SimpleDateFormat? = null
private var pText: Paint? = null
private var pGraph: Paint? = null
private var rect: RectF? = null
private var prevRect: RectF? = null
private var baseSize = 0
private var internalPaddingTop = 0
private var columnWidth = 0f
private var columnHeight = 0
private var nColumns = 0
private var textColor = 0
private var gridColor = 0
private var scores: List<Score>? = null
private var primaryColor = 0
@Deprecated("")
private var bucketSize = 7
private var internalBackgroundColor = 0
private var internalDrawingCache: Bitmap? = null
private var cacheCanvas: Canvas? = null
private var isTransparencyEnabled = false
private var skipYear = 0
private var previousYearText: String? = null
private var previousMonthText: String? = null
constructor(context: Context?) : super(context) {
init()
}
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
init()
}
fun populateWithRandomData() {
val random = Random()
val newScores = LinkedList<Score>()
var previous = 0.5
val timestamp: Timestamp = getToday()
for (i in 1..99) {
val step = 0.1
var current = previous + random.nextDouble() * step * 2 - step
current = max(0.0, min(1.0, current))
newScores.add(Score(timestamp.minus(i), current))
previous = current
}
scores = newScores
}
fun setBucketSize(bucketSize: Int) {
this.bucketSize = bucketSize
postInvalidate()
}
fun setIsTransparencyEnabled(enabled: Boolean) {
isTransparencyEnabled = enabled
postInvalidate()
}
fun setColor(primaryColor: Int) {
this.primaryColor = primaryColor
postInvalidate()
}
fun setScores(scores: List<Score>) {
this.scores = scores
postInvalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val activeCanvas: Canvas?
if (isTransparencyEnabled) {
if (internalDrawingCache == null) initCache(width, height)
activeCanvas = cacheCanvas
internalDrawingCache!!.eraseColor(Color.TRANSPARENT)
} else {
activeCanvas = canvas
}
if (scores == null) return
rect!![0f, 0f, nColumns * columnWidth] = columnHeight.toFloat()
rect!!.offset(0f, internalPaddingTop.toFloat())
drawGrid(activeCanvas, rect)
pText!!.color = textColor
pGraph!!.color = primaryColor
prevRect!!.setEmpty()
previousMonthText = ""
previousYearText = ""
skipYear = 0
for (k in 0 until nColumns) {
val offset = nColumns - k - 1 + dataOffset
if (offset >= scores!!.size) continue
val score = scores!![offset].value
val timestamp = scores!![offset].timestamp
val height = (columnHeight * score).toInt()
rect!![0f, 0f, baseSize.toFloat()] = baseSize.toFloat()
rect!!.offset(
k * columnWidth + (columnWidth - baseSize) / 2,
(
internalPaddingTop + columnHeight - height - baseSize / 2
).toFloat()
)
if (!prevRect!!.isEmpty) {
drawLine(activeCanvas, prevRect, rect)
drawMarker(activeCanvas, prevRect)
}
if (k == nColumns - 1) drawMarker(activeCanvas, rect)
prevRect!!.set(rect)
rect!![0f, 0f, columnWidth] = columnHeight.toFloat()
rect!!.offset(k * columnWidth, internalPaddingTop.toFloat())
drawFooter(activeCanvas, rect, timestamp)
}
if (activeCanvas !== canvas) canvas.drawBitmap(internalDrawingCache!!, 0f, 0f, null)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(width, height)
}
override fun onSizeChanged(
width: Int,
height: Int,
oldWidth: Int,
oldHeight: Int
) {
var height = height
if (height < 9) height = 200
val maxTextSize = getDimension(context, R.dimen.tinyTextSize)
val textSize = height * 0.06f
pText!!.textSize = min(textSize, maxTextSize)
em = pText!!.fontSpacing
val footerHeight = (3 * em).toInt()
internalPaddingTop = em.toInt()
baseSize = (height - footerHeight - internalPaddingTop) / 8
columnWidth = baseSize.toFloat()
columnWidth = max(columnWidth, maxDayWidth * 1.5f)
columnWidth = max(columnWidth, maxMonthWidth * 1.2f)
nColumns = (width / columnWidth).toInt()
columnWidth = width.toFloat() / nColumns
setScrollerBucketSize(columnWidth.toInt())
columnHeight = 8 * baseSize
val minStrokeWidth = dpToPixels(context, 1f)
pGraph!!.textSize = baseSize * 0.5f
pGraph!!.strokeWidth = baseSize * 0.1f
pGrid!!.strokeWidth = min(minStrokeWidth, baseSize * 0.05f)
if (isTransparencyEnabled) initCache(width, height)
}
private fun drawFooter(canvas: Canvas?, rect: RectF?, currentDate: Timestamp) {
val yearText = dfYear!!.format(currentDate.toJavaDate())
val monthText = dfMonth!!.format(currentDate.toJavaDate())
val dayText = dfDay!!.format(currentDate.toJavaDate())
val calendar = currentDate.toCalendar()
val text: String
val year = calendar[Calendar.YEAR]
var shouldPrintYear = true
if (yearText == previousYearText) shouldPrintYear = false
if (bucketSize >= 365 && year % 2 != 0) shouldPrintYear = false
if (skipYear > 0) {
skipYear--
shouldPrintYear = false
}
if (shouldPrintYear) {
previousYearText = yearText
previousMonthText = ""
pText!!.textAlign = Paint.Align.CENTER
canvas!!.drawText(
yearText,
rect!!.centerX(),
rect.bottom + em * 2.2f,
pText!!
)
skipYear = 1
}
if (bucketSize < 365) {
if (monthText != previousMonthText) {
previousMonthText = monthText
text = monthText
} else {
text = dayText
}
pText!!.textAlign = Paint.Align.CENTER
canvas!!.drawText(
text,
rect!!.centerX(),
rect.bottom + em * 1.2f,
pText!!
)
}
}
private fun drawGrid(canvas: Canvas?, rGrid: RectF?) {
val nRows = 5
val rowHeight = rGrid!!.height() / nRows
pText!!.textAlign = Paint.Align.LEFT
pText!!.color = textColor
pGrid!!.color = gridColor
for (i in 0 until nRows) {
canvas!!.drawText(
String.format("%d%%", 100 - i * 100 / nRows),
rGrid.left + 0.5f * em,
rGrid.top + 1f * em,
pText!!
)
canvas.drawLine(
rGrid.left,
rGrid.top,
rGrid.right,
rGrid.top,
pGrid!!
)
rGrid.offset(0f, rowHeight)
}
canvas!!.drawLine(rGrid.left, rGrid.top, rGrid.right, rGrid.top, pGrid!!)
}
private fun drawLine(canvas: Canvas?, rectFrom: RectF?, rectTo: RectF?) {
pGraph!!.color = primaryColor
canvas!!.drawLine(
rectFrom!!.centerX(),
rectFrom.centerY(),
rectTo!!.centerX(),
rectTo.centerY(),
pGraph!!
)
}
private fun drawMarker(canvas: Canvas?, rect: RectF?) {
rect!!.inset(baseSize * 0.225f, baseSize * 0.225f)
setModeOrColor(pGraph, XFERMODE_CLEAR, internalBackgroundColor)
canvas!!.drawOval(rect, pGraph!!)
rect.inset(baseSize * 0.1f, baseSize * 0.1f)
setModeOrColor(pGraph, XFERMODE_SRC, primaryColor)
canvas.drawOval(rect, pGraph!!)
// rect.inset(baseSize * 0.1f, baseSize * 0.1f);
// setModeOrColor(pGraph, XFERMODE_CLEAR, backgroundColor);
// canvas.drawOval(rect, pGraph);
if (isTransparencyEnabled) pGraph!!.xfermode = XFERMODE_SRC
}
private val maxDayWidth: Float
private get() {
var maxDayWidth = 0f
val day: GregorianCalendar =
getStartOfTodayCalendarWithOffset()
for (i in 0..27) {
day[Calendar.DAY_OF_MONTH] = i
val monthWidth = pText!!.measureText(dfMonth!!.format(day.time))
maxDayWidth = max(maxDayWidth, monthWidth)
}
return maxDayWidth
}
private val maxMonthWidth: Float
private get() {
var maxMonthWidth = 0f
val day: GregorianCalendar =
getStartOfTodayCalendarWithOffset()
for (i in 0..11) {
day[Calendar.MONTH] = i
val monthWidth = pText!!.measureText(dfMonth!!.format(day.time))
maxMonthWidth = max(maxMonthWidth, monthWidth)
}
return maxMonthWidth
}
private fun init() {
initPaints()
initColors()
initDateFormats()
initRects()
}
private fun initCache(width: Int, height: Int) {
if (internalDrawingCache != null) internalDrawingCache!!.recycle()
val newDrawingCache = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
internalDrawingCache = newDrawingCache
cacheCanvas = Canvas(newDrawingCache)
}
private fun initColors() {
val res = StyledResources(context)
primaryColor = Color.BLACK
textColor = res.getColor(R.attr.mediumContrastTextColor)
gridColor = res.getColor(R.attr.lowContrastTextColor)
internalBackgroundColor = res.getColor(R.attr.cardBgColor)
}
private fun initDateFormats() {
if (isInEditMode) {
dfMonth = SimpleDateFormat("MMM", Locale.getDefault())
dfYear = SimpleDateFormat("yyyy", Locale.getDefault())
dfDay = SimpleDateFormat("d", Locale.getDefault())
} else {
dfMonth = "MMM".toSimpleDataFormat()
dfYear = "yyyy".toSimpleDataFormat()
dfDay = "d".toSimpleDataFormat()
}
}
private fun initPaints() {
pText = Paint()
pText!!.isAntiAlias = true
pGraph = Paint()
pGraph!!.textAlign = Paint.Align.CENTER
pGraph!!.isAntiAlias = true
pGrid = Paint()
pGrid!!.isAntiAlias = true
}
private fun initRects() {
rect = RectF()
prevRect = RectF()
}
private fun setModeOrColor(p: Paint?, mode: PorterDuffXfermode, color: Int) {
if (isTransparencyEnabled) p!!.xfermode = mode else p!!.color = color
}
companion object {
private val XFERMODE_CLEAR = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
private val XFERMODE_SRC = PorterDuffXfermode(PorterDuff.Mode.SRC)
}
}

@ -1,245 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import android.animation.*;
import android.content.*;
import android.os.*;
import android.util.*;
import android.view.*;
import android.widget.*;
public abstract class ScrollableChart extends View
implements GestureDetector.OnGestureListener,
ValueAnimator.AnimatorUpdateListener
{
private int dataOffset;
private int scrollerBucketSize = 1;
private int direction = 1;
private GestureDetector detector;
private Scroller scroller;
private ValueAnimator scrollAnimator;
private ScrollController scrollController;
private int maxDataOffset = 10000;
public ScrollableChart(Context context)
{
super(context);
init(context);
}
public ScrollableChart(Context context, AttributeSet attrs)
{
super(context, attrs);
init(context);
}
public int getDataOffset()
{
return dataOffset;
}
@Override
public void onAnimationUpdate(ValueAnimator animation)
{
if (!scroller.isFinished())
{
scroller.computeScrollOffset();
updateDataOffset();
}
else
{
scrollAnimator.cancel();
}
}
@Override
public boolean onDown(MotionEvent e)
{
return true;
}
@Override
public boolean onFling(MotionEvent e1,
MotionEvent e2,
float velocityX,
float velocityY)
{
scroller.fling(scroller.getCurrX(), scroller.getCurrY(),
direction * ((int) velocityX) / 2, 0, 0, getMaxX(), 0, 0);
invalidate();
scrollAnimator.setDuration(scroller.getDuration());
scrollAnimator.start();
return false;
}
private int getMaxX()
{
return maxDataOffset * scrollerBucketSize;
}
@Override
public void onRestoreInstanceState(Parcelable state)
{
if(!(state instanceof BundleSavedState))
{
super.onRestoreInstanceState(state);
return;
}
BundleSavedState bss = (BundleSavedState) state;
int x = bss.bundle.getInt("x");
int y = bss.bundle.getInt("y");
direction = bss.bundle.getInt("direction");
dataOffset = bss.bundle.getInt("dataOffset");
maxDataOffset = bss.bundle.getInt("maxDataOffset");
scroller.startScroll(0, 0, x, y, 0);
scroller.computeScrollOffset();
super.onRestoreInstanceState(bss.getSuperState());
}
@Override
public Parcelable onSaveInstanceState()
{
Parcelable superState = super.onSaveInstanceState();
Bundle bundle = new Bundle();
bundle.putInt("x", scroller.getCurrX());
bundle.putInt("y", scroller.getCurrY());
bundle.putInt("dataOffset", dataOffset);
bundle.putInt("direction", direction);
bundle.putInt("maxDataOffset", maxDataOffset);
return new BundleSavedState(superState, bundle);
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy)
{
if (scrollerBucketSize == 0) return false;
if (Math.abs(dx) > Math.abs(dy))
{
ViewParent parent = getParent();
if (parent != null) parent.requestDisallowInterceptTouchEvent(true);
}
dx = - direction * dx;
dx = Math.min(dx, getMaxX() - scroller.getCurrX());
scroller.startScroll(scroller.getCurrX(), scroller.getCurrY(), (int) dx,
(int) dy, 0);
scroller.computeScrollOffset();
updateDataOffset();
return true;
}
@Override
public void onShowPress(MotionEvent e)
{
}
@Override
public boolean onSingleTapUp(MotionEvent e)
{
return false;
}
@Override
public boolean onTouchEvent(MotionEvent event)
{
return detector.onTouchEvent(event);
}
public void setScrollDirection(int direction)
{
if (direction != 1 && direction != -1)
throw new IllegalArgumentException();
this.direction = direction;
}
@Override
public void onLongPress(MotionEvent e)
{
}
public void setMaxDataOffset(int maxDataOffset)
{
this.maxDataOffset = maxDataOffset;
this.dataOffset = Math.min(dataOffset, maxDataOffset);
scrollController.onDataOffsetChanged(this.dataOffset);
postInvalidate();
}
public void setScrollController(ScrollController scrollController)
{
this.scrollController = scrollController;
}
public void setScrollerBucketSize(int scrollerBucketSize)
{
this.scrollerBucketSize = scrollerBucketSize;
}
private void init(Context context)
{
detector = new GestureDetector(context, this);
scroller = new Scroller(context, null, true);
scrollAnimator = ValueAnimator.ofFloat(0, 1);
scrollAnimator.addUpdateListener(this);
scrollController = new ScrollController() {};
}
public void reset()
{
scroller.setFinalX(0);
scroller.computeScrollOffset();
updateDataOffset();
}
private void updateDataOffset()
{
int newDataOffset = scroller.getCurrX() / scrollerBucketSize;
newDataOffset = Math.max(0, newDataOffset);
newDataOffset = Math.min(maxDataOffset, newDataOffset);
if (newDataOffset != dataOffset)
{
dataOffset = newDataOffset;
scrollController.onDataOffsetChanged(dataOffset);
postInvalidate();
}
}
public interface ScrollController
{
default void onDataOffsetChanged(int newDataOffset) {}
}
}

@ -0,0 +1,199 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import android.animation.ValueAnimator
import android.animation.ValueAnimator.AnimatorUpdateListener
import android.content.Context
import android.os.Bundle
import android.os.Parcelable
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.widget.Scroller
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
abstract class ScrollableChart : View, GestureDetector.OnGestureListener, AnimatorUpdateListener {
var dataOffset = 0
private set
private var scrollerBucketSize = 1
private var direction = 1
private lateinit var detector: GestureDetector
private lateinit var scroller: Scroller
private lateinit var scrollAnimator: ValueAnimator
private lateinit var scrollController: ScrollController
private var maxDataOffset = 10000
constructor(context: Context?) : super(context) {
init(context)
}
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
init(context)
}
override fun onAnimationUpdate(animation: ValueAnimator) {
if (!scroller.isFinished) {
scroller.computeScrollOffset()
updateDataOffset()
} else {
scrollAnimator.cancel()
}
}
override fun onDown(e: MotionEvent): Boolean {
return true
}
override fun onFling(
e1: MotionEvent,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
scroller.fling(
scroller.currX,
scroller.currY,
direction * velocityX.toInt() / 2,
0,
0,
maxX,
0,
0
)
invalidate()
scrollAnimator.duration = scroller.duration.toLong()
scrollAnimator.start()
return false
}
private val maxX: Int
get() = maxDataOffset * scrollerBucketSize
public override fun onRestoreInstanceState(state: Parcelable) {
if (state !is BundleSavedState) {
super.onRestoreInstanceState(state)
return
}
val x = state.bundle!!.getInt("x")
val y = state.bundle.getInt("y")
direction = state.bundle.getInt("direction")
dataOffset = state.bundle.getInt("dataOffset")
maxDataOffset = state.bundle.getInt("maxDataOffset")
scroller.startScroll(0, 0, x, y, 0)
scroller.computeScrollOffset()
super.onRestoreInstanceState(state.superState)
}
public override fun onSaveInstanceState(): Parcelable? {
val superState = super.onSaveInstanceState()
val bundle = Bundle().apply {
putInt("x", scroller.currX)
putInt("y", scroller.currY)
putInt("dataOffset", dataOffset)
putInt("direction", direction)
putInt("maxDataOffset", maxDataOffset)
}
return BundleSavedState(superState, bundle)
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, dx: Float, dy: Float): Boolean {
var dx = dx
if (scrollerBucketSize == 0) return false
if (abs(dx) > abs(dy)) {
val parent = parent
parent?.requestDisallowInterceptTouchEvent(true)
}
dx *= -direction
dx = min(dx, (maxX - scroller.currX).toFloat())
scroller.startScroll(
scroller.currX,
scroller.currY,
dx.toInt(),
dy.toInt(),
0
)
scroller.computeScrollOffset()
updateDataOffset()
return true
}
override fun onShowPress(e: MotionEvent) {}
override fun onSingleTapUp(e: MotionEvent): Boolean {
return false
}
override fun onTouchEvent(event: MotionEvent): Boolean {
return detector.onTouchEvent(event)
}
fun setScrollDirection(direction: Int) {
require(!(direction != 1 && direction != -1))
this.direction = direction
}
override fun onLongPress(e: MotionEvent) {}
fun setMaxDataOffset(maxDataOffset: Int) {
this.maxDataOffset = maxDataOffset
dataOffset = min(dataOffset, maxDataOffset)
scrollController.onDataOffsetChanged(dataOffset)
postInvalidate()
}
fun setScrollController(scrollController: ScrollController) {
this.scrollController = scrollController
}
fun setScrollerBucketSize(scrollerBucketSize: Int) {
this.scrollerBucketSize = scrollerBucketSize
}
private fun init(context: Context?) {
detector = GestureDetector(context, this)
scroller = Scroller(context, null, true)
val newScrollAnimator = ValueAnimator.ofFloat(0f, 1f)
newScrollAnimator.addUpdateListener(this)
scrollAnimator = newScrollAnimator
scrollController = object : ScrollController {}
}
fun reset() {
scroller.finalX = 0
scroller.computeScrollOffset()
updateDataOffset()
}
private fun updateDataOffset() {
var newDataOffset = scroller.currX / scrollerBucketSize
newDataOffset = max(0, newDataOffset)
newDataOffset = min(maxDataOffset, newDataOffset)
if (newDataOffset != dataOffset) {
dataOffset = newDataOffset
scrollController.onDataOffsetChanged(dataOffset)
postInvalidate()
}
}
interface ScrollController {
fun onDataOffsetChanged(newDataOffset: Int) {}
}
}

@ -1,313 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import android.content.*;
import android.graphics.*;
import android.util.*;
import android.view.*;
import android.view.ViewGroup.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.utils.*;
import java.text.*;
import java.util.*;
import static android.view.View.MeasureSpec.*;
import static org.isoron.uhabits.utils.InterfaceUtils.*;
public class StreakChart extends View
{
private Paint paint;
private long minLength;
private long maxLength;
private int[] colors;
private int[] textColors;
private RectF rect;
private int baseSize;
private int primaryColor;
private List<Streak> streaks;
private boolean isBackgroundTransparent;
private DateFormat dateFormat;
private int width;
private float em;
private float maxLabelWidth;
private float textMargin;
private boolean shouldShowLabels;
private int textColor;
public StreakChart(Context context)
{
super(context);
init();
}
public StreakChart(Context context, AttributeSet attrs)
{
super(context, attrs);
init();
}
/**
* Returns the maximum number of streaks this view is able to show, given
* its current size.
*
* @return max number of visible streaks
*/
public int getMaxStreakCount()
{
return (int) Math.floor(getMeasuredHeight() / baseSize);
}
public void populateWithRandomData()
{
Timestamp start = DateUtils.getToday();
LinkedList<Streak> streaks = new LinkedList<>();
for (int i = 0; i < 10; i++)
{
int length = new Random().nextInt(100);
Timestamp end = start.plus(length);
streaks.add(new Streak(start, end));
start = end.plus(1);
}
setStreaks(streaks);
}
public void setColor(int color)
{
this.primaryColor = color;
postInvalidate();
}
public void setIsBackgroundTransparent(boolean isBackgroundTransparent)
{
this.isBackgroundTransparent = isBackgroundTransparent;
initColors();
}
public void setStreaks(List<Streak> streaks)
{
this.streaks = streaks;
initColors();
updateMaxMinLengths();
requestLayout();
}
@Override
protected void onDraw(Canvas canvas)
{
super.onDraw(canvas);
if (streaks.size() == 0) return;
rect.set(0, 0, width, baseSize);
for (Streak s : streaks)
{
drawRow(canvas, s, rect);
rect.offset(0, baseSize);
}
}
@Override
protected void onMeasure(int widthSpec, int heightSpec)
{
LayoutParams params = getLayoutParams();
if (params != null && params.height == LayoutParams.WRAP_CONTENT)
{
int width = getSize(widthSpec);
int height = streaks.size() * baseSize;
heightSpec = makeMeasureSpec(height, EXACTLY);
widthSpec = makeMeasureSpec(width, EXACTLY);
}
setMeasuredDimension(widthSpec, heightSpec);
}
@Override
protected void onSizeChanged(int width,
int height,
int oldWidth,
int oldHeight)
{
this.width = width;
Context context = getContext();
float minTextSize = getDimension(context, R.dimen.tinyTextSize);
float maxTextSize = getDimension(context, R.dimen.regularTextSize);
float textSize = baseSize * 0.5f;
paint.setTextSize(
Math.max(Math.min(textSize, maxTextSize), minTextSize));
em = paint.getFontSpacing();
textMargin = 0.5f * em;
updateMaxMinLengths();
}
private void drawRow(Canvas canvas, Streak streak, RectF rect)
{
if (maxLength == 0) return;
float percentage = (float) streak.getLength() / maxLength;
float availableWidth = width - 2 * maxLabelWidth;
if (shouldShowLabels) availableWidth -= 2 * textMargin;
float barWidth = percentage * availableWidth;
float minBarWidth =
paint.measureText(Long.toString(streak.getLength())) + em;
barWidth = Math.max(barWidth, minBarWidth);
float gap = (width - barWidth) / 2;
float paddingTopBottom = baseSize * 0.05f;
paint.setColor(percentageToColor(percentage));
float round = dpToPixels(getContext(), 2);
canvas.drawRoundRect(rect.left + gap,
rect.top + paddingTopBottom,
rect.right - gap,
rect.bottom - paddingTopBottom,
round,
round,
paint);
float yOffset = rect.centerY() + 0.3f * em;
paint.setColor(percentageToTextColor(percentage));
paint.setTextAlign(Paint.Align.CENTER);
canvas.drawText(Long.toString(streak.getLength()), rect.centerX(),
yOffset, paint);
if (shouldShowLabels)
{
String startLabel = dateFormat.format(streak.getStart().toJavaDate());
String endLabel = dateFormat.format(streak.getEnd().toJavaDate());
paint.setColor(textColors[1]);
paint.setTextAlign(Paint.Align.RIGHT);
canvas.drawText(startLabel, gap - textMargin, yOffset, paint);
paint.setTextAlign(Paint.Align.LEFT);
canvas.drawText(endLabel, width - gap + textMargin, yOffset, paint);
}
}
private void init()
{
initPaints();
initColors();
streaks = Collections.emptyList();
dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM);
if (!isInEditMode()) dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
rect = new RectF();
baseSize = getResources().getDimensionPixelSize(R.dimen.baseSize);
}
private void initColors()
{
int red = Color.red(primaryColor);
int green = Color.green(primaryColor);
int blue = Color.blue(primaryColor);
StyledResources res = new StyledResources(getContext());
colors = new int[4];
colors[3] = primaryColor;
colors[2] = Color.argb(192, red, green, blue);
colors[1] = Color.argb(96, red, green, blue);
colors[0] = res.getColor(R.attr.lowContrastTextColor);
textColors = new int[3];
textColors[2] = res.getColor(R.attr.highContrastReverseTextColor);
textColors[1] = res.getColor(R.attr.mediumContrastTextColor);
textColors[0] = res.getColor(R.attr.lowContrastReverseTextColor);
}
private void initPaints()
{
paint = new Paint();
paint.setTextAlign(Paint.Align.CENTER);
paint.setAntiAlias(true);
}
private int percentageToColor(float percentage)
{
if (percentage >= 1.0f) return colors[3];
if (percentage >= 0.8f) return colors[2];
if (percentage >= 0.5f) return colors[1];
return colors[0];
}
private int percentageToTextColor(float percentage)
{
if (percentage >= 0.5f) return textColors[2];
return textColors[1];
}
private void updateMaxMinLengths()
{
maxLength = 0;
minLength = Long.MAX_VALUE;
shouldShowLabels = true;
for (Streak s : streaks)
{
maxLength = Math.max(maxLength, s.getLength());
minLength = Math.min(minLength, s.getLength());
float lw1 =
paint.measureText(dateFormat.format(s.getStart().toJavaDate()));
float lw2 =
paint.measureText(dateFormat.format(s.getEnd().toJavaDate()));
maxLabelWidth = Math.max(maxLabelWidth, Math.max(lw1, lw2));
}
if (width - 2 * maxLabelWidth < width * 0.25f)
{
maxLabelWidth = 0;
shouldShowLabels = false;
}
}
}

@ -0,0 +1,249 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Streak
import org.isoron.uhabits.core.models.Timestamp
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
import org.isoron.uhabits.utils.InterfaceUtils.getDimension
import org.isoron.uhabits.utils.StyledResources
import java.text.DateFormat
import java.util.LinkedList
import java.util.Random
import java.util.TimeZone
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
class StreakChart : View {
private var paint: Paint? = null
private var minLength: Long = 0
private var maxLength: Long = 0
private lateinit var colors: IntArray
private lateinit var textColors: IntArray
private var rect: RectF? = null
private var baseSize = 0
private var primaryColor = 0
private var streaks: List<Streak>? = null
private var isBackgroundTransparent = false
private var dateFormat: DateFormat? = null
private var internalWidth = 0
private var em = 0f
private var maxLabelWidth = 0f
private var textMargin = 0f
private var shouldShowLabels = false
private val textColor = 0
constructor(context: Context?) : super(context) {
init()
}
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
init()
}
/**
* Returns the maximum number of streaks this view is able to show, given
* its current size.
*
* @return max number of visible streaks
*/
val maxStreakCount: Int
get() = floor((measuredHeight / baseSize).toDouble()).toInt()
fun populateWithRandomData() {
var start: Timestamp = getToday()
val streaks: MutableList<Streak> = LinkedList()
for (i in 0..9) {
val length = Random().nextInt(100)
val end = start.plus(length)
streaks.add(Streak(start, end))
start = end.plus(1)
}
setStreaks(streaks)
}
fun setColor(color: Int) {
primaryColor = color
postInvalidate()
}
fun setIsBackgroundTransparent(isBackgroundTransparent: Boolean) {
this.isBackgroundTransparent = isBackgroundTransparent
initColors()
}
fun setStreaks(streaks: List<Streak>?) {
this.streaks = streaks
initColors()
updateMaxMinLengths()
requestLayout()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (streaks!!.isEmpty()) return
rect!![0f, 0f, internalWidth.toFloat()] = baseSize.toFloat()
for (s in streaks!!) {
drawRow(canvas, s, rect)
rect!!.offset(0f, baseSize.toFloat())
}
}
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
var widthSpec = widthSpec
var heightSpec = heightSpec
val params = layoutParams
if (params != null && params.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
val width = MeasureSpec.getSize(widthSpec)
val height = streaks!!.size * baseSize
heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
}
setMeasuredDimension(widthSpec, heightSpec)
}
override fun onSizeChanged(
width: Int,
height: Int,
oldWidth: Int,
oldHeight: Int
) {
this.internalWidth = width
val context = context
val minTextSize = getDimension(context, R.dimen.tinyTextSize)
val maxTextSize = getDimension(context, R.dimen.regularTextSize)
val textSize = baseSize * 0.5f
paint!!.textSize = max(min(textSize, maxTextSize), minTextSize)
em = paint!!.fontSpacing
textMargin = 0.5f * em
updateMaxMinLengths()
}
private fun drawRow(canvas: Canvas, streak: Streak, rect: RectF?) {
if (maxLength == 0L) return
val percentage = streak.length.toFloat() / maxLength
var availableWidth = internalWidth - 2 * maxLabelWidth
if (shouldShowLabels) availableWidth -= 2 * textMargin
var barWidth = percentage * availableWidth
val minBarWidth = paint!!.measureText(streak.length.toLong().toString()) + em
barWidth = max(barWidth, minBarWidth)
val gap = (internalWidth - barWidth) / 2
val paddingTopBottom = baseSize * 0.05f
paint!!.color = percentageToColor(percentage)
val round = dpToPixels(context, 2f)
canvas.drawRoundRect(
rect!!.left + gap,
rect.top + paddingTopBottom,
rect.right - gap,
rect.bottom - paddingTopBottom,
round,
round,
paint!!
)
val yOffset = rect.centerY() + 0.3f * em
paint!!.color = percentageToTextColor(percentage)
paint!!.textAlign = Paint.Align.CENTER
canvas.drawText(
streak.length.toLong().toString(),
rect.centerX(),
yOffset,
paint!!
)
if (shouldShowLabels) {
val startLabel = dateFormat!!.format(streak.start.toJavaDate())
val endLabel = dateFormat!!.format(streak.end.toJavaDate())
paint!!.color = textColors[1]
paint!!.textAlign = Paint.Align.RIGHT
canvas.drawText(startLabel, gap - textMargin, yOffset, paint!!)
paint!!.textAlign = Paint.Align.LEFT
canvas.drawText(endLabel, internalWidth - gap + textMargin, yOffset, paint!!)
}
}
private fun init() {
initPaints()
initColors()
streaks = emptyList()
val newDateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM)
if (!isInEditMode) newDateFormat.timeZone = TimeZone.getTimeZone("GMT")
dateFormat = newDateFormat
rect = RectF()
baseSize = resources.getDimensionPixelSize(R.dimen.baseSize)
}
private fun initColors() {
val red = Color.red(primaryColor)
val green = Color.green(primaryColor)
val blue = Color.blue(primaryColor)
val res = StyledResources(context)
colors = IntArray(4)
colors[3] = primaryColor
colors[2] = Color.argb(192, red, green, blue)
colors[1] = Color.argb(96, red, green, blue)
colors[0] = res.getColor(R.attr.lowContrastTextColor)
textColors = IntArray(3)
textColors[2] = res.getColor(R.attr.highContrastReverseTextColor)
textColors[1] = res.getColor(R.attr.mediumContrastTextColor)
textColors[0] = res.getColor(R.attr.lowContrastReverseTextColor)
}
private fun initPaints() {
paint = Paint()
paint!!.textAlign = Paint.Align.CENTER
paint!!.isAntiAlias = true
}
private fun percentageToColor(percentage: Float): Int {
if (percentage >= 1.0f) return colors[3]
if (percentage >= 0.8f) return colors[2]
return if (percentage >= 0.5f) colors[1] else colors[0]
}
private fun percentageToTextColor(percentage: Float): Int {
return if (percentage >= 0.5f) textColors[2] else textColors[1]
}
private fun updateMaxMinLengths() {
maxLength = 0
minLength = Long.MAX_VALUE
shouldShowLabels = true
for (s in streaks!!) {
maxLength = max(maxLength, s.length.toLong())
minLength = min(minLength, s.length.toLong())
val lw1 = paint!!.measureText(dateFormat!!.format(s.start.toJavaDate()))
val lw2 = paint!!.measureText(dateFormat!!.format(s.end.toJavaDate()))
maxLabelWidth = max(maxLabelWidth, max(lw1, lw2))
}
if (internalWidth - 2 * maxLabelWidth < internalWidth * 0.25f) {
maxLabelWidth = 0f
shouldShowLabels = false
}
}
}

@ -1,226 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views;
import android.content.*;
import android.graphics.*;
import android.util.*;
import android.view.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.activities.habits.list.views.*;
import org.isoron.uhabits.utils.*;
import java.util.*;
import static android.view.View.MeasureSpec.*;
import static org.isoron.uhabits.utils.InterfaceUtils.*;
public class TargetChart extends View
{
private Paint paint;
private int baseSize;
private int primaryColor;
private int mediumContrastTextColor;
private int highContrastReverseTextColor;
private int lowContrastTextColor;
private RectF rect = new RectF();
private RectF barRect = new RectF();
private List<Double> values = Collections.emptyList();
private List<String> labels = Collections.emptyList();
private List<Double> targets = Collections.emptyList();
private float maxLabelSize;
private float tinyTextSize;
public TargetChart(Context context)
{
super(context);
init();
}
public TargetChart(Context context, AttributeSet attrs)
{
super(context, attrs);
init();
}
public void populateWithRandomData()
{
labels = new ArrayList<>();
values = new ArrayList<>();
targets = new ArrayList<>();
for (int i = 0; i < 5; i++) {
double percentage = new Random().nextDouble();
targets.add(new Random().nextDouble() * 1000.0);
values.add(targets.get(i) * percentage * 1.2);
labels.add(String.format(Locale.US, "Label %d", i + 1));
}
}
public void setColor(int color)
{
this.primaryColor = color;
postInvalidate();
}
@Override
protected void onDraw(Canvas canvas)
{
super.onDraw(canvas);
if (labels.size() == 0) return;
maxLabelSize = 0;
for (String label : labels) {
paint.setTextSize(tinyTextSize);
float len = paint.measureText(label);
maxLabelSize = Math.max(maxLabelSize, len);
}
float marginTop = (getHeight() - baseSize * labels.size()) / 2.0f;
rect.set(0, marginTop, getWidth(), marginTop + baseSize);
for (int i = 0; i < labels.size(); i++) {
drawRow(canvas, i, rect);
rect.offset(0, baseSize);
}
}
@Override
protected void onMeasure(int widthSpec, int heightSpec)
{
baseSize = getResources().getDimensionPixelSize(R.dimen.baseSize);
int width = getSize(widthSpec);
int height = labels.size() * baseSize;
ViewGroup.LayoutParams params = getLayoutParams();
if (params != null && params.height == ViewGroup.LayoutParams.MATCH_PARENT) {
height = getSize(heightSpec);
if (labels.size() > 0) baseSize = height / labels.size();
}
heightSpec = makeMeasureSpec(height, EXACTLY);
widthSpec = makeMeasureSpec(width, EXACTLY);
setMeasuredDimension(widthSpec, heightSpec);
}
private void drawRow(Canvas canvas, int row, RectF rect)
{
float padding = dpToPixels(getContext(), 4);
float round = dpToPixels(getContext(), 2);
float stop = maxLabelSize + padding * 2;
paint.setColor(mediumContrastTextColor);
// Draw label
paint.setTextSize(tinyTextSize);
paint.setTextAlign(Paint.Align.RIGHT);
float yTextAdjust = (paint.descent() + paint.ascent()) / 2.0f;
canvas.drawText(labels.get(row),
rect.left + stop - padding,
rect.centerY() - yTextAdjust,
paint);
// Draw background box
paint.setColor(lowContrastTextColor);
barRect.set(rect.left + stop + padding,
rect.top + baseSize * 0.05f,
rect.right - padding,
rect.bottom - baseSize * 0.05f);
canvas.drawRoundRect(barRect, round, round, paint);
float percentage = (float) (values.get(row) / targets.get(row));
percentage = Math.min(1.0f, percentage);
// Draw completed box
float completedWidth = percentage * barRect.width();
if (completedWidth > 0 && completedWidth < 2 * round) {
completedWidth = 2 * round;
}
float remainingWidth = barRect.width() - completedWidth;
paint.setColor(primaryColor);
barRect.set(barRect.left,
barRect.top,
barRect.left + completedWidth,
barRect.bottom);
canvas.drawRoundRect(barRect, round, round, paint);
// Draw values
paint.setColor(Color.WHITE);
paint.setTextSize(tinyTextSize);
paint.setTextAlign(Paint.Align.CENTER);
yTextAdjust = (paint.descent() + paint.ascent()) / 2.0f;
double remaining = targets.get(row) - values.get(row);
String completedText = NumberButtonViewKt.toShortString(values.get(row));
String remainingText = NumberButtonViewKt.toShortString(remaining);
if (completedWidth > paint.measureText(completedText) + 2 * padding) {
paint.setColor(highContrastReverseTextColor);
canvas.drawText(completedText,
barRect.centerX(),
barRect.centerY() - yTextAdjust,
paint);
}
if (remainingWidth > paint.measureText(remainingText) + 2 * padding) {
paint.setColor(mediumContrastTextColor);
barRect.set(rect.left + stop + padding + completedWidth,
barRect.top,
rect.right - padding,
barRect.bottom);
canvas.drawText(remainingText,
barRect.centerX(),
barRect.centerY() - yTextAdjust,
paint);
}
}
private void init()
{
paint = new Paint();
paint.setTextAlign(Paint.Align.CENTER);
paint.setAntiAlias(true);
StyledResources res = new StyledResources(getContext());
lowContrastTextColor = res.getColor(R.attr.lowContrastTextColor);
mediumContrastTextColor = res.getColor(R.attr.mediumContrastTextColor);
highContrastReverseTextColor = res.getColor(R.attr.highContrastReverseTextColor);
tinyTextSize = getDimension(getContext(), R.dimen.tinyTextSize);
}
public void setValues(List<Double> values)
{
this.values = values;
requestLayout();
}
public void setLabels(List<String> labels)
{
this.labels = labels;
requestLayout();
}
public void setTargets(List<Double> targets)
{
this.targets = targets;
requestLayout();
}
}

@ -0,0 +1,188 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.views
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.habits.list.views.toShortString
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
import org.isoron.uhabits.utils.InterfaceUtils.getDimension
import org.isoron.uhabits.utils.StyledResources
import kotlin.math.max
import kotlin.math.min
class TargetChart : View {
private var paint: Paint? = null
private var baseSize = 0
private var primaryColor = 0
private var mediumContrastTextColor = 0
private var highContrastReverseTextColor = 0
private var lowContrastTextColor = 0
private val rect = RectF()
private val barRect = RectF()
private var values = emptyList<Double>()
private var labels = emptyList<String>()
private var targets = emptyList<Double>()
private var maxLabelSize = 0f
private var tinyTextSize = 0f
constructor(context: Context?) : super(context) {
init()
}
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
init()
}
fun setColor(color: Int) {
primaryColor = color
postInvalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (labels.isEmpty()) return
maxLabelSize = 0f
for (label in labels) {
paint!!.textSize = tinyTextSize
val len = paint!!.measureText(label)
maxLabelSize = max(maxLabelSize, len)
}
val marginTop = (height - baseSize * labels.size) / 2.0f
rect[0f, marginTop, width.toFloat()] = marginTop + baseSize
for (i in labels.indices) {
drawRow(canvas, i, rect)
rect.offset(0f, baseSize.toFloat())
}
}
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
var widthSpec = widthSpec
var heightSpec = heightSpec
baseSize = resources.getDimensionPixelSize(R.dimen.baseSize)
val width = MeasureSpec.getSize(widthSpec)
var height = labels.size * baseSize
val params = layoutParams
if (params != null && params.height == ViewGroup.LayoutParams.MATCH_PARENT) {
height = MeasureSpec.getSize(heightSpec)
if (labels.isNotEmpty()) baseSize = height / labels.size
}
heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
setMeasuredDimension(widthSpec, heightSpec)
}
private fun drawRow(canvas: Canvas, row: Int, rect: RectF) {
val padding = dpToPixels(context, 4f)
val round = dpToPixels(context, 2f)
val stop = maxLabelSize + padding * 2
paint!!.color = mediumContrastTextColor
// Draw label
paint!!.textSize = tinyTextSize
paint!!.textAlign = Paint.Align.RIGHT
var yTextAdjust = (paint!!.descent() + paint!!.ascent()) / 2.0f
canvas.drawText(
labels[row],
rect.left + stop - padding,
rect.centerY() - yTextAdjust,
paint!!
)
// Draw background box
paint!!.color = lowContrastTextColor
barRect[rect.left + stop + padding, rect.top + baseSize * 0.05f, rect.right - padding] =
rect.bottom - baseSize * 0.05f
canvas.drawRoundRect(barRect, round, round, paint!!)
var percentage = (values[row] / targets[row]).toFloat()
percentage = min(1.0f, percentage)
// Draw completed box
var completedWidth = percentage * barRect.width()
if (completedWidth > 0 && completedWidth < 2 * round) {
completedWidth = 2 * round
}
val remainingWidth = barRect.width() - completedWidth
paint!!.color = primaryColor
barRect[barRect.left, barRect.top, barRect.left + completedWidth] = barRect.bottom
canvas.drawRoundRect(barRect, round, round, paint!!)
// Draw values
paint!!.color = Color.WHITE
paint!!.textSize = tinyTextSize
paint!!.textAlign = Paint.Align.CENTER
yTextAdjust = (paint!!.descent() + paint!!.ascent()) / 2.0f
val remaining = targets[row] - values[row]
val completedText = values[row].toShortString()
val remainingText = remaining.toShortString()
if (completedWidth > paint!!.measureText(completedText) + 2 * padding) {
paint!!.color = highContrastReverseTextColor
canvas.drawText(
completedText,
barRect.centerX(),
barRect.centerY() - yTextAdjust,
paint!!
)
}
if (remainingWidth > paint!!.measureText(remainingText) + 2 * padding) {
paint!!.color = mediumContrastTextColor
barRect[rect.left + stop + padding + completedWidth, barRect.top, rect.right - padding] =
barRect.bottom
canvas.drawText(
remainingText,
barRect.centerX(),
barRect.centerY() - yTextAdjust,
paint!!
)
}
}
private fun init() {
paint = Paint()
paint!!.textAlign = Paint.Align.CENTER
paint!!.isAntiAlias = true
val res = StyledResources(context)
lowContrastTextColor = res.getColor(R.attr.lowContrastTextColor)
mediumContrastTextColor = res.getColor(R.attr.mediumContrastTextColor)
highContrastReverseTextColor = res.getColor(R.attr.highContrastReverseTextColor)
tinyTextSize = getDimension(context, R.dimen.tinyTextSize)
}
fun setValues(values: List<Double>) {
this.values = values
requestLayout()
}
fun setLabels(labels: List<String>) {
this.labels = labels
requestLayout()
}
fun setTargets(targets: List<Double>) {
this.targets = targets
requestLayout()
}
}

@ -40,8 +40,8 @@ class TaskProgressBar(
isIndeterminate = true
}
override fun onTaskStarted(task: Task?) = update()
override fun onTaskFinished(task: Task?) = update()
override fun onTaskStarted(task: Task) = update()
override fun onTaskFinished(task: Task) = update()
override fun onAttachedToWindow() {
super.onAttachedToWindow()

@ -33,7 +33,7 @@ import org.isoron.uhabits.activities.habits.list.views.HabitCardListAdapter
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.sync.SyncManager
import org.isoron.uhabits.core.tasks.TaskRunner
import org.isoron.uhabits.core.ui.ThemeSwitcher.THEME_DARK
import org.isoron.uhabits.core.ui.ThemeSwitcher.Companion.THEME_DARK
import org.isoron.uhabits.core.utils.MidnightTimer
import org.isoron.uhabits.database.AutoBackup
import org.isoron.uhabits.inject.ActivityContextModule

@ -173,8 +173,8 @@ class ListHabitsScreen
ConfirmDeleteDialog(activity, callback, quantity).show()
}
override fun showEditHabitsScreen(habits: List<Habit>) {
val intent = intentFactory.startEditActivity(activity, habits[0])
override fun showEditHabitsScreen(selected: List<Habit>) {
val intent = intentFactory.startEditActivity(activity, selected[0])
activity.startActivity(intent)
}
@ -183,8 +183,8 @@ class ListHabitsScreen
activity.startActivity(intent)
}
override fun showHabitScreen(habit: Habit) {
val intent = intentFactory.startShowHabitActivity(activity, habit)
override fun showHabitScreen(h: Habit) {
val intent = intentFactory.startShowHabitActivity(activity, h)
activity.startActivity(intent)
}
@ -230,10 +230,7 @@ class ListHabitsScreen
activity.startActivityForResult(intent, REQUEST_SETTINGS)
}
override fun showColorPicker(
defaultColor: PaletteColor,
callback: OnColorPickedCallback
) {
override fun showColorPicker(defaultColor: PaletteColor, callback: OnColorPickedCallback) {
val picker = colorPickerFactory.create(defaultColor)
picker.setListener(callback)
picker.show(activity.supportFragmentManager, "picker")
@ -290,13 +287,17 @@ class ListHabitsScreen
private fun onImportData(file: File, onFinished: () -> Unit) {
taskRunner.execute(
importTaskFactory.create(file) { result ->
if (result == ImportDataTask.SUCCESS) {
adapter.refresh()
activity.showMessage(activity.resources.getString(R.string.habits_imported))
} else if (result == ImportDataTask.NOT_RECOGNIZED) {
activity.showMessage(activity.resources.getString(R.string.file_not_recognized))
} else {
activity.showMessage(activity.resources.getString(R.string.could_not_import))
when (result) {
ImportDataTask.SUCCESS -> {
adapter.refresh()
activity.showMessage(activity.resources.getString(R.string.habits_imported))
}
ImportDataTask.NOT_RECOGNIZED -> {
activity.showMessage(activity.resources.getString(R.string.file_not_recognized))
}
else -> {
activity.showMessage(activity.resources.getString(R.string.could_not_import))
}
}
onFinished()
}

@ -53,7 +53,7 @@ class HabitCardListAdapter @Inject constructor(
ListHabitsSelectionMenuBehavior.Adapter {
val observable: ModelObservable = ModelObservable()
private var listView: HabitCardListView? = null
private val selected: LinkedList<Habit> = LinkedList()
override val selected: LinkedList<Habit> = LinkedList()
override fun atMidnight() {
cache.refreshAllHabits()
}
@ -90,10 +90,6 @@ class HabitCardListAdapter @Inject constructor(
return getItem(position)!!.id!!
}
override fun getSelected(): List<Habit> {
return LinkedList(selected)
}
/**
* Returns whether list of selected items is empty.
*
@ -158,8 +154,8 @@ class HabitCardListAdapter @Inject constructor(
observable.notifyListeners()
}
override fun onItemMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition, toPosition)
override fun onItemMoved(oldPosition: Int, newPosition: Int) {
notifyItemMoved(oldPosition, newPosition)
observable.notifyListeners()
}
@ -182,10 +178,10 @@ class HabitCardListAdapter @Inject constructor(
* database operation to finish, the cache can be modified to reflect the
* changes immediately.
*
* @param habits list of habits to be removed
* @param selected list of habits to be removed
*/
override fun performRemove(habits: List<Habit>) {
for (habit in habits) cache.remove(habit.id!!)
override fun performRemove(selected: List<Habit>) {
for (habit in selected) cache.remove(habit.id!!)
}
/**
@ -225,19 +221,19 @@ class HabitCardListAdapter @Inject constructor(
this.listView = listView
}
override fun setPrimaryOrder(order: HabitList.Order) {
cache.primaryOrder = order
preferences.defaultPrimaryOrder = order
}
override fun setSecondaryOrder(order: HabitList.Order) {
cache.secondaryOrder = order
preferences.defaultSecondaryOrder = order
}
override var primaryOrder: HabitList.Order
get() = cache.primaryOrder
set(value) {
cache.primaryOrder = value
preferences.defaultPrimaryOrder = value
}
override fun getPrimaryOrder(): HabitList.Order {
return cache.primaryOrder
}
override var secondaryOrder: HabitList.Order
get() = cache.secondaryOrder
set(value) {
cache.secondaryOrder = value
preferences.defaultSecondaryOrder = value
}
/**
* Selects or deselects the item at a given position.

@ -131,7 +131,7 @@ class HabitCardListView(
super.onRestoreInstanceState(state)
return
}
dataOffset = state.bundle.getInt("dataOffset")
dataOffset = state.bundle!!.getInt("dataOffset")
super.onRestoreInstanceState(state.superState)
}

@ -20,12 +20,11 @@
package org.isoron.uhabits.activities.habits.list.views
import android.content.Context
import android.graphics.text.LineBreaker.BREAK_STRATEGY_BALANCED
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.LOLLIPOP
import android.os.Build.VERSION_CODES.M
import android.os.Handler
import android.os.Looper
import android.text.Layout
import android.text.TextUtils
import android.view.Gravity
import android.view.View
@ -90,10 +89,10 @@ class HabitCardView(
}
var score
get() = scoreRing.percentage.toDouble()
get() = scoreRing.getPercentage().toDouble()
set(value) {
scoreRing.percentage = value.toFloat()
scoreRing.precision = 1.0f / 16
scoreRing.setPercentage(value.toFloat())
scoreRing.setPrecision(1.0f / 16)
}
var unit
@ -137,7 +136,7 @@ class HabitCardView(
maxLines = 2
ellipsize = TextUtils.TruncateAt.END
layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)
if (SDK_INT >= M) breakStrategy = Layout.BREAK_STRATEGY_BALANCED
if (SDK_INT >= M) breakStrategy = BREAK_STRATEGY_BALANCED
}
checkmarkPanel = checkmarkPanelFactory.create().apply {
@ -159,7 +158,7 @@ class HabitCardView(
gravity = Gravity.CENTER_VERTICAL
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
if (SDK_INT >= LOLLIPOP) elevation = dp(1f)
elevation = dp(1f)
addView(scoreRing)
addView(label)
@ -167,8 +166,7 @@ class HabitCardView(
addView(numberPanel)
setOnTouchListener { v, event ->
if (SDK_INT >= LOLLIPOP)
v.background.setHotspot(event.x, event.y)
v.background.setHotspot(event.x, event.y)
false
}
}
@ -225,7 +223,7 @@ class HabitCardView(
setTextColor(c)
}
scoreRing.apply {
color = c
setColor(c)
}
checkmarkPanel.apply {
color = c
@ -247,7 +245,7 @@ class HabitCardView(
private fun triggerRipple(x: Float, y: Float) {
val background = innerFrame.background
if (SDK_INT >= LOLLIPOP) background.setHotspot(x, y)
background.setHotspot(x, y)
background.state = intArrayOf(
android.R.attr.state_pressed,
android.R.attr.state_enabled
@ -256,14 +254,6 @@ class HabitCardView(
}
private fun updateBackground(isSelected: Boolean) {
if (SDK_INT < LOLLIPOP) {
val background = when (isSelected) {
true -> sres.getDrawable(R.attr.selectedBackground)
false -> sres.getDrawable(R.attr.cardBackground)
}
innerFrame.setBackgroundDrawable(background)
return
}
val background = when (isSelected) {
true -> R.drawable.selected_box

@ -25,8 +25,6 @@ import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.LOLLIPOP
import android.text.TextPaint
import android.view.View.MeasureSpec.EXACTLY
import org.isoron.uhabits.R
@ -60,7 +58,7 @@ class HeaderView(
init {
setScrollerBucketSize(dim(R.dimen.checkmarkWidth).toInt())
setBackgroundColor(sres.getColor(R.attr.headerBackgroundColor))
if (SDK_INT >= LOLLIPOP) elevation = dp(2.0f)
elevation = dp(2.0f)
}
override fun atMidnight() {

@ -161,15 +161,19 @@ class NumberButtonView(
val label: String
val typeface: Typeface
if (value >= 0) {
label = value.toShortString()
typeface = BOLD_TYPEFACE
} else if (preferences.areQuestionMarksEnabled()) {
label = resources.getString(R.string.fa_question)
typeface = getFontAwesome()
} else {
label = "0"
typeface = BOLD_TYPEFACE
when {
value >= 0 -> {
label = value.toShortString()
typeface = BOLD_TYPEFACE
}
preferences.areQuestionMarksEnabled() -> {
label = resources.getString(R.string.fa_question)
typeface = getFontAwesome()
}
else -> {
label = "0"
typeface = BOLD_TYPEFACE
}
}
pBold.color = activeColor

@ -49,8 +49,9 @@ class OverviewCardView(context: Context, attrs: AttributeSet) : LinearLayout(con
binding.monthDiffLabel.text = formatPercentageDiff(state.scoreMonthDiff)
binding.scoreLabel.setTextColor(androidColor)
binding.scoreLabel.text = String.format("%.0f%%", state.scoreToday * 100)
binding.scoreRing.color = androidColor
binding.scoreRing.percentage = state.scoreToday
binding.scoreRing.setColor(androidColor)
binding.scoreRing.setPercentage(state.scoreToday)
binding.title.setTextColor(androidColor)
binding.totalCountLabel.setTextColor(androidColor)
binding.totalCountLabel.text = state.totalCount.toString()

@ -78,7 +78,7 @@ class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeLis
setResultOnPreferenceClick("bugReport", RESULT_BUG_REPORT)
}
override fun onCreatePreferences(bundle: Bundle, s: String) {
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
// NOP
}

@ -57,10 +57,10 @@ class AndroidNotificationTray
Log.d("AndroidNotificationTray", msg)
}
override fun removeNotification(id: Int) {
override fun removeNotification(notificationId: Int) {
val manager = NotificationManagerCompat.from(context)
manager.cancel(id)
active.remove(id)
manager.cancel(notificationId)
active.remove(notificationId)
}
override fun showNotification(

@ -43,6 +43,7 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
private var dialog: AlertDialog? = null
override fun onCreate(bundle: Bundle?) {
super.onCreate(bundle)
val intent = intent
if (intent == null) finish()
if (intent.data == null) finish()
val app = applicationContext as HabitsApplication
@ -79,7 +80,7 @@ class SnoozeDelayPickerActivity : FragmentActivity(), OnItemClickListener {
override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
val snoozeValues = resources.getIntArray(R.array.snooze_picker_values)
if (snoozeValues[position] >= 0) {
reminderController!!.onSnoozeDelayPicked(habit, snoozeValues[position])
reminderController!!.onSnoozeDelayPicked(habit!!, snoozeValues[position])
finish()
} else showTimePicker()
}

@ -43,7 +43,7 @@ class ReminderController @Inject constructor(
fun onShowReminder(
habit: Habit,
timestamp: Timestamp?,
timestamp: Timestamp,
reminderTime: Long
) {
notificationTray.show(habit, timestamp, reminderTime)
@ -54,9 +54,9 @@ class ReminderController @Inject constructor(
showSnoozeDelayPicker(habit, context)
}
fun onSnoozeDelayPicked(habit: Habit?, delayInMinutes: Int) {
fun onSnoozeDelayPicked(habit: Habit, delayInMinutes: Int) {
reminderScheduler.snoozeReminder(habit, delayInMinutes.toLong())
notificationTray.cancel(habit!!)
notificationTray.cancel(habit)
}
fun onSnoozeTimePicked(habit: Habit?, hour: Int, minute: Int) {

@ -1,143 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.tasks;
import android.os.*;
import org.isoron.uhabits.core.*;
import org.isoron.uhabits.core.tasks.*;
import java.util.*;
import dagger.Module;
import dagger.Provides;
@Module
public class AndroidTaskRunner implements TaskRunner
{
private final LinkedList<CustomAsyncTask> activeTasks;
private final HashMap<Task, CustomAsyncTask> taskToAsyncTask;
private LinkedList<Listener> listeners;
public AndroidTaskRunner()
{
activeTasks = new LinkedList<>();
taskToAsyncTask = new HashMap<>();
listeners = new LinkedList<>();
}
@Provides
@AppScope
public static TaskRunner provideTaskRunner()
{
return new AndroidTaskRunner();
}
@Override
public void addListener(Listener listener)
{
listeners.add(listener);
}
@Override
public void execute(Task task)
{
task.onAttached(this);
new CustomAsyncTask(task).execute();
}
@Override
public int getActiveTaskCount()
{
return activeTasks.size();
}
@Override
public void publishProgress(Task task, int progress)
{
CustomAsyncTask asyncTask = taskToAsyncTask.get(task);
if (asyncTask == null) return;
asyncTask.publish(progress);
}
@Override
public void removeListener(Listener listener)
{
listeners.remove(listener);
}
private class CustomAsyncTask extends AsyncTask<Void, Integer, Void>
{
private final Task task;
private boolean isCancelled = false;
public CustomAsyncTask(Task task)
{
this.task = task;
}
public Task getTask()
{
return task;
}
public void publish(int progress)
{
publishProgress(progress);
}
@Override
protected Void doInBackground(Void... params)
{
if(isCancelled) return null;
task.doInBackground();
return null;
}
@Override
protected void onPostExecute(Void aVoid)
{
if(isCancelled) return;
task.onPostExecute();
activeTasks.remove(this);
taskToAsyncTask.remove(task);
for (Listener l : listeners) l.onTaskFinished(task);
}
@Override
protected void onPreExecute()
{
isCancelled = task.isCanceled();
if(isCancelled) return;
for (Listener l : listeners) l.onTaskStarted(task);
activeTasks.add(this);
taskToAsyncTask.put(task, this);
task.onPreExecute();
}
@Override
protected void onProgressUpdate(Integer... values)
{
task.onProgressUpdate(values[0]);
}
}
}

@ -0,0 +1,99 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.tasks
import android.os.AsyncTask
import dagger.Module
import dagger.Provides
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.tasks.Task
import org.isoron.uhabits.core.tasks.TaskRunner
import java.util.HashMap
import java.util.LinkedList
// TODO: @Module not needed?
@Module
class AndroidTaskRunner : TaskRunner {
private val activeTasks: LinkedList<CustomAsyncTask> = LinkedList()
private val taskToAsyncTask: HashMap<Task, CustomAsyncTask> = HashMap()
private val listeners: LinkedList<TaskRunner.Listener> = LinkedList<TaskRunner.Listener>()
override fun addListener(listener: TaskRunner.Listener) {
listeners.add(listener)
}
override fun execute(task: Task) {
task.onAttached(this)
CustomAsyncTask(task).execute()
}
override val activeTaskCount: Int
get() = activeTasks.size
override fun publishProgress(task: Task, progress: Int) {
val asyncTask = taskToAsyncTask[task] ?: return
asyncTask.publish(progress)
}
override fun removeListener(listener: TaskRunner.Listener) {
listeners.remove(listener)
}
private inner class CustomAsyncTask(val task: Task) : AsyncTask<Void?, Int?, Void?>() {
fun publish(progress: Int) {
publishProgress(progress)
}
override fun doInBackground(vararg params: Void?): Void? {
if (isCancelled) return null
task.doInBackground()
return null
}
override fun onPostExecute(aVoid: Void?) {
if (isCancelled) return
task.onPostExecute()
activeTasks.remove(this)
taskToAsyncTask.remove(task)
for (l in listeners) l.onTaskFinished(task)
}
override fun onPreExecute() {
if (isCancelled) return
for (l in listeners) l.onTaskStarted(task)
activeTasks.add(this)
taskToAsyncTask[task] = this
task.onPreExecute()
}
override fun onProgressUpdate(vararg values: Int?) {
values[0]?.let { task.onProgressUpdate(it) }
}
}
@Module
companion object {
@JvmStatic
@Provides
@AppScope
fun provideTaskRunner(): TaskRunner {
return AndroidTaskRunner()
}
}
}

@ -1,82 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.tasks;
import android.content.*;
import androidx.annotation.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.tasks.*;
import org.isoron.uhabits.inject.*;
import org.isoron.uhabits.utils.*;
import java.io.*;
public class ExportDBTask implements Task
{
private String filename;
@NonNull
private Context context;
private AndroidDirFinder system;
@NonNull
private final Listener listener;
public ExportDBTask(@AppContext @NonNull Context context,
@NonNull AndroidDirFinder system,
@NonNull Listener listener)
{
this.system = system;
this.listener = listener;
this.context = context;
}
@Override
public void doInBackground()
{
filename = null;
try
{
File dir = system.getFilesDir("Backups");
if (dir == null) return;
filename = DatabaseUtils.saveDatabaseCopy(context, dir);
}
catch (IOException e)
{
throw new RuntimeException(e);
}
}
@Override
public void onPostExecute()
{
listener.onExportDBFinished(filename);
}
public interface Listener
{
void onExportDBFinished(@Nullable String filename);
}
}

@ -0,0 +1,51 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.tasks
import android.content.Context
import org.isoron.uhabits.AndroidDirFinder
import org.isoron.uhabits.core.tasks.Task
import org.isoron.uhabits.inject.AppContext
import org.isoron.uhabits.utils.DatabaseUtils.saveDatabaseCopy
import java.io.IOException
class ExportDBTask(
@param:AppContext private val context: Context,
private val system: AndroidDirFinder,
private val listener: Listener
) : Task {
private var filename: String? = null
override fun doInBackground() {
filename = null
filename = try {
val dir = system.getFilesDir("Backups") ?: return
saveDatabaseCopy(context, dir)
} catch (e: IOException) {
throw RuntimeException(e)
}
}
override fun onPostExecute() {
listener.onExportDBFinished(filename)
}
fun interface Listener {
fun onExportDBFinished(filename: String?)
}
}

@ -1,101 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.tasks;
import android.util.*;
import androidx.annotation.NonNull;
import org.isoron.uhabits.core.io.*;
import org.isoron.uhabits.core.models.ModelFactory;
import org.isoron.uhabits.core.models.sqlite.SQLModelFactory;
import org.isoron.uhabits.core.tasks.*;
import java.io.*;
public class ImportDataTask implements Task
{
public static final int FAILED = 3;
public static final int NOT_RECOGNIZED = 2;
public static final int SUCCESS = 1;
private int result;
@NonNull
private final File file;
private GenericImporter importer;
private SQLModelFactory modelFactory;
@NonNull
private final Listener listener;
public ImportDataTask(@NonNull GenericImporter importer,
@NonNull ModelFactory modelFactory,
@NonNull File file,
@NonNull Listener listener)
{
this.importer = importer;
this.modelFactory = (SQLModelFactory) modelFactory;
this.listener = listener;
this.file = file;
}
@Override
public void doInBackground()
{
modelFactory.getDatabase().beginTransaction();
try
{
if (importer.canHandle(file))
{
importer.importHabitsFromFile(file);
result = SUCCESS;
modelFactory.getDatabase().setTransactionSuccessful();
}
else
{
result = NOT_RECOGNIZED;
}
}
catch (Exception e)
{
result = FAILED;
Log.e("ImportDataTask", "Import failed", e);
}
modelFactory.getDatabase().endTransaction();
}
@Override
public void onPostExecute()
{
listener.onImportDataFinished(result);
}
public interface Listener
{
void onImportDataFinished(int result);
}
}

@ -0,0 +1,66 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.tasks
import android.util.Log
import org.isoron.uhabits.core.io.GenericImporter
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.sqlite.SQLModelFactory
import org.isoron.uhabits.core.tasks.Task
import java.io.File
class ImportDataTask(
private val importer: GenericImporter,
modelFactory: ModelFactory,
private val file: File,
private val listener: Listener
) : Task {
private var result = 0
private val modelFactory: SQLModelFactory = modelFactory as SQLModelFactory
override fun doInBackground() {
modelFactory.database.beginTransaction()
try {
if (importer.canHandle(file)) {
importer.importHabitsFromFile(file)
result = SUCCESS
modelFactory.database.setTransactionSuccessful()
} else {
result = NOT_RECOGNIZED
}
} catch (e: Exception) {
result = FAILED
Log.e("ImportDataTask", "Import failed", e)
}
modelFactory.database.endTransaction()
}
override fun onPostExecute() {
listener.onImportDataFinished(result)
}
fun interface Listener {
fun onImportDataFinished(result: Int)
}
companion object {
const val FAILED = 3
const val NOT_RECOGNIZED = 2
const val SUCCESS = 1
}
}

@ -34,7 +34,7 @@ import org.isoron.uhabits.core.preferences.WidgetPreferences
import org.isoron.uhabits.intents.PendingIntentFactory
abstract class BaseWidget(val context: Context, val id: Int) {
protected val widgetPrefs: WidgetPreferences
private val widgetPrefs: WidgetPreferences
protected val prefs: Preferences
protected val pendingIntentFactory: PendingIntentFactory
protected val commandRunner: CommandRunner
@ -120,9 +120,9 @@ abstract class BaseWidget(val context: Context, val id: Int) {
return remoteViews
}
private fun measureView(view: View, width: Int, height: Int) {
var width = width
var height = height
private fun measureView(view: View, w: Int, h: Int) {
var width = w
var height = h
val inflater = LayoutInflater.from(context)
val entireView = inflater.inflate(R.layout.widget_wrapper, null)
var specWidth = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
@ -144,7 +144,7 @@ abstract class BaseWidget(val context: Context, val id: Int) {
}
protected val preferedBackgroundAlpha: Int
protected get() = prefs.widgetOpacity
get() = prefs.widgetOpacity
init {
val app = context.applicationContext as HabitsApplication

@ -109,7 +109,7 @@ abstract class BaseWidgetProvider : AppWidgetProvider() {
}
protected fun getHabitsFromWidgetId(widgetId: Int): List<Habit> {
val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId)
val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId)!!
val selectedHabits = ArrayList<Habit>(selectedIds.size)
for (id in selectedIds) {
val h = habits.getById(id) ?: throw HabitNotFoundException()

@ -95,7 +95,7 @@ class WidgetUpdater
val modifiedWidgetIds = when (modifiedHabitId) {
null -> widgetIds.toList()
else -> widgetIds.filter { w ->
widgetPrefs.getHabitIdsFromWidgetId(w).contains(modifiedHabitId)
widgetPrefs.getHabitIdsFromWidgetId(w)!!.contains(modifiedHabitId)
}
}

@ -83,8 +83,8 @@ class CheckmarkWidgetView : HabitWidgetView {
setShadowAlpha(0x00)
}
}
ring.percentage = percentage
ring.color = fgColor
ring.setPercentage(percentage)
ring.setColor(fgColor)
ring.setBackgroundColor(bgColor)
ring.setText(text)
label.text = name
@ -117,8 +117,6 @@ class CheckmarkWidgetView : HabitWidgetView {
get() = R.layout.widget_checkmark
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
var widthMeasureSpec = widthMeasureSpec
var heightMeasureSpec = heightMeasureSpec
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
var w = width.toFloat()
@ -128,15 +126,15 @@ class CheckmarkWidgetView : HabitWidgetView {
h *= scale
if (h < getDimension(context, R.dimen.checkmarkWidget_heightBreakpoint)) ring.visibility =
GONE else ring.visibility = VISIBLE
widthMeasureSpec = MeasureSpec.makeMeasureSpec(w.toInt(), MeasureSpec.EXACTLY)
heightMeasureSpec = MeasureSpec.makeMeasureSpec(h.toInt(), MeasureSpec.EXACTLY)
val newWidthMeasureSpec = MeasureSpec.makeMeasureSpec(w.toInt(), MeasureSpec.EXACTLY)
val newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(h.toInt(), MeasureSpec.EXACTLY)
var textSize = 0.15f * h
val maxTextSize = getDimension(context, R.dimen.smallerTextSize)
textSize = min(textSize, maxTextSize)
label.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
ring.setTextSize(textSize)
ring.setThickness(0.15f * textSize)
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
super.onMeasure(newWidthMeasureSpec, newHeightMeasureSpec)
}
private fun init() {

@ -168,6 +168,7 @@ class Repository<T>(
get() {
val fields: MutableList<Pair<Field, Column>> = ArrayList()
for (f in klass.declaredFields) {
f.isAccessible = true
for (annotation in f.annotations) {
if (annotation !is Column) continue
fields.add(ImmutablePair(f, annotation))

@ -77,7 +77,7 @@ class HabitsCSVExporter(
}
private fun sanitizeFilename(name: String): String {
val s = name.replace("[^ a-zA-Z0-9\\._-]+".toRegex(), "")
val s = name.replace("[^ a-zA-Z0-9._-]+".toRegex(), "")
return s.substring(0, min(s.length, 100))
}

@ -100,9 +100,9 @@ class LoopDBImporter
habit = habitList.getByUUID(habitRecord.uuid)
for (r in entryRecords) {
val t = Timestamp(r.timestamp)
val t = Timestamp(r.timestamp!!)
val (_, value) = habit!!.originalEntries.get(t)
if (value != r.value) CreateRepetitionCommand(habitList, habit, t, r.value).run()
if (value != r.value) CreateRepetitionCommand(habitList, habit, t, r.value!!).run()
}
runner.notifyListeners(command)

@ -16,51 +16,44 @@
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
package org.isoron.uhabits.core.models;
import androidx.annotation.*;
import com.opencsv.*;
import java.io.*;
import java.util.*;
import javax.annotation.concurrent.*;
import com.opencsv.CSVWriter
import java.io.IOException
import java.io.Writer
import java.util.LinkedList
import javax.annotation.concurrent.ThreadSafe
/**
* An ordered collection of {@link Habit}s.
* An ordered collection of [Habit]s.
*/
@ThreadSafe
public abstract class HabitList implements Iterable<Habit>
{
private final ModelObservable observable;
abstract class HabitList : Iterable<Habit> {
val observable: ModelObservable
@NonNull
protected final HabitMatcher filter;
@JvmField
protected val filter: HabitMatcher
/**
* Creates a new HabitList.
* <p>
*
* Depending on the implementation, this list can either be empty or be
* populated by some pre-existing habits, for example, from a certain
* database.
*/
public HabitList()
{
observable = new ModelObservable();
filter = new HabitMatcherBuilder().setArchivedAllowed(true).build();
constructor() {
observable = ModelObservable()
filter = HabitMatcherBuilder().setArchivedAllowed(true).build()
}
protected HabitList(@NonNull HabitMatcher filter)
{
observable = new ModelObservable();
this.filter = filter;
protected constructor(filter: HabitMatcher) {
observable = ModelObservable()
this.filter = filter
}
/**
* Inserts a new habit in the list.
* <p>
*
* 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
@ -69,8 +62,8 @@ public abstract class HabitList implements Iterable<Habit>
* @param habit the habit to be inserted
* @throws IllegalArgumentException if the habit is already on the list.
*/
public abstract void add(@NonNull Habit habit)
throws IllegalArgumentException;
@Throws(IllegalArgumentException::class)
abstract fun add(habit: Habit)
/**
* Returns the habit with specified id.
@ -78,8 +71,7 @@ public abstract class HabitList implements Iterable<Habit>
* @param id the id of the habit
* @return the habit, or null if none exist
*/
@Nullable
public abstract Habit getById(long id);
abstract fun getById(id: Long): Habit?
/**
* Returns the habit with specified UUID.
@ -87,8 +79,7 @@ public abstract class HabitList implements Iterable<Habit>
* @param uuid the UUID of the habit
* @return the habit, or null if none exist
*/
@Nullable
public abstract Habit getByUUID(String uuid);
abstract fun getByUUID(uuid: String?): Habit?
/**
* Returns the habit that occupies a certain position.
@ -97,8 +88,7 @@ public abstract class HabitList implements Iterable<Habit>
* @return the habit at that position
* @throws IndexOutOfBoundsException when the position is invalid
*/
@NonNull
public abstract Habit getByPosition(int position);
abstract fun getByPosition(position: Int): Habit
/**
* Returns the list of habits that match a given condition.
@ -106,31 +96,9 @@ public abstract class HabitList implements Iterable<Habit>
* @param matcher the matcher that checks the condition
* @return the list of matching habits
*/
@NonNull
public abstract HabitList getFiltered(HabitMatcher matcher);
public ModelObservable getObservable()
{
return observable;
}
public abstract Order getPrimaryOrder();
public abstract Order getSecondaryOrder();
/**
* Changes the order of the elements on the list.
*
* @param order the new order criterion
*/
public abstract void setPrimaryOrder(@NonNull Order order);
/**
* Changes the previous order of the elements on the list.
*
* @param order the new order criterion
*/
public abstract void setSecondaryOrder(@NonNull Order order);
abstract fun getFiltered(matcher: HabitMatcher?): HabitList
abstract var primaryOrder: Order
abstract var secondaryOrder: Order
/**
* Returns the index of the given habit in the list, or -1 if the list does
@ -139,31 +107,27 @@ public abstract class HabitList implements Iterable<Habit>
* @param h the habit
* @return the index of the habit, or -1 if not in the list
*/
public abstract int indexOf(@NonNull Habit h);
public boolean isEmpty()
{
return size() == 0;
}
abstract fun indexOf(h: Habit): Int
val isEmpty: Boolean
get() = size() == 0
/**
* Removes the given habit from the list.
* <p>
*
* If the given habit is not in the list, does nothing.
*
* @param h the habit to be removed.
*/
public abstract void remove(@NonNull Habit h);
abstract fun remove(h: Habit)
/**
* Removes all the habits from the list.
*/
public void removeAll()
{
List<Habit> copy = new LinkedList<>();
for (Habit h : this) copy.add(h);
for (Habit h : copy) remove(h);
observable.notifyListeners();
open fun removeAll() {
val copy: MutableList<Habit> = LinkedList()
for (h in this) copy.add(h)
for (h in copy) remove(h)
observable.notifyListeners()
}
/**
@ -172,40 +136,36 @@ public abstract class HabitList implements Iterable<Habit>
* @param from the habit that should be moved
* @param to the habit that currently occupies the desired position
*/
public abstract void reorder(@NonNull Habit from, @NonNull Habit to);
public void repair()
{
}
abstract fun reorder(from: Habit, to: Habit)
open fun repair() {}
/**
* Returns the number of habits in this list.
*
* @return number of habits
*/
public abstract int size();
abstract fun size(): Int
/**
* Notifies the list that a certain list of habits has been modified.
* <p>
*
* Depending on the implementation, this operation might trigger a write to
* disk, or do nothing at all. To make sure that the habits get persisted,
* this operation must be called.
*
* @param habits the list of habits that have been modified.
*/
public abstract void update(List<Habit> habits);
abstract fun update(habits: List<Habit>)
/**
* Notifies the list that a certain habit has been modified.
* <p>
* See {@link #update(List)} for more details.
*
* See [.update] for more details.
*
* @param habit the habit that has been modified.
*/
public void update(@NonNull Habit habit)
{
update(Collections.singletonList(habit));
fun update(habit: Habit) {
update(listOf(habit))
}
/**
@ -217,9 +177,9 @@ public abstract class HabitList implements Iterable<Habit>
* @param out the writer that will receive the result
* @throws IOException if write operations fail
*/
public void writeCSV(@NonNull Writer out) throws IOException
{
String header[] = {
@Throws(IOException::class)
fun writeCSV(out: Writer) {
val header = arrayOf(
"Position",
"Name",
"Question",
@ -227,35 +187,27 @@ public abstract class HabitList implements Iterable<Habit>
"NumRepetitions",
"Interval",
"Color"
};
CSVWriter csv = new CSVWriter(out);
csv.writeNext(header, false);
for (Habit habit : this)
{
Frequency freq = habit.getFrequency();
String[] cols = {
)
val csv = CSVWriter(out)
csv.writeNext(header, false)
for (habit in this) {
val (numerator, denominator) = habit.frequency
val cols = arrayOf(
String.format("%03d", indexOf(habit) + 1),
habit.getName(),
habit.getQuestion(),
habit.getDescription(),
Integer.toString(freq.getNumerator()),
Integer.toString(freq.getDenominator()),
habit.getColor().toCsvColor(),
};
csv.writeNext(cols, false);
habit.name,
habit.question,
habit.description,
numerator.toString(),
denominator.toString(),
habit.color.toCsvColor()
)
csv.writeNext(cols, false)
}
csv.close();
csv.close()
}
public abstract void resort();
public enum Order
{
abstract fun resort()
enum class Order {
BY_NAME_ASC,
BY_NAME_DESC,
BY_COLOR_ASC,

@ -16,74 +16,70 @@
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
package org.isoron.uhabits.core.models;
import java.util.*;
import javax.annotation.concurrent.*;
import java.util.LinkedList
import javax.annotation.concurrent.ThreadSafe
/**
* A ModelObservable allows objects to subscribe themselves to it and receive
* notifications whenever the model is changed.
*/
@ThreadSafe
public class ModelObservable
{
private List<Listener> listeners;
/**
* Creates a new ModelObservable with no listeners.
*/
public ModelObservable()
{
super();
listeners = new LinkedList<>();
}
class ModelObservable {
private val listeners: MutableList<Listener>
/**
* Adds the given listener to the observable.
*
* @param l the listener to be added.
*/
public synchronized void addListener(Listener l)
{
listeners.add(l);
@Synchronized
fun addListener(l: Listener) {
listeners.add(l)
}
/**
* Notifies every listener that the model has changed.
* <p>
*
*
* Only models should call this method.
*/
public synchronized void notifyListeners()
{
for (Listener l : listeners) l.onModelChange();
@Synchronized
fun notifyListeners() {
for (l in listeners) l.onModelChange()
}
/**
* Removes the given listener.
* <p>
*
*
* The listener will no longer be notified when the model changes. If the
* given listener is not subscribed to this observable, does nothing.
*
* @param l the listener to be removed
*/
public synchronized void removeListener(Listener l)
{
listeners.remove(l);
@Synchronized
fun removeListener(l: Listener) {
listeners.remove(l)
}
/**
* Interface implemented by objects that want to be notified when the model
* changes.
*/
public interface Listener
{
fun interface Listener {
/**
* Called whenever the model associated to this observable has been
* modified.
*/
void onModelChange();
fun onModelChange()
}
/**
* Creates a new ModelObservable with no listeners.
*/
init {
listeners = LinkedList()
}
}

@ -1,159 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models;
import org.isoron.platform.time.LocalDate;
import org.apache.commons.lang3.builder.*;
import org.isoron.uhabits.core.utils.*;
import java.util.*;
import static java.util.Calendar.*;
public final class Timestamp implements Comparable<Timestamp> {
public static final long DAY_LENGTH = 86400000;
public static final Timestamp ZERO = new Timestamp(0);
private final long unixTime;
public static Timestamp fromLocalDate(LocalDate date) {
return new Timestamp(946684800000L + date.getDaysSince2000() * 86400000L);
}
public Timestamp(long unixTime) {
if (unixTime < 0)
throw new IllegalArgumentException(
"Invalid unix time: " + unixTime);
if (unixTime % DAY_LENGTH != 0)
unixTime = (unixTime / DAY_LENGTH) * DAY_LENGTH;
this.unixTime = unixTime;
}
public Timestamp(GregorianCalendar cal) {
this(cal.getTimeInMillis());
}
public static Timestamp from(int year, int javaMonth, int day) {
GregorianCalendar cal = DateUtils.getStartOfTodayCalendar();
cal.set(year, javaMonth, day, 0, 0, 0);
return new Timestamp(cal.getTimeInMillis());
}
public long getUnixTime() {
return unixTime;
}
public LocalDate toLocalDate() {
long millisSince2000 = unixTime - 946684800000L;
int daysSince2000 = (int) (millisSince2000 / 86400000);
return new LocalDate(daysSince2000);
}
/**
* Returns -1 if this timestamp is older than the given timestamp, 1 if this
* timestamp is newer, or zero if they are equal.
*/
@Override
public int compareTo(Timestamp other) {
return Long.signum(this.unixTime - other.unixTime);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Timestamp timestamp = (Timestamp) o;
return new EqualsBuilder()
.append(unixTime, timestamp.unixTime)
.isEquals();
}
@Override
public int hashCode() {
return new HashCodeBuilder(17, 37).append(unixTime).toHashCode();
}
/**
* Given two timestamps, returns whichever timestamp is the oldest one.
*/
public static Timestamp oldest(Timestamp first, Timestamp second) {
return first.unixTime < second.unixTime ? first : second;
}
public Timestamp minus(int days) {
return plus(-days);
}
public Timestamp plus(int days) {
return new Timestamp(unixTime + DAY_LENGTH * days);
}
/**
* Returns the number of days between this timestamp and the given one. If
* the other timestamp equals this one, returns zero. If the other timestamp
* is older than this one, returns a negative number.
*/
public int daysUntil(Timestamp other) {
return (int) ((other.unixTime - this.unixTime) / DAY_LENGTH);
}
public boolean isNewerThan(Timestamp other) {
return compareTo(other) > 0;
}
public boolean isOlderThan(Timestamp other) {
return compareTo(other) < 0;
}
public Date toJavaDate() {
return new Date(unixTime);
}
public GregorianCalendar toCalendar() {
GregorianCalendar day =
new GregorianCalendar(TimeZone.getTimeZone("GMT"));
day.setTimeInMillis(unixTime);
return day;
}
@Override
public String toString() {
return DateFormats.getCSVDateFormat().format(new Date(unixTime));
}
/**
* Returns an integer corresponding to the day of the week. Saturday maps
* to 0, Sunday maps to 1, and so on.
*/
public int getWeekday() {
return toCalendar().get(DAY_OF_WEEK) % 7;
}
Timestamp truncate(DateUtils.TruncateField field, int firstWeekday) {
return new Timestamp(DateUtils.truncate(field, unixTime, firstWeekday));
}
}

@ -0,0 +1,130 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import org.isoron.platform.time.LocalDate
import org.isoron.uhabits.core.utils.DateFormats.Companion.getCSVDateFormat
import org.isoron.uhabits.core.utils.DateUtils
import org.isoron.uhabits.core.utils.DateUtils.Companion.getStartOfTodayCalendar
import org.isoron.uhabits.core.utils.DateUtils.Companion.truncate
import java.util.Calendar
import java.util.Date
import java.util.GregorianCalendar
import java.util.TimeZone
data class Timestamp(var unixTime: Long) : Comparable<Timestamp> {
constructor(cal: GregorianCalendar) : this(cal.timeInMillis)
fun toLocalDate(): LocalDate {
val millisSince2000 = unixTime - 946684800000L
val daysSince2000 = (millisSince2000 / 86400000).toInt()
return LocalDate(daysSince2000)
}
/**
* Returns -1 if this timestamp is older than the given timestamp, 1 if this
* timestamp is newer, or zero if they are equal.
*/
override fun compareTo(other: Timestamp): Int {
return java.lang.Long.signum(unixTime - other.unixTime)
}
operator fun minus(days: Int): Timestamp {
return plus(-days)
}
operator fun plus(days: Int): Timestamp {
return Timestamp(unixTime + DAY_LENGTH * days)
}
/**
* Returns the number of days between this timestamp and the given one. If
* the other timestamp equals this one, returns zero. If the other timestamp
* is older than this one, returns a negative number.
*/
fun daysUntil(other: Timestamp): Int {
return ((other.unixTime - unixTime) / DAY_LENGTH).toInt()
}
fun isNewerThan(other: Timestamp): Boolean {
return compareTo(other) > 0
}
fun isOlderThan(other: Timestamp): Boolean {
return compareTo(other) < 0
}
fun toJavaDate(): Date {
return Date(unixTime)
}
fun toCalendar(): GregorianCalendar {
val day = GregorianCalendar(TimeZone.getTimeZone("GMT"))
day.timeInMillis = unixTime
return day
}
override fun toString(): String {
return getCSVDateFormat().format(Date(unixTime))
}
/**
* Returns an integer corresponding to the day of the week. Saturday maps
* to 0, Sunday maps to 1, and so on.
*/
val weekday: Int
get() = toCalendar()[Calendar.DAY_OF_WEEK] % 7
fun truncate(field: DateUtils.TruncateField?, firstWeekday: Int): Timestamp {
return Timestamp(
truncate(
field!!,
unixTime,
firstWeekday
)
)
}
companion object {
const val DAY_LENGTH: Long = 86400000
val ZERO = Timestamp(0)
fun fromLocalDate(date: LocalDate): Timestamp {
return Timestamp(946684800000L + date.daysSince2000 * 86400000L)
}
fun from(year: Int, javaMonth: Int, day: Int): Timestamp {
val cal = getStartOfTodayCalendar()
cal[year, javaMonth, day, 0, 0] = 0
return Timestamp(cal.timeInMillis)
}
/**
* Given two timestamps, returns whichever timestamp is the oldest one.
*/
fun oldest(first: Timestamp, second: Timestamp): Timestamp {
return if (first.unixTime < second.unixTime) first else second
}
}
init {
require(unixTime >= 0) { "Invalid unix time: $unixTime" }
if (unixTime % DAY_LENGTH != 0L) unixTime = unixTime / DAY_LENGTH * DAY_LENGTH
}
}

@ -1,294 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models.memory;
import androidx.annotation.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.utils.*;
import java.util.*;
import static org.isoron.uhabits.core.models.HabitList.Order.*;
/**
* In-memory implementation of {@link HabitList}.
*/
public class MemoryHabitList extends HabitList
{
@NonNull
private LinkedList<Habit> list = new LinkedList<>();
@NonNull
private Order primaryOrder = Order.BY_POSITION;
@NonNull
private Order secondaryOrder = Order.BY_NAME_ASC;
private Comparator<Habit> comparator = getComposedComparatorByOrder(primaryOrder, secondaryOrder);
@Nullable
private MemoryHabitList parent = null;
public MemoryHabitList()
{
super();
}
protected MemoryHabitList(@NonNull HabitMatcher matcher,
Comparator<Habit> comparator,
@NonNull MemoryHabitList parent)
{
super(matcher);
this.parent = parent;
this.comparator = comparator;
this.primaryOrder = parent.primaryOrder;
this.secondaryOrder = parent.secondaryOrder;
parent.getObservable().addListener(this::loadFromParent);
loadFromParent();
}
@Override
public synchronized void add(@NonNull Habit habit)
throws IllegalArgumentException
{
throwIfHasParent();
if (list.contains(habit))
throw new IllegalArgumentException("habit already added");
Long id = habit.getId();
if (id != null && getById(id) != null)
throw new RuntimeException("duplicate id");
if (id == null) habit.setId((long) list.size());
list.addLast(habit);
resort();
}
@Override
public synchronized Habit getById(long id)
{
for (Habit h : list)
{
if (h.getId() == null) throw new IllegalStateException();
if (h.getId() == id) return h;
}
return null;
}
@Override
public synchronized Habit getByUUID(String uuid)
{
for (Habit h : list) if (h.getUuid().equals(uuid)) return h;
return null;
}
@NonNull
@Override
public synchronized Habit getByPosition(int position)
{
return list.get(position);
}
@NonNull
@Override
public synchronized HabitList getFiltered(HabitMatcher matcher)
{
return new MemoryHabitList(matcher, comparator, this);
}
@Override
public synchronized Order getPrimaryOrder()
{
return primaryOrder;
}
@Override
public synchronized Order getSecondaryOrder()
{
return secondaryOrder;
}
@Override
public synchronized void setPrimaryOrder(@NonNull Order order)
{
this.primaryOrder = order;
this.comparator = getComposedComparatorByOrder(this.primaryOrder, this.secondaryOrder);
resort();
}
@Override
public void setSecondaryOrder(@NonNull Order order)
{
this.secondaryOrder = order;
this.comparator = getComposedComparatorByOrder(this.primaryOrder, this.secondaryOrder);
resort();
}
private Comparator<Habit> getComposedComparatorByOrder(Order firstOrder, Order secondOrder)
{
return (h1, h2) -> {
int firstResult = getComparatorByOrder(firstOrder).compare(h1, h2);
if (firstResult != 0 || secondOrder == null) {
return firstResult;
}
return getComparatorByOrder(secondOrder).compare(h1, h2);
};
}
private Comparator<Habit> getComparatorByOrder(Order order) {
Comparator<Habit> nameComparatorAsc = (h1, h2) ->
h1.getName().compareTo(h2.getName());
Comparator<Habit> nameComparatorDesc = (h1, h2) ->
nameComparatorAsc.compare(h2, h1);
Comparator<Habit> colorComparatorAsc = (h1, h2) ->
h1.getColor().compareTo(h2.getColor());
Comparator<Habit> colorComparatorDesc = (h1, h2) ->
colorComparatorAsc.compare(h2, h1);
Comparator<Habit> scoreComparatorDesc = (h1, h2) ->
{
Timestamp today = DateUtils.getTodayWithOffset();
return Double.compare(
h1.getScores().get(today).getValue(),
h2.getScores().get(today).getValue());
};
Comparator<Habit> scoreComparatorAsc = (h1, h2) ->
scoreComparatorDesc.compare(h2, h1);
Comparator<Habit> positionComparator = (h1, h2) ->
Integer.compare(h1.getPosition(), h2.getPosition());
Comparator<Habit> statusComparatorDesc = (h1, h2) ->
{
if (h1.isCompletedToday() != h2.isCompletedToday()) {
return h1.isCompletedToday() ? -1 : 1;
}
if (h1.isNumerical() != h2.isNumerical()) {
return h1.isNumerical() ? -1 : 1;
}
Timestamp today = DateUtils.getTodayWithOffset();
Integer v1 = h1.getComputedEntries().get(today).getValue();
Integer v2 = h2.getComputedEntries().get(today).getValue();
return v2.compareTo(v1);
};
Comparator<Habit> statusComparatorAsc = (h1, h2) -> statusComparatorDesc.compare(h2, h1);
if (order == BY_POSITION) return positionComparator;
if (order == BY_NAME_ASC) return nameComparatorAsc;
if (order == BY_NAME_DESC) return nameComparatorDesc;
if (order == BY_COLOR_ASC) return colorComparatorAsc;
if (order == BY_COLOR_DESC) return colorComparatorDesc;
if (order == BY_SCORE_DESC) return scoreComparatorDesc;
if (order == BY_SCORE_ASC) return scoreComparatorAsc;
if (order == BY_STATUS_DESC) return statusComparatorDesc;
if (order == BY_STATUS_ASC) return statusComparatorAsc;
throw new IllegalStateException();
}
@Override
public synchronized int indexOf(@NonNull Habit h)
{
return list.indexOf(h);
}
@NonNull
@Override
public synchronized Iterator<Habit> iterator()
{
return new ArrayList<>(list).iterator();
}
@Override
public synchronized void remove(@NonNull Habit habit)
{
throwIfHasParent();
list.remove(habit);
getObservable().notifyListeners();
}
@Override
public synchronized void reorder(@NonNull Habit from, @NonNull Habit to)
{
throwIfHasParent();
if (primaryOrder != BY_POSITION) throw new IllegalStateException(
"cannot reorder automatically sorted list");
if (indexOf(from) < 0) throw new IllegalArgumentException(
"list does not contain (from) habit");
int toPos = indexOf(to);
if (toPos < 0) throw new IllegalArgumentException(
"list does not contain (to) habit");
list.remove(from);
list.add(toPos, from);
int position = 0;
for(Habit h : list)
h.setPosition(position++);
getObservable().notifyListeners();
}
@Override
public synchronized int size()
{
return list.size();
}
@Override
public synchronized void update(List<Habit> habits)
{
resort();
}
private void throwIfHasParent()
{
if (parent != null) throw new IllegalStateException(
"Filtered lists cannot be modified directly. " +
"You should modify the parent list instead.");
}
private synchronized void loadFromParent()
{
if (parent == null) throw new IllegalStateException();
list.clear();
for (Habit h : parent) if (filter.matches(h)) list.add(h);
resort();
}
public synchronized void resort()
{
if (comparator != null) Collections.sort(list, comparator);
getObservable().notifyListeners();
}
}

@ -0,0 +1,229 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models.memory
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.utils.DateUtils.Companion.getTodayWithOffset
import java.util.ArrayList
import java.util.Comparator
import java.util.LinkedList
import java.util.Objects
/**
* In-memory implementation of [HabitList].
*/
class MemoryHabitList : HabitList {
private val list = LinkedList<Habit>()
@get:Synchronized
override var primaryOrder = Order.BY_POSITION
set(value) {
field = value
comparator = getComposedComparatorByOrder(primaryOrder, secondaryOrder)
resort()
}
@get:Synchronized
override var secondaryOrder = Order.BY_NAME_ASC
set(value) {
field = value
comparator = getComposedComparatorByOrder(primaryOrder, secondaryOrder)
resort()
}
private var comparator: Comparator<Habit>? =
getComposedComparatorByOrder(primaryOrder, secondaryOrder)
private var parent: MemoryHabitList? = null
constructor() : super()
constructor(
matcher: HabitMatcher,
comparator: Comparator<Habit>?,
parent: MemoryHabitList
) : super(matcher) {
this.parent = parent
this.comparator = comparator
primaryOrder = parent.primaryOrder
secondaryOrder = parent.secondaryOrder
parent.observable.addListener { loadFromParent() }
loadFromParent()
}
@Synchronized
@Throws(IllegalArgumentException::class)
override fun add(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.addLast(habit)
resort()
}
@Synchronized
override fun getById(id: Long): Habit? {
for (h in list) {
checkNotNull(h.id)
if (h.id == id) return h
}
return null
}
@Synchronized
override fun getByUUID(uuid: String?): Habit? {
for (h in list) if (Objects.requireNonNull(h.uuid) == uuid) return h
return null
}
@Synchronized
override fun getByPosition(position: Int): Habit {
return list[position]
}
@Synchronized
override fun getFiltered(matcher: HabitMatcher?): HabitList {
return MemoryHabitList(matcher!!, comparator, this)
}
private fun getComposedComparatorByOrder(
firstOrder: Order,
secondOrder: Order?
): Comparator<Habit> {
return Comparator { h1: Habit, h2: Habit ->
val firstResult = getComparatorByOrder(firstOrder).compare(h1, h2)
if (firstResult != 0 || secondOrder == null) {
return@Comparator firstResult
}
getComparatorByOrder(secondOrder).compare(h1, h2)
}
}
private fun getComparatorByOrder(order: Order): Comparator<Habit> {
val nameComparatorAsc = Comparator<Habit> { habit1, habit2 ->
habit1.name.compareTo(habit2.name)
}
val nameComparatorDesc =
Comparator { h1: Habit, h2: Habit -> nameComparatorAsc.compare(h2, h1) }
val colorComparatorAsc = Comparator<Habit> { (color1), (color2) ->
color1.compareTo(color2)
}
val colorComparatorDesc =
Comparator { h1: Habit, h2: Habit -> colorComparatorAsc.compare(h2, h1) }
val scoreComparatorDesc =
Comparator<Habit> { habit1, habit2 ->
val today = getTodayWithOffset()
habit1.scores[today].value.compareTo(habit2.scores[today].value)
}
val scoreComparatorAsc =
Comparator { h1: Habit, h2: Habit -> scoreComparatorDesc.compare(h2, h1) }
val positionComparator =
Comparator<Habit> { habit1, habit2 -> habit1.position.compareTo(habit2.position) }
val statusComparatorDesc = Comparator { h1: Habit, h2: Habit ->
if (h1.isCompletedToday() != h2.isCompletedToday()) {
return@Comparator if (h1.isCompletedToday()) -1 else 1
}
if (h1.isNumerical != h2.isNumerical) {
return@Comparator if (h1.isNumerical) -1 else 1
}
val today = getTodayWithOffset()
val v1 = h1.computedEntries.get(today).value
val v2 = h2.computedEntries.get(today).value
v2.compareTo(v1)
}
val statusComparatorAsc =
Comparator { h1: Habit, h2: Habit -> statusComparatorDesc.compare(h2, h1) }
return when {
order === Order.BY_POSITION -> positionComparator
order === Order.BY_NAME_ASC -> nameComparatorAsc
order === Order.BY_NAME_DESC -> nameComparatorDesc
order === Order.BY_COLOR_ASC -> colorComparatorAsc
order === Order.BY_COLOR_DESC -> colorComparatorDesc
order === Order.BY_SCORE_DESC -> scoreComparatorDesc
order === Order.BY_SCORE_ASC -> scoreComparatorAsc
order === Order.BY_STATUS_DESC -> statusComparatorDesc
order === Order.BY_STATUS_ASC -> statusComparatorAsc
else -> throw IllegalStateException()
}
}
@Synchronized
override fun indexOf(h: Habit): Int {
return list.indexOf(h)
}
@Synchronized
override fun iterator(): Iterator<Habit> {
return ArrayList(list).iterator()
}
@Synchronized
override fun remove(h: Habit) {
throwIfHasParent()
list.remove(h)
observable.notifyListeners()
}
@Synchronized
override fun reorder(from: Habit, to: Habit) {
throwIfHasParent()
check(!(primaryOrder !== Order.BY_POSITION)) { "cannot reorder automatically sorted list" }
require(indexOf(from) >= 0) { "list does not contain (from) habit" }
val toPos = indexOf(to)
require(toPos >= 0) { "list does not contain (to) habit" }
list.remove(from)
list.add(toPos, from)
var position = 0
for (h in list) h.position = position++
observable.notifyListeners()
}
@Synchronized
override fun size(): Int {
return list.size
}
@Synchronized
override fun update(habits: List<Habit>) {
resort()
}
private fun throwIfHasParent() {
check(parent == null) {
"Filtered lists cannot be modified directly. " +
"You should modify the parent list instead."
}
}
@Synchronized
private fun loadFromParent() {
checkNotNull(parent)
list.clear()
for (h in parent!!) if (filter.matches(h)) list.add(h)
resort()
}
@Synchronized
override fun resort() {
if (comparator != null) list.sortWith(comparator!!)
observable.notifyListeners()
}
}

@ -1,294 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models.sqlite;
import androidx.annotation.*;
import org.isoron.uhabits.core.database.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.models.memory.*;
import org.isoron.uhabits.core.models.sqlite.records.*;
import java.util.*;
import javax.inject.*;
/**
* Implementation of a {@link HabitList} that is backed by SQLite.
*/
public class SQLiteHabitList extends HabitList
{
@NonNull
private final Repository<HabitRecord> repository;
@NonNull
private final ModelFactory modelFactory;
@NonNull
private final MemoryHabitList list;
private boolean loaded = false;
@Inject
public SQLiteHabitList(@NonNull ModelFactory modelFactory)
{
super();
this.modelFactory = modelFactory;
this.list = new MemoryHabitList();
this.repository = modelFactory.buildHabitListRepository();
}
private void loadRecords()
{
if (loaded) return;
loaded = true;
list.removeAll();
List<HabitRecord> records = repository.findAll("order by position");
int expectedPosition = 0;
boolean shouldRebuildOrder = false;
for (HabitRecord rec : records)
{
if (rec.position != expectedPosition) shouldRebuildOrder = true;
expectedPosition++;
Habit h = modelFactory.buildHabit();
rec.copyTo(h);
((SQLiteEntryList) h.getOriginalEntries()).setHabitId(h.getId());
list.add(h);
}
if(shouldRebuildOrder) rebuildOrder();
}
@Override
public synchronized void add(@NonNull Habit habit)
{
loadRecords();
habit.setPosition(size());
HabitRecord record = new HabitRecord();
record.copyFrom(habit);
repository.save(record);
habit.setId(record.id);
((SQLiteEntryList) habit.getOriginalEntries()).setHabitId(record.id);
list.add(habit);
getObservable().notifyListeners();
}
@Override
@Nullable
public synchronized Habit getById(long id)
{
loadRecords();
return list.getById(id);
}
@Override
@Nullable
public synchronized Habit getByUUID(String uuid)
{
loadRecords();
return list.getByUUID(uuid);
}
@Override
@NonNull
public synchronized Habit getByPosition(int position)
{
loadRecords();
return list.getByPosition(position);
}
@NonNull
@Override
public synchronized HabitList getFiltered(HabitMatcher filter)
{
loadRecords();
return list.getFiltered(filter);
}
@Override
@NonNull
public Order getPrimaryOrder()
{
return list.getPrimaryOrder();
}
@Override
public Order getSecondaryOrder()
{
return list.getSecondaryOrder();
}
@Override
public synchronized void setPrimaryOrder(@NonNull Order order)
{
list.setPrimaryOrder(order);
getObservable().notifyListeners();
}
@Override
public synchronized void setSecondaryOrder(@NonNull Order order)
{
list.setSecondaryOrder(order);
getObservable().notifyListeners();
}
@Override
public synchronized int indexOf(@NonNull Habit h)
{
loadRecords();
return list.indexOf(h);
}
@Override
public synchronized Iterator<Habit> iterator()
{
loadRecords();
return list.iterator();
}
private synchronized void rebuildOrder()
{
List<HabitRecord> records = repository.findAll("order by position");
repository.executeAsTransaction(() ->
{
int pos = 0;
for (HabitRecord r : records)
{
if (r.position != pos)
{
r.position = pos;
repository.save(r);
}
pos++;
}
});
}
@Override
public synchronized void remove(@NonNull Habit habit)
{
loadRecords();
reorder(habit, list.getByPosition(size() - 1));
list.remove(habit);
HabitRecord record = repository.find(habit.getId());
if (record == null) throw new RuntimeException("habit not in database");
repository.executeAsTransaction(() ->
{
habit.getOriginalEntries().clear();
repository.remove(record);
});
getObservable().notifyListeners();
}
@Override
public synchronized void removeAll()
{
list.removeAll();
repository.execSQL("delete from habits");
repository.execSQL("delete from repetitions");
getObservable().notifyListeners();
}
@Override
public synchronized void reorder(@NonNull Habit from, @NonNull Habit to)
{
loadRecords();
list.reorder(from, to);
HabitRecord fromRecord = repository.find(from.getId());
HabitRecord toRecord = repository.find(to.getId());
if (fromRecord == null)
throw new RuntimeException("habit not in database");
if (toRecord == null)
throw new RuntimeException("habit not in database");
if (toRecord.position < fromRecord.position)
{
repository.execSQL("update habits set position = position + 1 " +
"where position >= ? and position < ?",
toRecord.position, fromRecord.position);
}
else
{
repository.execSQL("update habits set position = position - 1 " +
"where position > ? and position <= ?",
fromRecord.position, toRecord.position);
}
fromRecord.position = toRecord.position;
repository.save(fromRecord);
getObservable().notifyListeners();
}
@Override
public synchronized void repair()
{
loadRecords();
rebuildOrder();
getObservable().notifyListeners();
}
@Override
public synchronized int size()
{
loadRecords();
return list.size();
}
@Override
public synchronized void update(List<Habit> habits)
{
loadRecords();
list.update(habits);
for (Habit h : habits)
{
HabitRecord record = repository.find(h.getId());
if (record == null) continue;
record.copyFrom(h);
repository.save(record);
}
getObservable().notifyListeners();
}
@Override
public void resort()
{
list.resort();
getObservable().notifyListeners();
}
public synchronized void reload()
{
loaded = false;
}
}

@ -0,0 +1,220 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models.sqlite
import org.isoron.uhabits.core.database.Repository
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.memory.MemoryHabitList
import org.isoron.uhabits.core.models.sqlite.records.HabitRecord
import javax.inject.Inject
/**
* Implementation of a [HabitList] that is backed by SQLite.
*/
class SQLiteHabitList @Inject constructor(private val modelFactory: ModelFactory) : HabitList() {
private val repository: Repository<HabitRecord> = modelFactory.buildHabitListRepository()
private val list: MemoryHabitList = MemoryHabitList()
private var loaded = false
private fun loadRecords() {
if (loaded) return
loaded = true
list.removeAll()
val records = repository.findAll("order by position")
var shouldRebuildOrder = false
for ((expectedPosition, rec) in records.withIndex()) {
if (rec.position != expectedPosition) shouldRebuildOrder = true
val h = modelFactory.buildHabit()
rec.copyTo(h)
(h.originalEntries as SQLiteEntryList).habitId = h.id
list.add(h)
}
if (shouldRebuildOrder) rebuildOrder()
}
@Synchronized
override fun add(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(habit)
observable.notifyListeners()
}
@Synchronized
override fun getById(id: Long): Habit? {
loadRecords()
return list.getById(id)
}
@Synchronized
override fun getByUUID(uuid: String?): Habit? {
loadRecords()
return list.getByUUID(uuid)
}
@Synchronized
override fun getByPosition(position: Int): Habit {
loadRecords()
return list.getByPosition(position)
}
@Synchronized
override fun getFiltered(matcher: HabitMatcher?): HabitList {
loadRecords()
return list.getFiltered(matcher)
}
@set:Synchronized
override var primaryOrder: Order
get() = list.primaryOrder
set(order) {
list.primaryOrder = order
observable.notifyListeners()
}
@set:Synchronized
override var secondaryOrder: Order
get() = list.secondaryOrder
set(order) {
list.secondaryOrder = order
observable.notifyListeners()
}
@Synchronized
override fun indexOf(h: Habit): Int {
loadRecords()
return list.indexOf(h)
}
@Synchronized
override fun iterator(): Iterator<Habit> {
loadRecords()
return list.iterator()
}
@Synchronized
private fun rebuildOrder() {
val records = repository.findAll("order by position")
repository.executeAsTransaction {
for ((pos, r) in records.withIndex()) {
if (r.position != pos) {
r.position = pos
repository.save(r)
}
}
}
}
@Synchronized
override fun remove(h: Habit) {
loadRecords()
reorder(h, list.getByPosition(size() - 1))
list.remove(h)
val record = repository.find(
h.id!!
) ?: throw RuntimeException("habit not in database")
repository.executeAsTransaction {
h.originalEntries.clear()
repository.remove(record)
}
observable.notifyListeners()
}
@Synchronized
override fun removeAll() {
list.removeAll()
repository.execSQL("delete from habits")
repository.execSQL("delete from repetitions")
observable.notifyListeners()
}
@Synchronized
override fun reorder(from: Habit, to: Habit) {
loadRecords()
list.reorder(from, to)
val fromRecord = repository.find(
from.id!!
)
val toRecord = repository.find(
to.id!!
)
if (fromRecord == null) throw RuntimeException("habit not in database")
if (toRecord == null) throw RuntimeException("habit not in database")
if (toRecord.position!! < fromRecord.position!!) {
repository.execSQL(
"update habits set position = position + 1 " +
"where position >= ? and position < ?",
toRecord.position!!,
fromRecord.position!!
)
} else {
repository.execSQL(
"update habits set position = position - 1 " +
"where position > ? and position <= ?",
fromRecord.position!!,
toRecord.position!!
)
}
fromRecord.position = toRecord.position
repository.save(fromRecord)
observable.notifyListeners()
}
@Synchronized
override fun repair() {
loadRecords()
rebuildOrder()
observable.notifyListeners()
}
@Synchronized
override fun size(): Int {
loadRecords()
return list.size()
}
@Synchronized
override fun update(habits: List<Habit>) {
loadRecords()
list.update(habits)
for (h in habits) {
val record = repository.find(h.id!!) ?: continue
record.copyFrom(h)
repository.save(record)
}
observable.notifyListeners()
}
override fun resort() {
list.resort()
observable.notifyListeners()
}
@Synchronized
fun reload() {
loaded = false
}
}

@ -1,23 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* Provides SQLite implementations of the core models.
*/
package org.isoron.uhabits.core.models.sqlite;

@ -16,40 +16,37 @@
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models.sqlite.records
package org.isoron.uhabits.core.models.sqlite.records;
import org.isoron.uhabits.core.database.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.database.Column
import org.isoron.uhabits.core.database.Table
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Timestamp
/**
* The SQLite database record corresponding to a {@link Entry}.
* The SQLite database record corresponding to a [Entry].
*/
@Table(name = "Repetitions")
public class EntryRecord
{
public HabitRecord habit;
@Column(name = "habit")
public Long habitId;
class EntryRecord {
var habit: HabitRecord? = null
@Column
public Long timestamp;
@field:Column(name = "habit")
var habitId: Long? = null
@Column
public Integer value;
@field:Column
var timestamp: Long? = null
@Column
public Long id;
@field:Column
var value: Int? = null
public void copyFrom(Entry entry)
{
timestamp = entry.getTimestamp().getUnixTime();
value = entry.getValue();
@field:Column
var id: Long? = null
fun copyFrom(entry: Entry) {
timestamp = entry.timestamp.unixTime
value = entry.value
}
public Entry toEntry()
{
return new Entry(new Timestamp(timestamp), value);
fun toEntry(): Entry {
return Entry(Timestamp(timestamp!!), value!!)
}
}

@ -1,139 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models.sqlite.records;
import org.isoron.uhabits.core.database.*;
import org.isoron.uhabits.core.models.*;
/**
* The SQLite database record corresponding to a {@link Habit}.
*/
@Table(name = "habits")
public class HabitRecord
{
@Column
public String description;
@Column
public String question;
@Column
public String name;
@Column(name = "freq_num")
public Integer freqNum;
@Column(name = "freq_den")
public Integer freqDen;
@Column
public Integer color;
@Column
public Integer position;
@Column(name = "reminder_hour")
public Integer reminderHour;
@Column(name = "reminder_min")
public Integer reminderMin;
@Column(name = "reminder_days")
public Integer reminderDays;
@Column
public Integer highlight;
@Column
public Integer archived;
@Column
public Integer type;
@Column(name = "target_value")
public Double targetValue;
@Column(name = "target_type")
public Integer targetType;
@Column
public String unit;
@Column
public Long id;
@Column
public String uuid;
public void copyFrom(Habit model)
{
this.id = model.getId();
this.name = model.getName();
this.description = model.getDescription();
this.highlight = 0;
this.color = model.getColor().getPaletteIndex();
this.archived = model.isArchived() ? 1 : 0;
this.type = model.getType();
this.targetType = model.getTargetType();
this.targetValue = model.getTargetValue();
this.unit = model.getUnit();
this.position = model.getPosition();
this.question = model.getQuestion();
this.uuid = model.getUuid();
Frequency freq = model.getFrequency();
this.freqNum = freq.getNumerator();
this.freqDen = freq.getDenominator();
this.reminderDays = 0;
this.reminderMin = null;
this.reminderHour = null;
if (model.hasReminder())
{
Reminder reminder = model.getReminder();
this.reminderHour = reminder.getHour();
this.reminderMin = reminder.getMinute();
this.reminderDays = reminder.getDays().toInteger();
}
}
public void copyTo(Habit habit)
{
habit.setId(this.id);
habit.setName(this.name);
habit.setDescription(this.description);
habit.setQuestion(this.question);
habit.setFrequency(new Frequency(this.freqNum, this.freqDen));
habit.setColor(new PaletteColor(this.color));
habit.setArchived(this.archived != 0);
habit.setType(this.type);
habit.setTargetType(this.targetType);
habit.setTargetValue(this.targetValue);
habit.setUnit(this.unit);
habit.setPosition(this.position);
habit.setUuid(this.uuid);
if (reminderHour != null && reminderMin != null)
{
habit.setReminder(new Reminder(reminderHour, reminderMin,
new WeekdayList(reminderDays)));
}
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save