diff --git a/.gitignore b/.gitignore index c2db49228..b5365d8c1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ build/ *.iml art/ +*.actual.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 89efd1ab9..fe40f351e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +### 1.4.0 (April 4, 2016) + +* Ability to import data from third-party apps +* Ability to save and restore full database backup +* Show more information on streak chart +* Simplify interface for creating habits +* Add link to Frequently Asked Questions (FAQ) +* Reduce app loading time and lag on widgets +* Generate bug reports on crash and from settings screen +* Disable vibration according to phone settings +* Add Czech translation +* Fix wrong month names for some languages + ### 1.3.3 (March 20, 2016) * Add Spanish and Korean translations diff --git a/NOTICE.md b/NOTICE.md index 12368d1e6..cea565a0b 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -89,3 +89,22 @@ Material design icons are the official icon set from Google that are designed under the material design guidelines. Available under the Creative Common Attribution 4.0 International License (CC-BY 4.0). +### Android Flow Layout + + + +Extended linear layout that wrap its content when there is no place in the current line. + + Copyright 2011, Artem Votincev (apmem.org) + + Licensed under the Apache License, Version 2.0 (the "License"); you may not + use this file except in compliance with the License. You may obtain a copy + of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + License for the specific language governing permissions and limitations + under the License. \ No newline at end of file diff --git a/README.md b/README.md index e3c7796c8..46a9cc93b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ # Loop Habit Tracker + + + + + Coverage via Codecov + Loop is a simple Android app that helps you create and maintain good habits, allowing you to achieve your long-term goals. Detailed graphs and statistics show you how your habits improved over time. It is completely ad-free and open source. +

Get it on Google Play Git if on F-Droid +

## Features @@ -65,9 +73,14 @@ contribute, even if you are not a software developer. to improve it. You can either use the link inside the app, or open an issue at GitHub. +* **Spread the word.** If you like the app, share it with your family, friends + and colleagues. You can also rate and review the app on Google Play Store, to help + other users find it more easily. + * **Translate the app into your own language.** If you are not a native English speaker, and would like to see the app translated into your own language, - please join our [open translation project at POEditor][poedit]. + please join our [open translation project at POEditor][poedit]. If the translation + is already completed, you are also very welcome to join and proofread it. * **Write some code.** If you are an Android developer, you are very welcome to contribute with code. Please, see the [developer guidelines][dev-guide] for more details. diff --git a/app/build.gradle b/app/build.gradle index a1a1bb4bf..d98191ed1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,14 +2,18 @@ apply plugin: 'com.android.application' android { compileSdkVersion 23 - buildToolsVersion "21.1.2" + buildToolsVersion "23.0.1" defaultConfig { applicationId "org.isoron.uhabits" minSdkVersion 15 targetSdkVersion 23 + buildConfigField "Integer", "databaseVersion", "13" + buildConfigField "String", "databaseFilename", "\"uhabits.db\"" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + //testInstrumentationRunnerArgument "size", "small" } buildTypes { @@ -30,6 +34,8 @@ android { dependencies { compile 'com.android.support:support-v4:23.1.1' compile 'com.github.paolorotolo:appintro:3.4.0' + compile 'org.apmem.tools:layouts:1.10@aar' + compile 'com.opencsv:opencsv:3.7' compile project(':libs:drag-sort-listview:library') compile files('libs/ActiveAndroid.jar') @@ -40,3 +46,13 @@ dependencies { androidTestCompile 'com.android.support.test.espresso:espresso-intents:2.2.1' } + +task grantAnimationPermission(type: Exec, dependsOn: 'installDebug') { + commandLine "adb shell pm grant org.isoron.uhabits android.permission.SET_ANIMATION_SCALE".split(' ') +} + +tasks.whenTaskAdded { task -> + if (task.name.startsWith('connected')) { + task.dependsOn grantAnimationPermission + } +} \ No newline at end of file diff --git a/app/src/androidTest/assets/habitbull.csv b/app/src/androidTest/assets/habitbull.csv new file mode 100644 index 000000000..977a8e8df --- /dev/null +++ b/app/src/androidTest/assets/habitbull.csv @@ -0,0 +1,19 @@ +HabitName,HabitDescription,HabitCategory,CalendarDate,Value,CommentText +Breed dragons,with love and fire,Diet & Food,2016-03-18,1, +Breed dragons,with love and fire,Diet & Food,2016-03-19,1, +Breed dragons,with love and fire,Diet & Food,2016-03-21,1, +Reduce sleep,only 2 hours per day,Time Management,2016-03-15,1, +Reduce sleep,only 2 hours per day,Time Management,2016-03-16,1, +Reduce sleep,only 2 hours per day,Time Management,2016-03-17,1, +Reduce sleep,only 2 hours per day,Time Management,2016-03-21,1, +No-arms pushup,Become like water my friend!,Fitness,2016-03-15,1, +No-arms pushup,Become like water my friend!,Fitness,2016-03-16,1, +No-arms pushup,Become like water my friend!,Fitness,2016-03-18,1, +No-arms pushup,Become like water my friend!,Fitness,2016-03-21,1, +No-arms pushup,Become like water my friend!,Fitness,2016-03-15,1, +No-arms pushup,Become like water my friend!,Fitness,2016-03-16,1, +No-arms pushup,Become like water my friend!,Fitness,2016-03-18,1, +No-arms pushup,Become like water my friend!,Fitness,2016-03-21,1, +Grow spiritually,"transcend ego, practice compassion, smile and breath",Meditation,2016-03-15,1, +Grow spiritually,"transcend ego, practice compassion, smile and breath",Meditation,2016-03-17,1, +Grow spiritually,"transcend ego, practice compassion, smile and breath",Meditation,2016-03-21,1, diff --git a/app/src/androidTest/assets/icon.png b/app/src/androidTest/assets/icon.png new file mode 100644 index 000000000..7907954db Binary files /dev/null and b/app/src/androidTest/assets/icon.png differ diff --git a/app/src/androidTest/assets/loop.db b/app/src/androidTest/assets/loop.db new file mode 100644 index 000000000..25f0f32cd Binary files /dev/null and b/app/src/androidTest/assets/loop.db differ diff --git a/app/src/androidTest/assets/pull_failed b/app/src/androidTest/assets/pull_failed new file mode 100755 index 000000000..8a3238df7 --- /dev/null +++ b/app/src/androidTest/assets/pull_failed @@ -0,0 +1,5 @@ +#!/bin/bash +P=/sdcard/Android/data/org.isoron.uhabits/cache/Failed/ + +adb pull $P Failed/ +adb shell rm -r $P diff --git a/app/src/androidTest/assets/rewire.db b/app/src/androidTest/assets/rewire.db new file mode 100644 index 000000000..21f55032f Binary files /dev/null and b/app/src/androidTest/assets/rewire.db differ diff --git a/app/src/androidTest/assets/tickmate.db b/app/src/androidTest/assets/tickmate.db new file mode 100644 index 000000000..a4bec769c Binary files /dev/null and b/app/src/androidTest/assets/tickmate.db differ diff --git a/app/src/androidTest/assets/views-v21/CheckmarkView/checked.png b/app/src/androidTest/assets/views-v21/CheckmarkView/checked.png new file mode 100644 index 000000000..b54e5707d Binary files /dev/null and b/app/src/androidTest/assets/views-v21/CheckmarkView/checked.png differ diff --git a/app/src/androidTest/assets/views-v21/CheckmarkView/implicitly_checked.png b/app/src/androidTest/assets/views-v21/CheckmarkView/implicitly_checked.png new file mode 100644 index 000000000..39d8e87f3 Binary files /dev/null and b/app/src/androidTest/assets/views-v21/CheckmarkView/implicitly_checked.png differ diff --git a/app/src/androidTest/assets/views-v21/CheckmarkView/large_size.png b/app/src/androidTest/assets/views-v21/CheckmarkView/large_size.png new file mode 100644 index 000000000..99e439e72 Binary files /dev/null and b/app/src/androidTest/assets/views-v21/CheckmarkView/large_size.png differ diff --git a/app/src/androidTest/assets/views-v21/CheckmarkView/unchecked.png b/app/src/androidTest/assets/views-v21/CheckmarkView/unchecked.png new file mode 100644 index 000000000..2dc0f531a Binary files /dev/null and b/app/src/androidTest/assets/views-v21/CheckmarkView/unchecked.png differ diff --git a/app/src/androidTest/assets/views/CheckmarkView/checked.png b/app/src/androidTest/assets/views/CheckmarkView/checked.png new file mode 100644 index 000000000..7884c804f Binary files /dev/null and b/app/src/androidTest/assets/views/CheckmarkView/checked.png differ diff --git a/app/src/androidTest/assets/views/CheckmarkView/implicitly_checked.png b/app/src/androidTest/assets/views/CheckmarkView/implicitly_checked.png new file mode 100644 index 000000000..3096be180 Binary files /dev/null and b/app/src/androidTest/assets/views/CheckmarkView/implicitly_checked.png differ diff --git a/app/src/androidTest/assets/views/CheckmarkView/large_size.png b/app/src/androidTest/assets/views/CheckmarkView/large_size.png new file mode 100644 index 000000000..79152fb18 Binary files /dev/null and b/app/src/androidTest/assets/views/CheckmarkView/large_size.png differ diff --git a/app/src/androidTest/assets/views/CheckmarkView/unchecked.png b/app/src/androidTest/assets/views/CheckmarkView/unchecked.png new file mode 100644 index 000000000..b0d90c5c0 Binary files /dev/null and b/app/src/androidTest/assets/views/CheckmarkView/unchecked.png differ diff --git a/app/src/androidTest/assets/views/HabitFrequencyView/render.png b/app/src/androidTest/assets/views/HabitFrequencyView/render.png new file mode 100644 index 000000000..28c46f5a2 Binary files /dev/null and b/app/src/androidTest/assets/views/HabitFrequencyView/render.png differ diff --git a/app/src/androidTest/assets/views/HabitFrequencyView/renderDataOffset.png b/app/src/androidTest/assets/views/HabitFrequencyView/renderDataOffset.png new file mode 100644 index 000000000..1125e478a Binary files /dev/null and b/app/src/androidTest/assets/views/HabitFrequencyView/renderDataOffset.png differ diff --git a/app/src/androidTest/assets/views/HabitFrequencyView/renderDifferentSize.png b/app/src/androidTest/assets/views/HabitFrequencyView/renderDifferentSize.png new file mode 100644 index 000000000..cb5347b1c Binary files /dev/null and b/app/src/androidTest/assets/views/HabitFrequencyView/renderDifferentSize.png differ diff --git a/app/src/androidTest/assets/views/HabitFrequencyView/renderTransparent.png b/app/src/androidTest/assets/views/HabitFrequencyView/renderTransparent.png new file mode 100644 index 000000000..a0437b31f Binary files /dev/null and b/app/src/androidTest/assets/views/HabitFrequencyView/renderTransparent.png differ diff --git a/app/src/androidTest/assets/views/HabitHistoryView/render.png b/app/src/androidTest/assets/views/HabitHistoryView/render.png new file mode 100644 index 000000000..fce176813 Binary files /dev/null and b/app/src/androidTest/assets/views/HabitHistoryView/render.png differ diff --git a/app/src/androidTest/assets/views/HabitHistoryView/renderDataOffset.png b/app/src/androidTest/assets/views/HabitHistoryView/renderDataOffset.png new file mode 100644 index 000000000..679ad00d4 Binary files /dev/null and b/app/src/androidTest/assets/views/HabitHistoryView/renderDataOffset.png differ diff --git a/app/src/androidTest/assets/views/HabitHistoryView/renderDifferentSize.png b/app/src/androidTest/assets/views/HabitHistoryView/renderDifferentSize.png new file mode 100644 index 000000000..be64fb851 Binary files /dev/null and b/app/src/androidTest/assets/views/HabitHistoryView/renderDifferentSize.png differ diff --git a/app/src/androidTest/assets/views/HabitHistoryView/renderTransparent.png b/app/src/androidTest/assets/views/HabitHistoryView/renderTransparent.png new file mode 100644 index 000000000..d52288da7 Binary files /dev/null and b/app/src/androidTest/assets/views/HabitHistoryView/renderTransparent.png differ diff --git a/app/src/androidTest/assets/views/HabitScoreView/render.png b/app/src/androidTest/assets/views/HabitScoreView/render.png new file mode 100644 index 000000000..f5c98549c Binary files /dev/null and b/app/src/androidTest/assets/views/HabitScoreView/render.png differ diff --git a/app/src/androidTest/assets/views/HabitScoreView/renderDataOffset.png b/app/src/androidTest/assets/views/HabitScoreView/renderDataOffset.png new file mode 100644 index 000000000..c84c230a1 Binary files /dev/null and b/app/src/androidTest/assets/views/HabitScoreView/renderDataOffset.png differ diff --git a/app/src/androidTest/assets/views/HabitScoreView/renderDifferentSize.png b/app/src/androidTest/assets/views/HabitScoreView/renderDifferentSize.png new file mode 100644 index 000000000..133068dc4 Binary files /dev/null and b/app/src/androidTest/assets/views/HabitScoreView/renderDifferentSize.png differ diff --git a/app/src/androidTest/assets/views/HabitScoreView/renderMonthly.png b/app/src/androidTest/assets/views/HabitScoreView/renderMonthly.png new file mode 100644 index 000000000..83c31bcfe Binary files /dev/null and b/app/src/androidTest/assets/views/HabitScoreView/renderMonthly.png differ diff --git a/app/src/androidTest/assets/views/HabitScoreView/renderTransparent.png b/app/src/androidTest/assets/views/HabitScoreView/renderTransparent.png new file mode 100644 index 000000000..b6fd78857 Binary files /dev/null and b/app/src/androidTest/assets/views/HabitScoreView/renderTransparent.png differ diff --git a/app/src/androidTest/assets/views/HabitScoreView/renderYearly.png b/app/src/androidTest/assets/views/HabitScoreView/renderYearly.png new file mode 100644 index 000000000..1e45b47d1 Binary files /dev/null and b/app/src/androidTest/assets/views/HabitScoreView/renderYearly.png differ diff --git a/app/src/androidTest/assets/views/HabitStreakView/render.png b/app/src/androidTest/assets/views/HabitStreakView/render.png new file mode 100644 index 000000000..365231bff Binary files /dev/null and b/app/src/androidTest/assets/views/HabitStreakView/render.png differ diff --git a/app/src/androidTest/assets/views/HabitStreakView/renderSmallSize.png b/app/src/androidTest/assets/views/HabitStreakView/renderSmallSize.png new file mode 100644 index 000000000..eaa0ca53e Binary files /dev/null and b/app/src/androidTest/assets/views/HabitStreakView/renderSmallSize.png differ diff --git a/app/src/androidTest/assets/views/HabitStreakView/renderTransparent.png b/app/src/androidTest/assets/views/HabitStreakView/renderTransparent.png new file mode 100644 index 000000000..408a215d3 Binary files /dev/null and b/app/src/androidTest/assets/views/HabitStreakView/renderTransparent.png differ diff --git a/app/src/androidTest/assets/views/NumberView/render.png b/app/src/androidTest/assets/views/NumberView/render.png new file mode 100644 index 000000000..52e65b579 Binary files /dev/null and b/app/src/androidTest/assets/views/NumberView/render.png differ diff --git a/app/src/androidTest/assets/views/NumberView/renderDifferentParams.png b/app/src/androidTest/assets/views/NumberView/renderDifferentParams.png new file mode 100644 index 000000000..4b4814f1a Binary files /dev/null and b/app/src/androidTest/assets/views/NumberView/renderDifferentParams.png differ diff --git a/app/src/androidTest/assets/views/NumberView/renderLongLabel.png b/app/src/androidTest/assets/views/NumberView/renderLongLabel.png new file mode 100644 index 000000000..89fe4fd32 Binary files /dev/null and b/app/src/androidTest/assets/views/NumberView/renderLongLabel.png differ diff --git a/app/src/androidTest/assets/views/RingView/render.png b/app/src/androidTest/assets/views/RingView/render.png new file mode 100644 index 000000000..c77355c7c Binary files /dev/null and b/app/src/androidTest/assets/views/RingView/render.png differ diff --git a/app/src/androidTest/assets/views/RingView/renderDifferentParams.png b/app/src/androidTest/assets/views/RingView/renderDifferentParams.png new file mode 100644 index 000000000..02dfdf803 Binary files /dev/null and b/app/src/androidTest/assets/views/RingView/renderDifferentParams.png differ diff --git a/app/src/androidTest/assets/views/RingView/renderLongLabel.png b/app/src/androidTest/assets/views/RingView/renderLongLabel.png new file mode 100644 index 000000000..48b9998ab Binary files /dev/null and b/app/src/androidTest/assets/views/RingView/renderLongLabel.png differ diff --git a/app/src/androidTest/java/org/isoron/uhabits/BaseTest.java b/app/src/androidTest/java/org/isoron/uhabits/BaseTest.java new file mode 100644 index 000000000..a73f5cd9d --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/BaseTest.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits; + +import android.content.Context; +import android.os.Build; +import android.os.Looper; +import android.support.test.InstrumentationRegistry; + +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.tasks.BaseTask; +import org.junit.Before; + +import java.util.concurrent.TimeoutException; + +public class BaseTest +{ + protected Context testContext; + protected Context targetContext; + private static boolean isLooperPrepared; + + public static final long FIXED_LOCAL_TIME = 1422172800000L; // 8:00am, January 25th, 2015 (UTC) + + @Before + public void setup() + { + if(!isLooperPrepared) + { + Looper.prepare(); + isLooperPrepared = true; + } + + targetContext = InstrumentationRegistry.getTargetContext(); + testContext = InstrumentationRegistry.getContext(); + + DateHelper.setFixedLocalTime(FIXED_LOCAL_TIME); + } + + protected void waitForAsyncTasks() throws InterruptedException, TimeoutException + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) + { + Thread.sleep(1000); + return; + } + + BaseTask.waitForTasks(10000); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/MainTest.java b/app/src/androidTest/java/org/isoron/uhabits/MainTest.java deleted file mode 100644 index b6047c69a..000000000 --- a/app/src/androidTest/java/org/isoron/uhabits/MainTest.java +++ /dev/null @@ -1,200 +0,0 @@ -package org.isoron.uhabits; - -import android.content.Context; -import android.support.test.InstrumentationRegistry; -import android.support.test.espresso.NoMatchingViewException; -import android.support.test.espresso.intent.rule.IntentsTestRule; -import android.support.test.runner.AndroidJUnit4; -import android.test.suitebuilder.annotation.LargeTest; - -import org.isoron.uhabits.models.Habit; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.LinkedList; -import java.util.List; -import java.util.Random; - -import static android.support.test.espresso.Espresso.onData; -import static android.support.test.espresso.Espresso.onView; -import static android.support.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu; -import static android.support.test.espresso.Espresso.pressBack; -import static android.support.test.espresso.action.ViewActions.click; -import static android.support.test.espresso.action.ViewActions.longClick; -import static android.support.test.espresso.action.ViewActions.scrollTo; -import static android.support.test.espresso.action.ViewActions.swipeLeft; -import static android.support.test.espresso.action.ViewActions.swipeRight; -import static android.support.test.espresso.action.ViewActions.swipeUp; -import static android.support.test.espresso.assertion.ViewAssertions.matches; -import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; -import static android.support.test.espresso.matcher.ViewMatchers.isRoot; -import static android.support.test.espresso.matcher.ViewMatchers.withClassName; -import static android.support.test.espresso.matcher.ViewMatchers.withId; -import static android.support.test.espresso.matcher.ViewMatchers.withText; -import static org.hamcrest.Matchers.allOf; -import static org.hamcrest.Matchers.endsWith; -import static org.hamcrest.Matchers.instanceOf; -import static org.hamcrest.Matchers.is; -import static org.isoron.uhabits.HabitMatchers.withName; -import static org.isoron.uhabits.HabitViewActions.clickAtRandomLocations; -import static org.isoron.uhabits.HabitViewActions.toggleAllCheckmarks; -import static org.isoron.uhabits.MainActivityActions.addHabit; -import static org.isoron.uhabits.MainActivityActions.assertHabitExists; -import static org.isoron.uhabits.MainActivityActions.assertHabitsDontExist; -import static org.isoron.uhabits.MainActivityActions.assertHabitsExist; -import static org.isoron.uhabits.MainActivityActions.clickActionModeMenuItem; -import static org.isoron.uhabits.MainActivityActions.deleteHabit; -import static org.isoron.uhabits.MainActivityActions.deleteHabits; -import static org.isoron.uhabits.MainActivityActions.selectHabit; -import static org.isoron.uhabits.MainActivityActions.selectHabits; -import static org.isoron.uhabits.MainActivityActions.typeHabitData; -import static org.isoron.uhabits.ShowHabitActivityActions.openHistoryEditor; - -@RunWith(AndroidJUnit4.class) -@LargeTest -public class MainTest -{ - @Rule - public IntentsTestRule activityRule = new IntentsTestRule<>( - MainActivity.class); - - @Before - public void skipTutorial() - { - try - { - for (int i = 0; i < 10; i++) - onView(allOf(withClassName(endsWith("AppCompatImageButton")), - isDisplayed())).perform(click()); - } - catch (NoMatchingViewException e) - { - // ignored - } - } - - @Test - public void testArchiveHabits() - { - List names = new LinkedList<>(); - Context context = InstrumentationRegistry.getTargetContext(); - - for(int i = 0; i < 3; i++) - names.add(addHabit()); - - selectHabits(names); - - clickActionModeMenuItem(R.string.archive); - assertHabitsDontExist(names); - - openActionBarOverflowOrOptionsMenu(context); - onView(withText(R.string.show_archived)) - .perform(click()); - - assertHabitsExist(names); - selectHabits(names); - clickActionModeMenuItem(R.string.unarchive); - - openActionBarOverflowOrOptionsMenu(context); - onView(withText(R.string.show_archived)) - .perform(click()); - - assertHabitsExist(names); - deleteHabits(names); - } - - @Test - public void testAddInvalidHabit() - { - onView(withId(R.id.action_add)) - .perform(click()); - - typeHabitData("", "", "15", "7"); - - onView(withId(R.id.buttonSave)).perform(click()); - onView(withId(R.id.input_name)).check(matches(isDisplayed())); - } - - @Test - public void testAddHabitAndViewStats() - { - String name = addHabit(true); - - onData(allOf(is(instanceOf(Habit.class)), withName(name))) - .onChildView(withId(R.id.llButtons)) - .perform(toggleAllCheckmarks()); - - onData(allOf(is(instanceOf(Habit.class)), withName(name))) - .onChildView(withId(R.id.label)) - .perform(click()); - - onView(withId(R.id.scoreView)) - .perform(swipeRight()); - - onView(withId(R.id.punchcardView)) - .perform(scrollTo()); - } - - @Test - public void testEditHabit() - { - String name = addHabit(); - - onData(allOf(is(instanceOf(Habit.class)), withName(name))) - .onChildView(withId(R.id.label)) - .perform(longClick()); - - clickActionModeMenuItem(R.string.edit); - - String modifiedName = "Modified " + new Random().nextInt(10000); - typeHabitData(modifiedName, "", "1", "1"); - - onView(withId(R.id.buttonSave)) - .perform(click()); - - assertHabitExists(modifiedName); - - selectHabit(modifiedName); - clickActionModeMenuItem(R.string.color_picker_default_title); - pressBack(); - - deleteHabit(modifiedName); - } - - @Test - public void testEditHistory() - { - String name = addHabit(); - - onData(allOf(is(instanceOf(Habit.class)), withName(name))) - .onChildView(withId(R.id.label)) - .perform(click()); - - openHistoryEditor(); - onView(withClassName(endsWith("HabitHistoryView"))) - .perform(clickAtRandomLocations(20)); - - pressBack(); - onView(withId(R.id.historyView)) - .perform(scrollTo(), swipeRight(), swipeLeft()); - } - - @Test - public void testSettings() - { - Context context = InstrumentationRegistry.getContext(); - openActionBarOverflowOrOptionsMenu(context); - onView(withText(R.string.settings)).perform(click()); - } - - @Test - public void testAbout() - { - Context context = InstrumentationRegistry.getContext(); - openActionBarOverflowOrOptionsMenu(context); - onView(withText(R.string.about)).perform(click()); - onView(isRoot()).perform(swipeUp()); - } -} diff --git a/app/src/androidTest/java/org/isoron/uhabits/HabitMatchers.java b/app/src/androidTest/java/org/isoron/uhabits/ui/HabitMatchers.java similarity index 78% rename from app/src/androidTest/java/org/isoron/uhabits/HabitMatchers.java rename to app/src/androidTest/java/org/isoron/uhabits/ui/HabitMatchers.java index c96b24c16..ee8b810b8 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/HabitMatchers.java +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/HabitMatchers.java @@ -17,12 +17,14 @@ * with this program. If not, see . */ -package org.isoron.uhabits; +package org.isoron.uhabits.ui; +import android.preference.Preference; import android.view.View; import android.widget.Adapter; import android.widget.AdapterView; +import org.hamcrest.BaseMatcher; import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.TypeSafeMatcher; @@ -76,4 +78,23 @@ public class HabitMatchers } }; } + + public static Matcher isPreferenceWithText(final String text) + { + return (Matcher) new BaseMatcher() + { + @Override + public boolean matches(Object o) + { + if(!(o instanceof Preference)) return false; + return o.toString().contains(text); + } + + @Override + public void describeTo(Description description) + { + description.appendText(String.format("is preference with text '%s'", text)); + } + }; + } } diff --git a/app/src/androidTest/java/org/isoron/uhabits/HabitViewActions.java b/app/src/androidTest/java/org/isoron/uhabits/ui/HabitViewActions.java similarity index 98% rename from app/src/androidTest/java/org/isoron/uhabits/HabitViewActions.java rename to app/src/androidTest/java/org/isoron/uhabits/ui/HabitViewActions.java index 7c9a7e0a2..afa630ea0 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/HabitViewActions.java +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/HabitViewActions.java @@ -17,7 +17,7 @@ * with this program. If not, see . */ -package org.isoron.uhabits; +package org.isoron.uhabits.ui; import android.support.test.espresso.UiController; import android.support.test.espresso.ViewAction; @@ -32,6 +32,7 @@ import android.widget.LinearLayout; import android.widget.TextView; import org.hamcrest.Matcher; +import org.isoron.uhabits.R; import java.security.InvalidParameterException; import java.util.Random; diff --git a/app/src/androidTest/java/org/isoron/uhabits/MainActivityActions.java b/app/src/androidTest/java/org/isoron/uhabits/ui/MainActivityActions.java similarity index 85% rename from app/src/androidTest/java/org/isoron/uhabits/MainActivityActions.java rename to app/src/androidTest/java/org/isoron/uhabits/ui/MainActivityActions.java index 3ce26da37..40699aa35 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/MainActivityActions.java +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/MainActivityActions.java @@ -17,11 +17,11 @@ * with this program. If not, see . */ -package org.isoron.uhabits; +package org.isoron.uhabits.ui; -import android.content.Context; -import android.support.test.InstrumentationRegistry; +import android.support.test.espresso.NoMatchingViewException; +import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; import java.util.Collections; @@ -37,16 +37,20 @@ import static android.support.test.espresso.action.ViewActions.click; import static android.support.test.espresso.action.ViewActions.longClick; import static android.support.test.espresso.action.ViewActions.replaceText; import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.matcher.RootMatchers.isPlatformPopup; +import static android.support.test.espresso.matcher.ViewMatchers.Visibility.VISIBLE; import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; import static android.support.test.espresso.matcher.ViewMatchers.withContentDescription; +import static android.support.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static android.support.test.espresso.matcher.ViewMatchers.withId; import static android.support.test.espresso.matcher.ViewMatchers.withText; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; -import static org.isoron.uhabits.HabitMatchers.containsHabit; -import static org.isoron.uhabits.HabitMatchers.withName; +import static org.hamcrest.Matchers.startsWith; +import static org.isoron.uhabits.ui.HabitMatchers.containsHabit; +import static org.isoron.uhabits.ui.HabitMatchers.withName; public class MainActivityActions { @@ -97,6 +101,20 @@ public class MainActivityActions .perform(replaceText(name)); onView(withId(R.id.input_description)) .perform(replaceText(description)); + + try + { + onView(allOf(withId(R.id.sFrequency), withEffectiveVisibility(VISIBLE))) + .perform(click()); + onData(allOf(instanceOf(String.class), startsWith("Custom"))) + .inRoot(isPlatformPopup()) + .perform(click()); + } + catch(NoMatchingViewException e) + { + // ignored + } + onView(withId(R.id.input_freq_num)) .perform(replaceText(num)); onView(withId(R.id.input_freq_den)) diff --git a/app/src/androidTest/java/org/isoron/uhabits/ui/MainTest.java b/app/src/androidTest/java/org/isoron/uhabits/ui/MainTest.java new file mode 100644 index 000000000..0ea767757 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/MainTest.java @@ -0,0 +1,359 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.ui; + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Context; +import android.content.Intent; +import android.support.test.InstrumentationRegistry; +import android.support.test.espresso.NoMatchingViewException; +import android.support.test.espresso.intent.rule.IntentsTestRule; +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.LargeTest; + +import org.isoron.uhabits.MainActivity; +import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.LinkedList; +import java.util.List; +import java.util.Random; + +import static android.support.test.espresso.Espresso.onData; +import static android.support.test.espresso.Espresso.onView; +import static android.support.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu; +import static android.support.test.espresso.Espresso.pressBack; +import static android.support.test.espresso.action.ViewActions.click; +import static android.support.test.espresso.action.ViewActions.longClick; +import static android.support.test.espresso.action.ViewActions.scrollTo; +import static android.support.test.espresso.action.ViewActions.swipeLeft; +import static android.support.test.espresso.action.ViewActions.swipeRight; +import static android.support.test.espresso.action.ViewActions.swipeUp; +import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.intent.Intents.intended; +import static android.support.test.espresso.intent.Intents.intending; +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasAction; +import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.isRoot; +import static android.support.test.espresso.matcher.ViewMatchers.withClassName; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; +import static org.isoron.uhabits.ui.HabitMatchers.isPreferenceWithText; +import static org.isoron.uhabits.ui.HabitMatchers.withName; +import static org.isoron.uhabits.ui.HabitViewActions.clickAtRandomLocations; +import static org.isoron.uhabits.ui.HabitViewActions.toggleAllCheckmarks; +import static org.isoron.uhabits.ui.MainActivityActions.addHabit; +import static org.isoron.uhabits.ui.MainActivityActions.assertHabitExists; +import static org.isoron.uhabits.ui.MainActivityActions.assertHabitsDontExist; +import static org.isoron.uhabits.ui.MainActivityActions.assertHabitsExist; +import static org.isoron.uhabits.ui.MainActivityActions.clickActionModeMenuItem; +import static org.isoron.uhabits.ui.MainActivityActions.deleteHabit; +import static org.isoron.uhabits.ui.MainActivityActions.deleteHabits; +import static org.isoron.uhabits.ui.MainActivityActions.selectHabit; +import static org.isoron.uhabits.ui.MainActivityActions.selectHabits; +import static org.isoron.uhabits.ui.MainActivityActions.typeHabitData; +import static org.isoron.uhabits.ui.ShowHabitActivityActions.openHistoryEditor; + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class MainTest +{ + private SystemHelper sys; + + @Rule + public IntentsTestRule activityRule = new IntentsTestRule<>( + MainActivity.class); + + private Context targetContext; + + @Before + public void setup() + { + Context context = InstrumentationRegistry.getInstrumentation().getContext(); + sys = new SystemHelper(context); + sys.disableAllAnimations(); + sys.acquireWakeLock(); + sys.unlockScreen(); + + targetContext = InstrumentationRegistry.getTargetContext(); + + Instrumentation.ActivityResult okResult = new Instrumentation.ActivityResult( + Activity.RESULT_OK, new Intent()); + + intending(hasAction(equalTo(Intent.ACTION_SEND))).respondWith(okResult); + intending(hasAction(equalTo(Intent.ACTION_SENDTO))).respondWith(okResult); + intending(hasAction(equalTo(Intent.ACTION_VIEW))).respondWith(okResult); + + skipTutorial(); + } + + @After + public void tearDown() + { + sys.releaseWakeLock(); + } + + public void skipTutorial() + { + try + { + for (int i = 0; i < 10; i++) + onView(allOf(withClassName(endsWith("AppCompatImageButton")), + isDisplayed())).perform(click()); + } + catch (NoMatchingViewException e) + { + // ignored + } + } + + /** + * User opens the app, creates some habits, selects them, archives them, select 'show archived' + * on the menu, selects the previously archived habits and then deletes them. + */ + @Test + public void testArchiveHabits() + { + List names = new LinkedList<>(); + + for(int i = 0; i < 3; i++) + names.add(addHabit()); + + selectHabits(names); + + clickActionModeMenuItem(R.string.archive); + assertHabitsDontExist(names); + + openActionBarOverflowOrOptionsMenu(targetContext); + onView(withText(R.string.show_archived)) + .perform(click()); + + assertHabitsExist(names); + selectHabits(names); + clickActionModeMenuItem(R.string.unarchive); + + openActionBarOverflowOrOptionsMenu(targetContext); + onView(withText(R.string.show_archived)) + .perform(click()); + + assertHabitsExist(names); + deleteHabits(names); + } + + /** + * User opens the app, clicks the add button, types some bogus information, tries to save, + * dialog displays an error. + */ + @Test + public void testAddInvalidHabit() + { + onView(withId(R.id.action_add)) + .perform(click()); + + typeHabitData("", "", "15", "7"); + + onView(withId(R.id.buttonSave)).perform(click()); + onView(withId(R.id.input_name)).check(matches(isDisplayed())); + } + + /** + * User creates a habit, toggles a bunch of checkmarks, clicks the habit to open the statistics + * screen, scrolls down to some views, then scrolls the views backwards and forwards in time. + */ + @Test + public void testAddHabitAndViewStats() throws InterruptedException + { + String name = addHabit(true); + + onData(allOf(is(instanceOf(Habit.class)), withName(name))) + .onChildView(withId(R.id.llButtons)) + .perform(toggleAllCheckmarks()); + + Thread.sleep(1200); + + onData(allOf(is(instanceOf(Habit.class)), withName(name))) + .onChildView(withId(R.id.label)) + .perform(click()); + + onView(withId(R.id.scoreView)) + .perform(scrollTo(), swipeRight()); + + onView(withId(R.id.punchcardView)) + .perform(scrollTo(), swipeRight()); + } + + /** + * User creates a habit, selects the habit, clicks edit button, changes some information about + * the habit, click save button, sees changes on the main window, selects habit again, + * changes color, then deletes the habit. + */ + @Test + public void testEditHabit() + { + String name = addHabit(); + + onData(allOf(is(instanceOf(Habit.class)), withName(name))) + .onChildView(withId(R.id.label)) + .perform(longClick()); + + clickActionModeMenuItem(R.string.edit); + + String modifiedName = "Modified " + new Random().nextInt(10000); + typeHabitData(modifiedName, "", "1", "1"); + + onView(withId(R.id.buttonSave)) + .perform(click()); + + assertHabitExists(modifiedName); + + selectHabit(modifiedName); + clickActionModeMenuItem(R.string.color_picker_default_title); + pressBack(); + + deleteHabit(modifiedName); + } + + /** + * User creates a habit, opens statistics page, clicks button to edit history, adds some + * checkmarks, closes dialog, sees the modified history calendar. + */ + @Test + public void testEditHistory() + { + String name = addHabit(); + + onData(allOf(is(instanceOf(Habit.class)), withName(name))) + .onChildView(withId(R.id.label)) + .perform(click()); + + openHistoryEditor(); + onView(withClassName(endsWith("HabitHistoryView"))) + .perform(clickAtRandomLocations(20)); + + pressBack(); + onView(withId(R.id.historyView)) + .perform(scrollTo(), swipeRight(), swipeLeft()); + } + + /** + * User opens menu, clicks settings, sees settings screen. + */ + @Test + public void testSettings() + { + openActionBarOverflowOrOptionsMenu(targetContext); + onView(withText(R.string.settings)).perform(click()); + } + + /** + * User opens menu, clicks about, sees about screen. + */ + @Test + public void testAbout() + { + openActionBarOverflowOrOptionsMenu(targetContext); + onView(withText(R.string.about)).perform(click()); + onView(isRoot()).perform(swipeUp()); + } + + /** + * User opens menu, clicks Help, sees website. + */ + @Test + public void testHelp() + { + openActionBarOverflowOrOptionsMenu(targetContext); + onView(withText(R.string.help)).perform(click()); + intended(hasAction(Intent.ACTION_VIEW)); + } + + /** + * User creates a habit, exports full backup, deletes the habit, restores backup, sees that the + * previously created habit has appeared back. + */ + @Test + public void testExportImportDB() + { + String name = addHabit(); + + openActionBarOverflowOrOptionsMenu(targetContext); + onView(withText(R.string.settings)).perform(click()); + + String date = DateHelper.getBackupDateFormat().format(DateHelper.getLocalTime()); + date = date.substring(0, date.length() - 2); + + onData(isPreferenceWithText("Export full backup")).perform(click()); + intended(hasAction(Intent.ACTION_SEND)); + + deleteHabit(name); + + openActionBarOverflowOrOptionsMenu(targetContext); + onView(withText(R.string.settings)).perform(click()); + onData(isPreferenceWithText("Import data")).perform(click()); + + onData(allOf(is(instanceOf(String.class)), startsWith("Backups"))) + .perform(click()); + + onData(allOf(is(instanceOf(String.class)), containsString(date))) + .perform(click()); + + selectHabit(name); + } + + /** + * User creates a habit, opens settings, clicks export as CSV, is asked what activity should + * handle the file. + */ + @Test + public void testExportCSV() + { + addHabit(); + openActionBarOverflowOrOptionsMenu(targetContext); + onView(withText(R.string.settings)).perform(click()); + onData(isPreferenceWithText("Export as CSV")).perform(click()); + intended(hasAction(Intent.ACTION_SEND)); + } + + /** + * User opens the settings and generates a bug report. + */ + @Test + public void testGenerateBugReport() + { + openActionBarOverflowOrOptionsMenu(targetContext); + onView(withText(R.string.settings)).perform(click()); + onData(isPreferenceWithText("Generate bug report")).perform(click()); + intended(hasAction(Intent.ACTION_SENDTO)); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/ShowHabitActivityActions.java b/app/src/androidTest/java/org/isoron/uhabits/ui/ShowHabitActivityActions.java similarity index 86% rename from app/src/androidTest/java/org/isoron/uhabits/ShowHabitActivityActions.java rename to app/src/androidTest/java/org/isoron/uhabits/ui/ShowHabitActivityActions.java index cacc9f21f..31a89c397 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/ShowHabitActivityActions.java +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/ShowHabitActivityActions.java @@ -17,18 +17,21 @@ * with this program. If not, see . */ -package org.isoron.uhabits; +package org.isoron.uhabits.ui; + +import android.support.test.espresso.matcher.ViewMatchers; + +import org.isoron.uhabits.R; import static android.support.test.espresso.Espresso.onView; import static android.support.test.espresso.action.ViewActions.click; import static android.support.test.espresso.action.ViewActions.scrollTo; -import static android.support.test.espresso.matcher.ViewMatchers.withId; public class ShowHabitActivityActions { public static void openHistoryEditor() { - onView(withId(R.id.btEditHistory)) + onView(ViewMatchers.withId(R.id.btEditHistory)) .perform(scrollTo(), click()); } } diff --git a/app/src/androidTest/java/org/isoron/uhabits/ui/SystemHelper.java b/app/src/androidTest/java/org/isoron/uhabits/ui/SystemHelper.java new file mode 100644 index 000000000..807e3b36c --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/ui/SystemHelper.java @@ -0,0 +1,105 @@ +package org.isoron.uhabits.ui; + +import android.app.KeyguardManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.IBinder; +import android.os.PowerManager; +import android.support.test.runner.AndroidJUnitRunner; +import android.util.Log; + +import java.lang.reflect.Method; + +public final class SystemHelper extends AndroidJUnitRunner +{ + private static final String ANIMATION_PERMISSION = "android.permission.SET_ANIMATION_SCALE"; + private static final float DISABLED = 0.0f; + private static final float DEFAULT = 1.0f; + + private final Context context; + private PowerManager.WakeLock wakeLock; + + SystemHelper(Context context) + { + this.context = context; + } + + void acquireWakeLock() + { + PowerManager power = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + wakeLock = power.newWakeLock(PowerManager.FULL_WAKE_LOCK | + PowerManager.ACQUIRE_CAUSES_WAKEUP | + PowerManager.ON_AFTER_RELEASE, getClass().getSimpleName()); + wakeLock.acquire(); + } + + void releaseWakeLock() + { + if(wakeLock != null) + wakeLock.release(); + } + + void unlockScreen() + { + Log.i("SystemHelper", "Trying to unlock screen"); + try + { + KeyguardManager mKeyGuardManager = + (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); + KeyguardManager.KeyguardLock mLock = mKeyGuardManager.newKeyguardLock("lock"); + mLock.disableKeyguard(); + Log.e("SystemHelper", "Successfully unlocked screen"); + } catch (Exception e) + { + Log.e("SystemHelper", "Could not unlock screen"); + e.printStackTrace(); + } + } + + void disableAllAnimations() + { + Log.i("SystemHelper", "Trying to disable animations"); + int permStatus = context.checkCallingOrSelfPermission(ANIMATION_PERMISSION); + if (permStatus == PackageManager.PERMISSION_GRANTED) setSystemAnimationsScale(DISABLED); + else Log.e("SystemHelper", "Permission denied"); + + } + + void enableAllAnimations() + { + int permStatus = context.checkCallingOrSelfPermission(ANIMATION_PERMISSION); + if (permStatus == PackageManager.PERMISSION_GRANTED) + { + setSystemAnimationsScale(DEFAULT); + } + } + + private void setSystemAnimationsScale(float animationScale) + { + try + { + Class windowManagerStubClazz = Class.forName("android.view.IWindowManager$Stub"); + Method asInterface = + windowManagerStubClazz.getDeclaredMethod("asInterface", IBinder.class); + Class serviceManagerClazz = Class.forName("android.os.ServiceManager"); + Method getService = serviceManagerClazz.getDeclaredMethod("getService", String.class); + Class windowManagerClazz = Class.forName("android.view.IWindowManager"); + Method setAnimationScales = + windowManagerClazz.getDeclaredMethod("setAnimationScales", float[].class); + Method getAnimationScales = windowManagerClazz.getDeclaredMethod("getAnimationScales"); + + IBinder windowManagerBinder = (IBinder) getService.invoke(null, "window"); + Object windowManagerObj = asInterface.invoke(null, windowManagerBinder); + float[] currentScales = (float[]) getAnimationScales.invoke(windowManagerObj); + for (int i = 0; i < currentScales.length; i++) + currentScales[i] = animationScale; + + setAnimationScales.invoke(windowManagerObj, new Object[]{currentScales}); + Log.i("SystemHelper", "All animations successfully disabled"); + } + catch (Exception e) + { + Log.e("SystemHelper", "Could not change animation scale to " + animationScale + " :'("); + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/HabitFixtures.java b/app/src/androidTest/java/org/isoron/uhabits/unit/HabitFixtures.java new file mode 100644 index 000000000..22a425266 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/HabitFixtures.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit; + +import android.content.Context; +import android.support.annotation.Nullable; +import android.util.Log; + +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.tasks.BaseTask; +import org.isoron.uhabits.tasks.ExportDBTask; +import org.isoron.uhabits.tasks.ImportDataTask; + +import java.io.File; +import java.io.InputStream; +import java.util.Random; + +import static org.junit.Assert.fail; + +public class HabitFixtures +{ + public static boolean NON_DAILY_HABIT_CHECKS[] = { true, false, false, true, true, true, false, + false, true, true }; + + public static Habit createShortHabit() + { + Habit habit = new Habit(); + habit.name = "Wake up early"; + habit.description = "Did you wake up before 6am?"; + habit.freqNum = 2; + habit.freqDen = 3; + habit.save(); + + long timestamp = DateHelper.getStartOfToday(); + for(boolean c : NON_DAILY_HABIT_CHECKS) + { + if(c) habit.repetitions.toggle(timestamp); + timestamp -= DateHelper.millisecondsInOneDay; + } + + return habit; + } + + public static Habit createEmptyHabit() + { + Habit habit = new Habit(); + habit.name = "Meditate"; + habit.description = "Did you meditate this morning?"; + habit.color = ColorHelper.palette[3]; + habit.freqNum = 1; + habit.freqDen = 1; + habit.save(); + return habit; + } + + public static Habit createLongHabit() + { + Habit habit = createEmptyHabit(); + habit.freqNum = 3; + habit.freqDen = 7; + habit.color = ColorHelper.palette[4]; + habit.save(); + + long day = DateHelper.millisecondsInOneDay; + long today = DateHelper.getStartOfToday(); + 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.repetitions.toggle(today - mark * day); + + return habit; + } + + public static void generateHugeDataSet() throws Throwable + { + final int nHabits = 30; + final int nYears = 5; + + DatabaseHelper.executeAsTransaction(new DatabaseHelper.Command() + { + @Override + public void execute() + { + Random rand = new Random(); + + for(int i = 0; i < nHabits; i++) + { + Log.i("HabitFixture", String.format("Creating habit %d / %d", i, nHabits)); + + Habit habit = new Habit(); + habit.name = String.format("Habit %d", i); + habit.save(); + + long today = DateHelper.getStartOfToday(); + long day = DateHelper.millisecondsInOneDay; + + + for(int j = 0; j < 365 * nYears; j++) + { + if(rand.nextBoolean()) + habit.repetitions.toggle(today - j * day); + } + + habit.scores.getTodayValue(); + habit.streaks.getAll(1); + } + } + }); + + ExportDBTask task = new ExportDBTask(null); + task.setListener(new ExportDBTask.Listener() + { + @Override + public void onExportDBFinished(@Nullable String filename) + { + if(filename != null) + Log.i("HabitFixture", String.format("Huge data set exported to %s", filename)); + else + Log.i("HabitFixture", "Failed to save database"); + } + }); + task.execute(); + + BaseTask.waitForTasks(30000); + } + + public static void loadHugeDataSet(Context testContext) throws Throwable + { + File baseDir = DatabaseHelper.getFilesDir("Backups"); + if(baseDir == null) fail("baseDir should not be null"); + + File dst = new File(String.format("%s/%s", baseDir.getPath(), "loopHuge.db")); + InputStream in = testContext.getAssets().open("fixtures/loopHuge.db"); + DatabaseHelper.copy(in, dst); + + ImportDataTask task = new ImportDataTask(dst, null); + task.execute(); + + BaseTask.waitForTasks(30000); + } + + public static void purgeHabits() + { + for(Habit h : Habit.getAll(true)) + h.cascadeDelete(); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/HabitsApplicationTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/HabitsApplicationTest.java new file mode 100644 index 000000000..1c4f78ad8 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/HabitsApplicationTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit; + +import android.os.Build; +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.HabitsApplication; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class HabitsApplicationTest +{ + @Test + public void test_getLogcat() throws IOException + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) + return; + + String msg = "LOGCAT TEST"; + new RuntimeException(msg).printStackTrace(); + + String log = HabitsApplication.getLogcat(); + assertThat(log, containsString(msg)); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/commands/ArchiveHabitsCommandTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/ArchiveHabitsCommandTest.java new file mode 100644 index 000000000..24be62f7e --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/ArchiveHabitsCommandTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.commands; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.commands.ArchiveHabitsCommand; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Collections; + +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ArchiveHabitsCommandTest extends BaseTest +{ + + private ArchiveHabitsCommand command; + private Habit habit; + + @Before + public void setup() + { + super.setup(); + + habit = HabitFixtures.createShortHabit(); + command = new ArchiveHabitsCommand(Collections.singletonList(habit)); + } + + @Test + public void testExecuteUndoRedo() + { + assertFalse(habit.isArchived()); + + command.execute(); + assertTrue(habit.isArchived()); + + command.undo(); + assertFalse(habit.isArchived()); + + command.execute(); + assertTrue(habit.isArchived()); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/commands/ChangeHabitColorCommandTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/ChangeHabitColorCommandTest.java new file mode 100644 index 000000000..54fd200fa --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/ChangeHabitColorCommandTest.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.commands; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.commands.ChangeHabitColorCommand; +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.LinkedList; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ChangeHabitColorCommandTest extends BaseTest +{ + private ChangeHabitColorCommand command; + private LinkedList habits; + + @Before + public void setup() + { + super.setup(); + + habits = new LinkedList<>(); + + for(int i = 0; i < 3; i ++) + { + Habit habit = HabitFixtures.createShortHabit(); + habit.color = ColorHelper.palette[i+1]; + habit.save(); + habits.add(habit); + } + + command = new ChangeHabitColorCommand(habits, ColorHelper.palette[0]); + } + + @Test + public void testExecuteUndoRedo() + { + checkOriginalColors(); + + command.execute(); + checkNewColors(); + + command.undo(); + checkOriginalColors(); + + command.execute(); + checkNewColors(); + } + + private void checkOriginalColors() + { + int k = 0; + for(Habit h : habits) + assertThat(h.color, equalTo(ColorHelper.palette[++k])); + } + + private void checkNewColors() + { + for(Habit h : habits) + assertThat(h.color, equalTo(ColorHelper.palette[0])); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/commands/CreateHabitCommandTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/CreateHabitCommandTest.java new file mode 100644 index 000000000..d97fa2e07 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/CreateHabitCommandTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.commands; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.commands.CreateHabitCommand; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +import static junit.framework.Assert.assertTrue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class CreateHabitCommandTest extends BaseTest +{ + + private CreateHabitCommand command; + private Habit model; + + @Before + public void setup() + { + super.setup(); + + model = new Habit(); + model.name = "New habit"; + command = new CreateHabitCommand(model); + + HabitFixtures.purgeHabits(); + } + + @Test + public void testExecuteUndoRedo() + { + assertTrue(Habit.getAll(true).isEmpty()); + + command.execute(); + + List allHabits = Habit.getAll(true); + assertThat(allHabits.size(), equalTo(1)); + + Habit habit = allHabits.get(0); + Long id = habit.getId(); + assertThat(habit.name, equalTo(model.name)); + + command.undo(); + assertTrue(Habit.getAll(true).isEmpty()); + + command.execute(); + allHabits = Habit.getAll(true); + assertThat(allHabits.size(), equalTo(1)); + + habit = allHabits.get(0); + Long newId = habit.getId(); + assertThat(id, equalTo(newId)); + assertThat(habit.name, equalTo(model.name)); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/commands/DeleteHabitsCommandTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/DeleteHabitsCommandTest.java new file mode 100644 index 000000000..36a97198a --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/DeleteHabitsCommandTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.commands; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.commands.DeleteHabitsCommand; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; + +import java.util.LinkedList; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class DeleteHabitsCommandTest extends BaseTest +{ + private DeleteHabitsCommand command; + private LinkedList habits; + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Before + public void setup() + { + super.setup(); + + HabitFixtures.purgeHabits(); + habits = new LinkedList<>(); + + // Habits that shuold be deleted + for(int i = 0; i < 3; i ++) + { + Habit habit = HabitFixtures.createShortHabit(); + habits.add(habit); + } + + // Extra habit that should not be deleted + Habit extraHabit = HabitFixtures.createShortHabit(); + extraHabit.name = "extra"; + extraHabit.save(); + + command = new DeleteHabitsCommand(habits); + } + + @Test + public void testExecuteUndoRedo() + { + assertThat(Habit.getAll(true).size(), equalTo(4)); + + command.execute(); + assertThat(Habit.getAll(true).size(), equalTo(1)); + assertThat(Habit.getAll(true).get(0).name, equalTo("extra")); + + thrown.expect(UnsupportedOperationException.class); + command.undo(); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/commands/EditHabitCommandTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/EditHabitCommandTest.java new file mode 100644 index 000000000..94d765870 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/EditHabitCommandTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.commands; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.commands.EditHabitCommand; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static junit.framework.Assert.assertTrue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class EditHabitCommandTest extends BaseTest +{ + + private EditHabitCommand command; + private Habit habit; + private Habit modified; + private Long id; + + @Before + public void setup() + { + super.setup(); + + habit = HabitFixtures.createShortHabit(); + habit.name = "original"; + habit.freqDen = 1; + habit.freqNum = 1; + habit.save(); + + id = habit.getId(); + + modified = new Habit(habit); + modified.name = "modified"; + } + + @Test + public void testExecuteUndoRedo() + { + command = new EditHabitCommand(habit, modified); + + int originalScore = habit.scores.getTodayValue(); + assertThat(habit.name, equalTo("original")); + + command.execute(); + refreshHabit(); + assertThat(habit.name, equalTo("modified")); + assertThat(habit.scores.getTodayValue(), equalTo(originalScore)); + + command.undo(); + refreshHabit(); + assertThat(habit.name, equalTo("original")); + assertThat(habit.scores.getTodayValue(), equalTo(originalScore)); + + command.execute(); + refreshHabit(); + assertThat(habit.name, equalTo("modified")); + assertThat(habit.scores.getTodayValue(), equalTo(originalScore)); + } + + @Test + public void testExecuteUndoRedo_withModifiedInterval() + { + modified.freqNum = 1; + modified.freqDen = 7; + command = new EditHabitCommand(habit, modified); + + int originalScore = habit.scores.getTodayValue(); + assertThat(habit.name, equalTo("original")); + + command.execute(); + refreshHabit(); + assertThat(habit.name, equalTo("modified")); + assertThat(habit.scores.getTodayValue(), greaterThan(originalScore)); + + command.undo(); + refreshHabit(); + assertThat(habit.name, equalTo("original")); + assertThat(habit.scores.getTodayValue(), equalTo(originalScore)); + + command.execute(); + refreshHabit(); + assertThat(habit.name, equalTo("modified")); + assertThat(habit.scores.getTodayValue(), greaterThan(originalScore)); + } + + private void refreshHabit() + { + habit = Habit.get(id); + assertTrue(habit != null); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/commands/ToggleRepetitionCommandTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/ToggleRepetitionCommandTest.java new file mode 100644 index 000000000..9d427d154 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/ToggleRepetitionCommandTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.commands; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.commands.ToggleRepetitionCommand; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ToggleRepetitionCommandTest extends BaseTest +{ + + private ToggleRepetitionCommand command; + private Habit habit; + private long today; + + @Before + public void setup() + { + super.setup(); + + habit = HabitFixtures.createShortHabit(); + + today = DateHelper.getStartOfToday(); + command = new ToggleRepetitionCommand(habit, today); + } + + @Test + public void testExecuteUndoRedo() + { + assertTrue(habit.repetitions.contains(today)); + + command.execute(); + assertFalse(habit.repetitions.contains(today)); + + command.undo(); + assertTrue(habit.repetitions.contains(today)); + + command.execute(); + assertFalse(habit.repetitions.contains(today)); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/commands/UnarchiveHabitsCommandTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/UnarchiveHabitsCommandTest.java new file mode 100644 index 000000000..066ff8bc4 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/commands/UnarchiveHabitsCommandTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.commands; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.commands.UnarchiveHabitsCommand; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Collections; + +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class UnarchiveHabitsCommandTest extends BaseTest +{ + + private UnarchiveHabitsCommand command; + private Habit habit; + + @Before + public void setup() + { + super.setup(); + + habit = HabitFixtures.createShortHabit(); + Habit.archive(Collections.singletonList(habit)); + + command = new UnarchiveHabitsCommand(Collections.singletonList(habit)); + } + + @Test + public void testExecuteUndoRedo() + { + assertTrue(habit.isArchived()); + + command.execute(); + assertFalse(habit.isArchived()); + + command.undo(); + assertTrue(habit.isArchived()); + + command.execute(); + assertFalse(habit.isArchived()); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/io/HabitsCSVExporterTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/io/HabitsCSVExporterTest.java new file mode 100644 index 000000000..2091d7aa3 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/io/HabitsCSVExporterTest.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.io; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.io.HabitsCSVExporter; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Enumeration; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static junit.framework.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class HabitsCSVExporterTest extends BaseTest +{ + private File baseDir; + + @Before + public void setup() + { + super.setup(); + + HabitFixtures.purgeHabits(); + HabitFixtures.createShortHabit(); + HabitFixtures.createEmptyHabit(); + + Context targetContext = InstrumentationRegistry.getTargetContext(); + baseDir = targetContext.getCacheDir(); + } + + private void unzip(File file) throws IOException + { + ZipFile zip = new ZipFile(file); + Enumeration e = zip.entries(); + + while(e.hasMoreElements()) + { + ZipEntry entry = e.nextElement(); + InputStream stream = zip.getInputStream(entry); + + String outputFilename = String.format("%s/%s", baseDir.getAbsolutePath(), + entry.getName()); + File outputFile = new File(outputFilename); + + File parent = outputFile.getParentFile(); + if(parent != null) parent.mkdirs(); + + DatabaseHelper.copy(stream, outputFile); + } + + zip.close(); + } + + @Test + public void testExportCSV() throws IOException + { + List habits = Habit.getAll(true); + + HabitsCSVExporter exporter = new HabitsCSVExporter(habits, baseDir); + String filename = exporter.writeArchive(); + assertAbsolutePathExists(filename); + + File archive = new File(filename); + unzip(archive); + + assertPathExists("Habits.csv"); + assertPathExists("001 Wake up early"); + assertPathExists("001 Wake up early/Checkmarks.csv"); + assertPathExists("001 Wake up early/Scores.csv"); + assertPathExists("002 Meditate/Checkmarks.csv"); + assertPathExists("002 Meditate/Scores.csv"); + } + + private void assertPathExists(String s) + { + assertAbsolutePathExists(String.format("%s/%s", baseDir.getAbsolutePath(), s)); + } + + private void assertAbsolutePathExists(String s) + { + File file = new File(s); + assertTrue(String.format("File %s should exist", file.getAbsolutePath()), file.exists()); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/io/ImportTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/io/ImportTest.java new file mode 100644 index 000000000..8f6f3fbe0 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/io/ImportTest.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.io; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.io.GenericImporter; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.GregorianCalendar; +import java.util.List; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ImportTest extends BaseTest +{ + private File baseDir; + private Context context; + + @Before + public void setup() + { + super.setup(); + DateHelper.setFixedLocalTime(null); + + HabitFixtures.purgeHabits(); + context = InstrumentationRegistry.getInstrumentation().getContext(); + baseDir = DatabaseHelper.getFilesDir("Backups"); + if(baseDir == null) fail("baseDir should not be null"); + } + + private void copyAssetToFile(String assetPath, File dst) throws IOException + { + InputStream in = context.getAssets().open(assetPath); + DatabaseHelper.copy(in, dst); + } + + private void importFromFile(String assetFilename) throws IOException + { + File file = new File(String.format("%s/%s", baseDir.getPath(), assetFilename)); + copyAssetToFile(assetFilename, file); + assertTrue(file.exists()); + assertTrue(file.canRead()); + + GenericImporter importer = new GenericImporter(); + assertThat(importer.canHandle(file), is(true)); + + importer.importHabitsFromFile(file); + } + + private boolean containsRepetition(Habit h, int year, int month, int day) + { + GregorianCalendar date = DateHelper.getStartOfTodayCalendar(); + date.set(year, month - 1, day); + return h.repetitions.contains(date.getTimeInMillis()); + } + + @Test + public void testTickmateDB() throws IOException + { + importFromFile("tickmate.db"); + + List habits = Habit.getAll(true); + assertThat(habits.size(), equalTo(3)); + + Habit h = habits.get(0); + assertThat(h.name, equalTo("Vegan")); + assertTrue(containsRepetition(h, 2016, 1, 24)); + assertTrue(containsRepetition(h, 2016, 2, 5)); + assertTrue(containsRepetition(h, 2016, 3, 18)); + assertFalse(containsRepetition(h, 2016, 3, 14)); + } + + @Test + public void testRewireDB() throws IOException + { + importFromFile("rewire.db"); + + List habits = Habit.getAll(true); + assertThat(habits.size(), equalTo(3)); + + Habit habit = habits.get(0); + assertThat(habit.name, equalTo("Wake up early")); + assertThat(habit.freqNum, equalTo(3)); + assertThat(habit.freqDen, equalTo(7)); + assertFalse(habit.hasReminder()); + assertFalse(containsRepetition(habit, 2015, 12, 31)); + assertTrue(containsRepetition(habit, 2016, 1, 18)); + assertTrue(containsRepetition(habit, 2016, 1, 28)); + assertFalse(containsRepetition(habit, 2016, 3, 10)); + + habit = habits.get(1); + assertThat(habit.name, equalTo("brush teeth")); + assertThat(habit.freqNum, equalTo(3)); + assertThat(habit.freqDen, equalTo(7)); + assertThat(habit.reminderHour, equalTo(8)); + assertThat(habit.reminderMin, equalTo(0)); + boolean[] reminderDays = {false, true, true, true, true, true, false}; + assertThat(habit.reminderDays, equalTo(DateHelper.packWeekdayList(reminderDays))); + } + + @Test + public void testHabitBullCSV() throws IOException + { + importFromFile("habitbull.csv"); + + List habits = Habit.getAll(true); + assertThat(habits.size(), equalTo(4)); + + Habit habit = habits.get(0); + assertThat(habit.name, equalTo("Breed dragons")); + assertThat(habit.description, equalTo("with love and fire")); + assertThat(habit.freqNum, equalTo(1)); + assertThat(habit.freqDen, equalTo(1)); + assertTrue(containsRepetition(habit, 2016, 3, 18)); + assertTrue(containsRepetition(habit, 2016, 3, 19)); + assertFalse(containsRepetition(habit, 2016, 3, 20)); + } + + @Test + public void testLoopDB() throws IOException + { + importFromFile("loop.db"); + + List habits = Habit.getAll(true); + assertThat(habits.size(), equalTo(9)); + + Habit habit = habits.get(0); + assertThat(habit.name, equalTo("Wake up early")); + assertThat(habit.freqNum, equalTo(3)); + assertThat(habit.freqDen, equalTo(7)); + assertTrue(containsRepetition(habit, 2016, 3, 14)); + assertTrue(containsRepetition(habit, 2016, 3, 16)); + assertFalse(containsRepetition(habit, 2016, 3, 17)); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/models/CheckmarkListTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/models/CheckmarkListTest.java new file mode 100644 index 000000000..5763892b8 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/models/CheckmarkListTest.java @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.models; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.io.StringWriter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.isoron.uhabits.models.Checkmark.CHECKED_EXPLICITLY; +import static org.isoron.uhabits.models.Checkmark.CHECKED_IMPLICITLY; +import static org.isoron.uhabits.models.Checkmark.UNCHECKED; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class CheckmarkListTest extends BaseTest +{ + Habit nonDailyHabit; + private Habit emptyHabit; + + @Before + public void setup() + { + super.setup(); + + HabitFixtures.purgeHabits(); + nonDailyHabit = HabitFixtures.createShortHabit(); + emptyHabit = HabitFixtures.createEmptyHabit(); + } + + @After + public void tearDown() + { + DateHelper.setFixedLocalTime(null); + } + + @Test + public void test_getAllValues_withNonDailyHabit() + { + int[] expectedValues = { CHECKED_EXPLICITLY, UNCHECKED, CHECKED_IMPLICITLY, + CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, UNCHECKED, + CHECKED_IMPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY }; + + int[] actualValues = nonDailyHabit.checkmarks.getAllValues(); + + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void test_getAllValues_withEmptyHabit() + { + int[] expectedValues = new int[0]; + int[] actualValues = emptyHabit.checkmarks.getAllValues(); + + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void test_getAllValues_moveForwardInTime() + { + travelInTime(3); + + int[] expectedValues = { UNCHECKED, UNCHECKED, UNCHECKED, CHECKED_EXPLICITLY, UNCHECKED, + CHECKED_IMPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, + UNCHECKED, CHECKED_IMPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY }; + + int[] actualValues = nonDailyHabit.checkmarks.getAllValues(); + + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void test_getAllValues_moveBackwardsInTime() + { + travelInTime(-3); + + int[] expectedValues = { CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, + UNCHECKED, CHECKED_IMPLICITLY, CHECKED_EXPLICITLY, CHECKED_EXPLICITLY }; + + int[] actualValues = nonDailyHabit.checkmarks.getAllValues(); + + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void test_getValues_withInvalidInterval() + { + int values[] = nonDailyHabit.checkmarks.getValues(100L, -100L); + assertThat(values, equalTo(new int[0])); + } + + @Test + public void test_getValues_withValidInterval() + { + long from = DateHelper.getStartOfToday() - 15 * DateHelper.millisecondsInOneDay; + long to = DateHelper.getStartOfToday() - 5 * DateHelper.millisecondsInOneDay; + + int[] expectedValues = { CHECKED_EXPLICITLY, UNCHECKED, CHECKED_IMPLICITLY, + CHECKED_EXPLICITLY, CHECKED_EXPLICITLY, UNCHECKED, UNCHECKED, UNCHECKED, UNCHECKED, + UNCHECKED, UNCHECKED }; + + int[] actualValues = nonDailyHabit.checkmarks.getValues(from, to); + + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void test_getTodayValue() + { + travelInTime(-1); + assertThat(nonDailyHabit.checkmarks.getTodayValue(), equalTo(UNCHECKED)); + + travelInTime(0); + assertThat(nonDailyHabit.checkmarks.getTodayValue(), equalTo(CHECKED_EXPLICITLY)); + + travelInTime(1); + assertThat(nonDailyHabit.checkmarks.getTodayValue(), equalTo(UNCHECKED)); + } + + @Test + public void test_writeCSV() throws IOException + { + String expectedCSV = + "2015-01-16,2\n" + + "2015-01-17,2\n" + + "2015-01-18,1\n" + + "2015-01-19,0\n" + + "2015-01-20,2\n" + + "2015-01-21,2\n" + + "2015-01-22,2\n" + + "2015-01-23,1\n" + + "2015-01-24,0\n" + + "2015-01-25,2\n"; + + StringWriter writer = new StringWriter(); + nonDailyHabit.checkmarks.writeCSV(writer); + + assertThat(writer.toString(), equalTo(expectedCSV)); + } + + private void travelInTime(int days) + { + DateHelper.setFixedLocalTime(FIXED_LOCAL_TIME + + days * DateHelper.millisecondsInOneDay); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/models/HabitTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/models/HabitTest.java new file mode 100644 index 000000000..a71a0bac3 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/models/HabitTest.java @@ -0,0 +1,379 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.models; + +import android.graphics.Color; +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.hamcrest.MatcherAssert; +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.LinkedList; +import java.util.List; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.core.IsNot.not; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class HabitTest extends BaseTest +{ + @Before + public void setup() + { + super.setup(); + HabitFixtures.purgeHabits(); + } + + @Test + public void testConstructor_default() + { + Habit habit = new Habit(); + assertThat(habit.archived, is(0)); + assertThat(habit.highlight, is(0)); + + assertThat(habit.reminderHour, is(nullValue())); + assertThat(habit.reminderMin, is(nullValue())); + + assertThat(habit.reminderDays, is(not(nullValue()))); + assertThat(habit.streaks, is(not(nullValue()))); + assertThat(habit.scores, is(not(nullValue()))); + assertThat(habit.repetitions, is(not(nullValue()))); + assertThat(habit.checkmarks, is(not(nullValue()))); + } + + @Test + public void testConstructor_habit() + { + Habit model = new Habit(); + model.archived = 1; + model.highlight = 1; + model.color = Color.BLACK; + model.freqNum = 10; + model.freqDen = 20; + model.reminderDays = 1; + model.reminderHour = 8; + model.reminderMin = 30; + model.position = 0; + + Habit habit = new Habit(model); + assertThat(habit.archived, is(model.archived)); + assertThat(habit.highlight, is(model.highlight)); + assertThat(habit.color, is(model.color)); + assertThat(habit.freqNum, is(model.freqNum)); + assertThat(habit.freqDen, is(model.freqDen)); + assertThat(habit.reminderDays, is(model.reminderDays)); + assertThat(habit.reminderHour, is(model.reminderHour)); + assertThat(habit.reminderMin, is(model.reminderMin)); + assertThat(habit.position, is(model.position)); + } + + @Test + public void test_get_withValidId() + { + Habit habit = new Habit(); + habit.save(); + + Habit habit2 = Habit.get(habit.getId()); + assertThat(habit, equalTo(habit2)); + } + + @Test + public void test_get_withInvalidId() + { + Habit habit = Habit.get(123456L); + assertThat(habit, is(nullValue())); + } + + @Test + public void test_getAll_withoutArchived() + { + List habits = new LinkedList<>(); + List habitsWithArchived = new LinkedList<>(); + + for(int i = 0; i < 10; i++) + { + Habit h = new Habit(); + + if(i % 2 == 0) + h.archived = 1; + else + habits.add(h); + + habitsWithArchived.add(h); + h.save(); + } + + assertThat(habits, equalTo(Habit.getAll(false))); + assertThat(habitsWithArchived, equalTo(Habit.getAll(true))); + } + + @Test + public void test_getByPosition() + { + List habits = new LinkedList<>(); + + for(int i = 0; i < 10; i++) + { + Habit h = new Habit(); + h.save(); + habits.add(h); + } + + for(int i = 0; i < 10; i++) + { + Habit h = Habit.getByPosition(i); + if(h == null) fail(); + assertThat(h, equalTo(habits.get(i))); + } + } + + @Test + public void test_count() + { + for(int i = 0; i < 10; i++) + { + Habit h = new Habit(); + if(i % 2 == 0) h.archived = 1; + h.save(); + } + + assertThat(Habit.count(), equalTo(5)); + } + + + @Test + public void test_countWithArchived() + { + for(int i = 0; i < 10; i++) + { + Habit h = new Habit(); + if(i % 2 == 0) h.archived = 1; + h.save(); + } + + assertThat(Habit.countWithArchived(), equalTo(10)); + } + + @Test + public void test_updateId() + { + Habit habit = new Habit(); + habit.name = "Hello World"; + habit.save(); + + Long oldId = habit.getId(); + Long newId = 123456L; + Habit.updateId(oldId, newId); + + Habit newHabit = Habit.get(newId); + if(newHabit == null) fail(); + assertThat(newHabit, is(not(nullValue()))); + assertThat(newHabit.name, equalTo(habit.name)); + } + + @Test + public void test_reorder() + { + List ids = new LinkedList<>(); + + int n = 10; + for (int i = 0; i < n; i++) + { + Habit h = new Habit(); + h.save(); + ids.add(h.getId()); + assertThat(h.position, is(i)); + } + + int operations[][] = { + {5, 2}, + {3, 7}, + {4, 4}, + {3, 2} + }; + + int expectedPosition[][] = { + {0, 1, 3, 4, 5, 2, 6, 7, 8, 9}, + {0, 1, 7, 3, 4, 2, 5, 6, 8, 9}, + {0, 1, 7, 3, 4, 2, 5, 6, 8, 9}, + {0, 1, 7, 2, 4, 3, 5, 6, 8, 9}, + }; + + for(int i = 0; i < operations.length; i++) + { + int from = operations[i][0]; + int to = operations[i][1]; + + Habit fromHabit = Habit.getByPosition(from); + Habit toHabit = Habit.getByPosition(to); + Habit.reorder(fromHabit, toHabit); + + int actualPositions[] = new int[n]; + + for (int j = 0; j < n; j++) + { + Habit h = Habit.get(ids.get(j)); + if (h == null) fail(); + actualPositions[j] = h.position; + } + + assertThat(actualPositions, equalTo(expectedPosition[i])); + } + } + + @Test + public void test_rebuildOrder() + { + List ids = new LinkedList<>(); + int originalPositions[] = { 0, 1, 1, 4, 6, 8, 10, 10, 13}; + + for (int p : originalPositions) + { + Habit h = new Habit(); + h.position = p; + h.save(); + ids.add(h.getId()); + } + + Habit.rebuildOrder(); + + for (int i = 0; i < originalPositions.length; i++) + { + Habit h = Habit.get(ids.get(i)); + if(h == null) fail(); + assertThat(h.position, is(i)); + } + } + + @Test + public void test_getHabitsWithReminder() + { + List habitsWithReminder = new LinkedList<>(); + + for(int i = 0; i < 10; i++) + { + Habit habit = new Habit(); + if(i % 2 == 0) + { + habit.reminderDays = DateHelper.ALL_WEEK_DAYS; + habit.reminderHour = 8; + habit.reminderMin = 30; + habitsWithReminder.add(habit); + } + habit.save(); + } + + assertThat(habitsWithReminder, equalTo(Habit.getHabitsWithReminder())); + } + + @Test + public void test_archive_unarchive() + { + List allHabits = new LinkedList<>(); + List archivedHabits = new LinkedList<>(); + List unarchivedHabits = new LinkedList<>(); + + for(int i = 0; i < 10; i++) + { + Habit habit = new Habit(); + habit.save(); + allHabits.add(habit); + + if(i % 2 == 0) + archivedHabits.add(habit); + else + unarchivedHabits.add(habit); + } + + Habit.archive(archivedHabits); + assertThat(Habit.getAll(false), equalTo(unarchivedHabits)); + assertThat(Habit.getAll(true), equalTo(allHabits)); + + Habit.unarchive(archivedHabits); + assertThat(Habit.getAll(false), equalTo(allHabits)); + assertThat(Habit.getAll(true), equalTo(allHabits)); + } + + @Test + public void test_setColor() + { + List habits = new LinkedList<>(); + + for(int i = 0; i < 10; i++) + { + Habit habit = new Habit(); + habit.color = i; + habit.save(); + habits.add(habit); + } + + int newColor = 100; + Habit.setColor(habits, newColor); + + for(Habit h : habits) + assertThat(h.color, equalTo(newColor)); + } + + @Test + public void test_hasReminder_clearReminder() + { + Habit h = new Habit(); + assertThat(h.hasReminder(), is(false)); + + h.reminderDays = DateHelper.ALL_WEEK_DAYS; + h.reminderHour = 8; + h.reminderMin = 30; + assertThat(h.hasReminder(), is(true)); + + h.clearReminder(); + assertThat(h.hasReminder(), is(false)); + } + + @Test + public void test_writeCSV() throws IOException + { + HabitFixtures.createEmptyHabit(); + HabitFixtures.createShortHabit(); + + String expectedCSV = + "Name,Description,NumRepetitions,Interval,Color\n" + + "Meditate,Did you meditate this morning?,1,1,#AFB42B\n" + + "Wake up early,Did you wake up before 6am?,2,3,#00897B\n"; + + StringWriter writer = new StringWriter(); + Habit.writeCSV(Habit.getAll(true), writer); + + MatcherAssert.assertThat(writer.toString(), equalTo(expectedCSV)); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/models/RepetitionListTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/models/RepetitionListTest.java new file mode 100644 index 000000000..2b152a25f --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/models/RepetitionListTest.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.models; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.models.Repetition; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.Random; + +import static junit.framework.Assert.assertFalse; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class RepetitionListTest extends BaseTest +{ + private Habit habit; + private Habit emptyHabit; + + @Before + public void setup() + { + super.setup(); + + HabitFixtures.purgeHabits(); + habit = HabitFixtures.createShortHabit(); + emptyHabit = HabitFixtures.createEmptyHabit(); + } + + @After + public void tearDown() + { + DateHelper.setFixedLocalTime(null); + } + + @Test + public void test_contains() + { + long current = DateHelper.getStartOfToday(); + + for(boolean b : HabitFixtures.NON_DAILY_HABIT_CHECKS) + { + assertThat(habit.repetitions.contains(current), equalTo(b)); + current -= DateHelper.millisecondsInOneDay; + } + + for(int i = 0; i < 3; i++) + { + assertThat(habit.repetitions.contains(current), equalTo(false)); + current -= DateHelper.millisecondsInOneDay; + } + } + + @Test + public void test_delete() + { + long timestamp = DateHelper.getStartOfToday(); + assertThat(habit.repetitions.contains(timestamp), equalTo(true)); + + habit.repetitions.delete(timestamp); + assertThat(habit.repetitions.contains(timestamp), equalTo(false)); + } + + @Test + public void test_toggle() + { + long timestamp = DateHelper.getStartOfToday(); + assertThat(habit.repetitions.contains(timestamp), equalTo(true)); + + habit.repetitions.toggle(timestamp); + assertThat(habit.repetitions.contains(timestamp), equalTo(false)); + + habit.repetitions.toggle(timestamp); + assertThat(habit.repetitions.contains(timestamp), equalTo(true)); + } + + @Test + public void test_getWeekDayFrequency() + { + Random random = new Random(); + Integer weekdayCount[][] = new Integer[12][7]; + Integer monthCount[] = new Integer[12]; + + Arrays.fill(monthCount, 0); + for(Integer row[] : weekdayCount) + Arrays.fill(row, 0); + + GregorianCalendar day = DateHelper.getStartOfTodayCalendar(); + + // Sets the current date to the end of November + day.set(2015, 10, 30); + DateHelper.setFixedLocalTime(day.getTimeInMillis()); + + // Add repetitions randomly from January to December + // Leaves the month of March empty, to check that it returns null + day.set(2015, 0, 1); + for(int i = 0; i < 365; i ++) + { + if(random.nextBoolean()) + { + int month = day.get(Calendar.MONTH); + int week = day.get(Calendar.DAY_OF_WEEK) % 7; + + if(month != 2) + { + if (month <= 10) + { + weekdayCount[month][week]++; + monthCount[month]++; + } + emptyHabit.repetitions.toggle(day.getTimeInMillis()); + } + } + + day.add(Calendar.DAY_OF_YEAR, 1); + } + + HashMap freq = emptyHabit.repetitions.getWeekdayFrequency(); + + // Repetitions until November should be counted correctly + for(int month = 0; month < 11; month++) + { + day.set(2015, month, 1); + Integer actualCount[] = freq.get(day.getTimeInMillis()); + if(monthCount[month] == 0) + assertThat(actualCount, equalTo(null)); + else + assertThat(actualCount, equalTo(weekdayCount[month])); + } + + // Repetitions in December should be discarded + day.set(2015, 11, 1); + assertThat(freq.get(day.getTimeInMillis()), equalTo(null)); + } + + @Test + public void test_count() + { + long to = DateHelper.getStartOfToday(); + long from = to - 9 * DateHelper.millisecondsInOneDay; + assertThat(habit.repetitions.count(from, to), equalTo(6)); + + to = DateHelper.getStartOfToday() - DateHelper.millisecondsInOneDay; + from = to - 5 * DateHelper.millisecondsInOneDay; + assertThat(habit.repetitions.count(from, to), equalTo(3)); + } + + @Test + public void test_getOldest() + { + long expectedOldestTimestamp = DateHelper.getStartOfToday() - 9 * DateHelper.millisecondsInOneDay; + + assertThat(habit.repetitions.getOldestTimestamp(), equalTo(expectedOldestTimestamp)); + + Repetition oldest = habit.repetitions.getOldest(); + assertFalse(oldest == null); + assertThat(oldest.timestamp, equalTo(expectedOldestTimestamp)); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/models/ScoreListTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/models/ScoreListTest.java new file mode 100644 index 000000000..25e99cffc --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/models/ScoreListTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.models; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.models.Score; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.io.StringWriter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ScoreListTest extends BaseTest +{ + private Habit habit; + + @Before + public void setup() + { + super.setup(); + + HabitFixtures.purgeHabits(); + habit = HabitFixtures.createEmptyHabit(); + } + + @After + public void tearDown() + { + DateHelper.setFixedLocalTime(null); + } + + @Test + public void test_invalidateNewerThan() + { + assertThat(habit.scores.getTodayValue(), equalTo(0)); + + toggleRepetitions(0, 2); + assertThat(habit.scores.getTodayValue(), equalTo(1948077)); + + habit.freqNum = 1; + habit.freqDen = 2; + habit.scores.invalidateNewerThan(0); + + assertThat(habit.scores.getTodayValue(), equalTo(1974654)); + } + + @Test + public void test_getTodayStarValue() + { + assertThat(habit.scores.getTodayStarStatus(), equalTo(Score.EMPTY_STAR)); + + int k = 0; + while(habit.scores.getTodayValue() < Score.HALF_STAR_CUTOFF) toggleRepetitions(k, ++k); + assertThat(habit.scores.getTodayStarStatus(), equalTo(Score.HALF_STAR)); + + while(habit.scores.getTodayValue() < Score.FULL_STAR_CUTOFF) toggleRepetitions(k, ++k); + assertThat(habit.scores.getTodayStarStatus(), equalTo(Score.FULL_STAR)); + } + + @Test + public void test_getTodayValue() + { + toggleRepetitions(0, 20); + assertThat(habit.scores.getTodayValue(), equalTo(12629351)); + } + + @Test + public void test_getValue() + { + toggleRepetitions(0, 20); + + int expectedValues[] = { 12629351, 12266245, 11883254, 11479288, 11053198, 10603773, + 10129735, 9629735, 9102352, 8546087, 7959357, 7340494, 6687738, 5999234, 5273023, + 4507040, 3699107, 2846927, 1948077, 1000000 }; + + long current = DateHelper.getStartOfToday(); + for(int expectedValue : expectedValues) + { + assertThat(habit.scores.getValue(current), equalTo(expectedValue)); + current -= DateHelper.millisecondsInOneDay; + } + } + + @Test + public void test_getAllValues_withoutGroups() + { + toggleRepetitions(0, 20); + + int expectedValues[] = { 12629351, 12266245, 11883254, 11479288, 11053198, 10603773, + 10129735, 9629735, 9102352, 8546087, 7959357, 7340494, 6687738, 5999234, 5273023, + 4507040, 3699107, 2846927, 1948077, 1000000 }; + + int actualValues[] = habit.scores.getAllValues(1); + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void test_getAllValues_withGroups() + { + toggleRepetitions(0, 20); + + int expectedValues[] = { 11434978, 7894999, 3212362 }; + + int actualValues[] = habit.scores.getAllValues(7); + assertThat(actualValues, equalTo(expectedValues)); + } + + @Test + public void test_writeCSV() throws IOException + { + HabitFixtures.purgeHabits(); + Habit habit = HabitFixtures.createShortHabit(); + + String expectedCSV = + "2015-01-16,0.0519\n" + + "2015-01-17,0.1021\n" + + "2015-01-18,0.0986\n" + + "2015-01-19,0.0952\n" + + "2015-01-20,0.1439\n" + + "2015-01-21,0.1909\n" + + "2015-01-22,0.2364\n" + + "2015-01-23,0.2283\n" + + "2015-01-24,0.2205\n" + + "2015-01-25,0.2649\n"; + + StringWriter writer = new StringWriter(); + habit.scores.writeCSV(writer); + + assertThat(writer.toString(), equalTo(expectedCSV)); + } + + private void toggleRepetitions(final int from, final int to) + { + DatabaseHelper.executeAsTransaction(new DatabaseHelper.Command() + { + @Override + public void execute() + { + long today = DateHelper.getStartOfToday(); + for (int i = from; i < to; i++) + habit.repetitions.toggle(today - i * DateHelper.millisecondsInOneDay); + } + }); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/models/ScoreTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/models/ScoreTest.java new file mode 100644 index 000000000..e6881b1b4 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/models/ScoreTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.models; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.models.Checkmark; +import org.isoron.uhabits.models.Score; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ScoreTest extends BaseTest +{ + @Before + public void setup() + { + super.setup(); + } + + @Test + public void test_compute_withDailyHabit() + { + int checkmark = Checkmark.UNCHECKED; + assertThat(Score.compute(1, 0, checkmark), equalTo(0)); + assertThat(Score.compute(1, 5000000, checkmark), equalTo(4740387)); + assertThat(Score.compute(1, 10000000, checkmark), equalTo(9480775)); + assertThat(Score.compute(1, Score.MAX_VALUE, checkmark), equalTo(18259478)); + + checkmark = Checkmark.CHECKED_IMPLICITLY; + assertThat(Score.compute(1, 0, checkmark), equalTo(0)); + assertThat(Score.compute(1, 5000000, checkmark), equalTo(4740387)); + assertThat(Score.compute(1, 10000000, checkmark), equalTo(9480775)); + assertThat(Score.compute(1, Score.MAX_VALUE, checkmark), equalTo(18259478)); + + checkmark = Checkmark.CHECKED_EXPLICITLY; + assertThat(Score.compute(1, 0, checkmark), equalTo(1000000)); + assertThat(Score.compute(1, 5000000, checkmark), equalTo(5740387)); + assertThat(Score.compute(1, 10000000, checkmark), equalTo(10480775)); + assertThat(Score.compute(1, Score.MAX_VALUE, checkmark), equalTo(Score.MAX_VALUE)); + } + + @Test + public void test_compute_withNonDailyHabit() + { + int checkmark = Checkmark.CHECKED_EXPLICITLY; + assertThat(Score.compute(1/3.0, 0, checkmark), equalTo(1000000)); + assertThat(Score.compute(1/3.0, 5000000, checkmark), equalTo(5916180)); + assertThat(Score.compute(1/3.0, 10000000, checkmark), equalTo(10832360)); + assertThat(Score.compute(1/3.0, Score.MAX_VALUE, checkmark), equalTo(Score.MAX_VALUE)); + + assertThat(Score.compute(1/7.0, 0, checkmark), equalTo(1000000)); + assertThat(Score.compute(1/7.0, 5000000, checkmark), equalTo(5964398)); + assertThat(Score.compute(1/7.0, 10000000, checkmark), equalTo(10928796)); + assertThat(Score.compute(1/7.0, Score.MAX_VALUE, checkmark), equalTo(Score.MAX_VALUE)); + } + + @Test + public void test_getStarStatus() + { + Score s = new Score(); + + s.score = Score.FULL_STAR_CUTOFF + 1; + assertThat(s.getStarStatus(), equalTo(Score.FULL_STAR)); + + s.score = Score.FULL_STAR_CUTOFF; + assertThat(s.getStarStatus(), equalTo(Score.FULL_STAR)); + + s.score = Score.FULL_STAR_CUTOFF - 1; + assertThat(s.getStarStatus(), equalTo(Score.HALF_STAR)); + + s.score = Score.HALF_STAR_CUTOFF + 1; + assertThat(s.getStarStatus(), equalTo(Score.HALF_STAR)); + + s.score = Score.HALF_STAR_CUTOFF; + assertThat(s.getStarStatus(), equalTo(Score.HALF_STAR)); + + s.score = Score.HALF_STAR_CUTOFF - 1; + assertThat(s.getStarStatus(), equalTo(Score.EMPTY_STAR)); + + s.score = 0; + assertThat(s.getStarStatus(), equalTo(Score.EMPTY_STAR)); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/tasks/ExportCSVTaskTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/tasks/ExportCSVTaskTest.java new file mode 100644 index 000000000..f827dddf4 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/tasks/ExportCSVTaskTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.tasks; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; +import android.widget.ProgressBar; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.tasks.ExportCSVTask; +import org.isoron.uhabits.unit.HabitFixtures; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.util.List; + +import static junit.framework.Assert.assertTrue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.core.IsNot.not; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ExportCSVTaskTest extends BaseTest +{ + @Before + public void setup() + { + super.setup(); + } + + @Test + public void testExportCSV() throws Throwable + { + HabitFixtures.createShortHabit(); + List habits = Habit.getAll(true); + ProgressBar bar = new ProgressBar(targetContext); + + ExportCSVTask task = new ExportCSVTask(habits, bar); + task.setListener(new ExportCSVTask.Listener() + { + @Override + public void onExportCSVFinished(String archiveFilename) + { + assertThat(archiveFilename, is(not(nullValue()))); + + File f = new File(archiveFilename); + assertTrue(f.exists()); + assertTrue(f.canRead()); + } + }); + + task.execute(); + waitForAsyncTasks(); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/tasks/ExportDBTaskTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/tasks/ExportDBTaskTest.java new file mode 100644 index 000000000..26269e353 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/tasks/ExportDBTaskTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.tasks; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; +import android.widget.ProgressBar; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.tasks.ExportDBTask; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; + +import static junit.framework.Assert.assertTrue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.core.IsNot.not; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ExportDBTaskTest extends BaseTest +{ + @Before + public void setup() + { + super.setup(); + } + + @Test + public void testExportCSV() throws Throwable + { + Context context = InstrumentationRegistry.getContext(); + + ProgressBar bar = new ProgressBar(context); + ExportDBTask task = new ExportDBTask(bar); + task.setListener(new ExportDBTask.Listener() + { + @Override + public void onExportDBFinished(String filename) + { + assertThat(filename, is(not(nullValue()))); + + File f = new File(filename); + assertTrue(f.exists()); + assertTrue(f.canRead()); + } + }); + + task.execute(); + waitForAsyncTasks(); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/tasks/ImportDataTaskTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/tasks/ImportDataTaskTest.java new file mode 100644 index 000000000..d6a3cabaa --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/tasks/ImportDataTaskTest.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.tasks; + +import android.support.annotation.NonNull; +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; +import android.widget.ProgressBar; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.tasks.ImportDataTask; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.fail; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ImportDataTaskTest extends BaseTest +{ + private File baseDir; + + @Before + public void setup() + { + super.setup(); + + baseDir = DatabaseHelper.getFilesDir("Backups"); + if(baseDir == null) fail("baseDir should not be null"); + } + + private void copyAssetToFile(String assetPath, File dst) throws IOException + { + InputStream in = testContext.getAssets().open(assetPath); + DatabaseHelper.copy(in, dst); + } + + private void assertTaskResult(final int expectedResult, String assetFilename) throws Throwable + { + ImportDataTask task = createTask(assetFilename); + + task.setListener(new ImportDataTask.Listener() + { + @Override + public void onImportFinished(int result) + { + assertThat(result, equalTo(expectedResult)); + } + }); + + task.execute(); + waitForAsyncTasks(); + } + + @NonNull + private ImportDataTask createTask(String assetFilename) throws IOException + { + ProgressBar bar = new ProgressBar(targetContext); + File file = new File(String.format("%s/%s", baseDir.getPath(), assetFilename)); + copyAssetToFile(assetFilename, file); + + return new ImportDataTask(file, bar); + } + + @Test + public void testImportInvalidData() throws Throwable + { + assertTaskResult(ImportDataTask.NOT_RECOGNIZED, "icon.png"); + } + + @Test + public void testImportValidData() throws Throwable + { + assertTaskResult(ImportDataTask.SUCCESS, "loop.db"); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/CheckmarkViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/CheckmarkViewTest.java new file mode 100644 index 000000000..637edb5ec --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/CheckmarkViewTest.java @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.isoron.uhabits.views.CheckmarkView; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class CheckmarkViewTest extends ViewTest +{ + private CheckmarkView view; + private Habit habit; + + @Before + public void setup() + { + super.setup(); + + habit = HabitFixtures.createShortHabit(); + view = new CheckmarkView(targetContext); + view.setHabit(habit); + refreshData(view); + measureView(dpToPixels(100), dpToPixels(200), view); + } + + @Test + public void testRender_checked() throws IOException + { + assertRenders(view, "CheckmarkView/checked.png"); + } + + @Test + public void testRender_unchecked() throws IOException + { + habit.repetitions.toggle(DateHelper.getStartOfToday()); + view.refreshData(); + + assertRenders(view, "CheckmarkView/unchecked.png"); + } + + @Test + public void testRender_implicitlyChecked() throws IOException + { + long today = DateHelper.getStartOfToday(); + long day = DateHelper.millisecondsInOneDay; + habit.repetitions.toggle(today); + habit.repetitions.toggle(today - day); + habit.repetitions.toggle(today - 2 * day); + view.refreshData(); + + assertRenders(view, "CheckmarkView/implicitly_checked.png"); + } + + @Test + public void testRender_largeSize() throws IOException + { + measureView(dpToPixels(300), dpToPixels(300), view); + assertRenders(view, "CheckmarkView/large_size.png"); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitFrequencyViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitFrequencyViewTest.java new file mode 100644 index 000000000..590e2c9d2 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitFrequencyViewTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.isoron.uhabits.views.HabitFrequencyView; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class HabitFrequencyViewTest extends ViewTest +{ + private HabitFrequencyView view; + + @Before + public void setup() + { + super.setup(); + + HabitFixtures.purgeHabits(); + Habit habit = HabitFixtures.createLongHabit(); + + view = new HabitFrequencyView(targetContext); + view.setHabit(habit); + refreshData(view); + measureView(dpToPixels(300), dpToPixels(100), view); + } + + @Test + public void testRender() throws Throwable + { + assertRenders(view, "HabitFrequencyView/render.png"); + } + + @Test + public void testRender_withTransparentBackground() throws Throwable + { + view.setIsBackgroundTransparent(true); + assertRenders(view, "HabitFrequencyView/renderTransparent.png"); + } + + @Test + public void testRender_withDifferentSize() throws Throwable + { + measureView(dpToPixels(200), dpToPixels(200), view); + assertRenders(view, "HabitFrequencyView/renderDifferentSize.png"); + } + + @Test + public void testRender_withDataOffset() throws Throwable + { + view.onScroll(null, null, -dpToPixels(150), 0); + view.invalidate(); + + assertRenders(view, "HabitFrequencyView/renderDataOffset.png"); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitHistoryViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitHistoryViewTest.java new file mode 100644 index 000000000..6748dc895 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitHistoryViewTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.isoron.uhabits.views.HabitHistoryView; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class HabitHistoryViewTest extends ViewTest +{ + private Habit habit; + private HabitHistoryView view; + + @Before + public void setup() + { + super.setup(); + + HabitFixtures.purgeHabits(); + habit = HabitFixtures.createLongHabit(); + + view = new HabitHistoryView(targetContext); + view.setHabit(habit); + measureView(dpToPixels(300), dpToPixels(100), view); + refreshData(view); + } + + @Test + public void testRender() throws Throwable + { + assertRenders(view, "HabitHistoryView/render.png"); + } + + @Test + public void testRender_withTransparentBackground() throws Throwable + { + view.setIsBackgroundTransparent(true); + assertRenders(view, "HabitHistoryView/renderTransparent.png"); + } + + @Test + public void testRender_withDifferentSize() throws Throwable + { + measureView(dpToPixels(200), dpToPixels(200), view); + assertRenders(view, "HabitHistoryView/renderDifferentSize.png"); + } + + @Test + public void testRender_withDataOffset() throws Throwable + { + view.onScroll(null, null, -dpToPixels(150), 0); + view.invalidate(); + + assertRenders(view, "HabitHistoryView/renderDataOffset.png"); + } + + @Test + public void tapDate_withEditableView() throws Throwable + { + view.setIsEditable(true); + tap(view, 270, 30); + waitForAsyncTasks(); + + long today = DateHelper.getStartOfToday(); + assertFalse(habit.repetitions.contains(today)); + } + + @Test + public void tapDate_atInvalidLocations() throws Throwable + { + int expectedCheckmarkValues[] = habit.checkmarks.getAllValues(); + + view.setIsEditable(true); + tap(view, 45, 5); // header + tap(view, 270, 43); // tomorrow's square + tap(view, 280, 30); // right axis + waitForAsyncTasks(); + + int actualCheckmarkValues[] = habit.checkmarks.getAllValues(); + assertThat(actualCheckmarkValues, equalTo(expectedCheckmarkValues)); + } + + @Test + public void tapDate_withReadOnlyView() throws Throwable + { + view.setIsEditable(false); + tap(view, 270, 30); + waitForAsyncTasks(); + + long today = DateHelper.getStartOfToday(); + assertTrue(habit.repetitions.contains(today)); + } + +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitScoreViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitScoreViewTest.java new file mode 100644 index 000000000..89ccdcdb6 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitScoreViewTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; +import android.util.Log; + +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.isoron.uhabits.views.HabitScoreView; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class HabitScoreViewTest extends ViewTest +{ + private Habit habit; + private HabitScoreView view; + + @Before + public void setup() + { + super.setup(); + + HabitFixtures.purgeHabits(); + habit = HabitFixtures.createLongHabit(); + + view = new HabitScoreView(targetContext); + view.setHabit(habit); + view.setBucketSize(7); + refreshData(view); + measureView(dpToPixels(300), dpToPixels(100), view); + } + + @Test + public void testRender() throws Throwable + { + Log.d("HabitScoreViewTest", String.format("height=%d", dpToPixels(100))); + assertRenders(view, "HabitScoreView/render.png"); + } + + @Test + public void testRender_withTransparentBackground() throws Throwable + { + view.setIsBackgroundTransparent(true); + assertRenders(view, "HabitScoreView/renderTransparent.png"); + } + + @Test + public void testRender_withDifferentSize() throws Throwable + { + measureView(dpToPixels(200), dpToPixels(200), view); + assertRenders(view, "HabitScoreView/renderDifferentSize.png"); + } + + @Test + public void testRender_withDataOffset() throws Throwable + { + view.onScroll(null, null, -dpToPixels(150), 0); + view.invalidate(); + + assertRenders(view, "HabitScoreView/renderDataOffset.png"); + } + + @Test + public void testRender_withMonthlyBucket() throws Throwable + { + view.setBucketSize(30); + view.refreshData(); + view.invalidate(); + + assertRenders(view, "HabitScoreView/renderMonthly.png"); + } + + @Test + public void testRender_withYearlyBucket() throws Throwable + { + view.setBucketSize(365); + view.refreshData(); + view.invalidate(); + + assertRenders(view, "HabitScoreView/renderYearly.png"); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitStreakViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitStreakViewTest.java new file mode 100644 index 000000000..ececee945 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/HabitStreakViewTest.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.unit.HabitFixtures; +import org.isoron.uhabits.views.HabitStreakView; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class HabitStreakViewTest extends ViewTest +{ + private HabitStreakView view; + + @Before + public void setup() + { + super.setup(); + + HabitFixtures.purgeHabits(); + Habit habit = HabitFixtures.createLongHabit(); + + view = new HabitStreakView(targetContext); + measureView(dpToPixels(300), dpToPixels(100), view); + + view.setHabit(habit); + refreshData(view); + } + + @Test + public void testRender() throws Throwable + { + assertRenders(view, "HabitStreakView/render.png"); + } + + @Test + public void testRender_withTransparentBackground() throws Throwable + { + view.setIsBackgroundTransparent(true); + assertRenders(view, "HabitStreakView/renderTransparent.png"); + } + + @Test + public void testRender_withSmallSize() throws Throwable + { + measureView(dpToPixels(100), dpToPixels(100), view); + refreshData(view); + + assertRenders(view, "HabitStreakView/renderSmallSize.png"); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/NumberViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/NumberViewTest.java new file mode 100644 index 000000000..93269befa --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/NumberViewTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.views.NumberView; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class NumberViewTest extends ViewTest +{ + private NumberView view; + + @Before + public void setup() + { + super.setup(); + + view = new NumberView(targetContext); + view.setLabel("Hello world"); + view.setNumber(31); + view.setColor(ColorHelper.palette[0]); + measureView(dpToPixels(100), dpToPixels(100), view); + } + + @Test + public void testRender_base() throws IOException + { + assertRenders(view, "NumberView/render.png"); + } + + @Test + public void testRender_withLongLabel() throws IOException + { + view.setLabel("The quick brown fox jumps over the lazy fox"); + + measureView(dpToPixels(100), dpToPixels(100), view); + assertRenders(view, "NumberView/renderLongLabel.png"); + } + + @Test + public void testRender_withDifferentParams() throws IOException + { + view.setNumber(500); + view.setColor(ColorHelper.palette[5]); + view.setTextSize(targetContext.getResources().getDimension(R.dimen.tinyTextSize)); + + measureView(dpToPixels(200), dpToPixels(200), view); + assertRenders(view, "NumberView/renderDifferentParams.png"); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/RingViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/RingViewTest.java new file mode 100644 index 000000000..d7fcb48d6 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/RingViewTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.support.test.runner.AndroidJUnit4; +import android.test.suitebuilder.annotation.SmallTest; + +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.views.RingView; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class RingViewTest extends ViewTest +{ + private RingView view; + + @Before + public void setup() + { + super.setup(); + + view = new RingView(targetContext); + view.setLabel("Hello world"); + view.setPercentage(0.6f); + view.setColor(ColorHelper.palette[0]); + view.setMaxDiameter(dpToPixels(100)); + } + + @Test + public void testRender_base() throws IOException + { + measureView(dpToPixels(100), dpToPixels(100), view); + assertRenders(view, "RingView/render.png"); + } + + @Test + public void testRender_withLongLabel() throws IOException + { + view.setLabel("The quick brown fox jumps over the lazy fox"); + + measureView(dpToPixels(100), dpToPixels(100), view); + assertRenders(view, "RingView/renderLongLabel.png"); + } + + @Test + public void testRender_withDifferentParams() throws IOException + { + view.setLabel("Habit Strength"); + view.setPercentage(0.25f); + view.setMaxDiameter(dpToPixels(50)); + view.setColor(ColorHelper.palette[5]); + + measureView(dpToPixels(200), dpToPixels(200), view); + assertRenders(view, "RingView/renderDifferentParams.png"); + } +} diff --git a/app/src/androidTest/java/org/isoron/uhabits/unit/views/ViewTest.java b/app/src/androidTest/java/org/isoron/uhabits/unit/views/ViewTest.java new file mode 100644 index 000000000..d5e69fa60 --- /dev/null +++ b/app/src/androidTest/java/org/isoron/uhabits/unit/views/ViewTest.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.unit.views; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.SystemClock; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; + +import org.isoron.uhabits.BaseTest; +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.helpers.UIHelper; +import org.isoron.uhabits.tasks.BaseTask; +import org.isoron.uhabits.views.HabitDataView; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import static junit.framework.Assert.fail; + +public class ViewTest extends BaseTest +{ + protected static final double SIMILARITY_CUTOFF = 0.09; + public static final int HISTOGRAM_BIN_SIZE = 8; + + protected void measureView(int width, int height, View view) + { + int specWidth = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY); + int specHeight = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY); + + view.measure(specWidth, specHeight); + view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); + } + + protected void assertRenders(View view, String expectedImagePath) throws IOException + { + StringBuilder errorMessage = new StringBuilder(); + expectedImagePath = getVersionedViewAssetPath(expectedImagePath); + + view.setDrawingCacheEnabled(true); + view.buildDrawingCache(); + Bitmap actual = view.getDrawingCache(); + Bitmap expected = getBitmapFromAssets(expectedImagePath); + + int width = actual.getWidth(); + int height = actual.getHeight(); + Bitmap scaledExpected = Bitmap.createScaledBitmap(expected, width, height, true); + + double distance; + boolean similarEnough = true; + + if ((distance = compareHistograms(getHistogram(actual), getHistogram(scaledExpected))) > SIMILARITY_CUTOFF) + { + similarEnough = false; + errorMessage.append(String.format( + "Rendered image has wrong histogram (distance=%f). ", + distance)); + } + + if(!similarEnough) + { + saveBitmap(expectedImagePath, ".scaledExpected", scaledExpected); + String path = saveBitmap(expectedImagePath, ".actual", actual); + errorMessage.append(String.format("Actual rendered image " + "saved to %s", path)); + fail(errorMessage.toString()); + } + + actual.recycle(); + expected.recycle(); + scaledExpected.recycle(); + } + + private Bitmap getBitmapFromAssets(String path) throws IOException + { + InputStream stream = testContext.getAssets().open(path); + return BitmapFactory.decodeStream(stream); + } + + private String getVersionedViewAssetPath(String path) + { + String result = null; + + if (android.os.Build.VERSION.SDK_INT >= 21) + { + try + { + String vpath = "views-v21/" + path; + testContext.getAssets().open(vpath); + result = vpath; + } + catch (IOException e) + { + // ignored + } + } + + if(result == null) + result = "views/" + path; + + return result; + } + + private String saveBitmap(String filename, String suffix, Bitmap bitmap) + throws IOException + { + File dir = DatabaseHelper.getSDCardDir("test-screenshots"); + if(dir == null) dir = DatabaseHelper.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; + } + + private int[][] getHistogram(Bitmap bitmap) + { + int histogram[][] = new int[4][256 / HISTOGRAM_BIN_SIZE]; + + for(int x = 0; x < bitmap.getWidth(); x++) + { + for(int y = 0; y < bitmap.getHeight(); y++) + { + int color = bitmap.getPixel(x, y); + int[] argb = new int[]{ + (color >> 24) & 0xff, //alpha + (color >> 16) & 0xff, //red + (color >> 8) & 0xff, //green + (color ) & 0xff //blue + }; + + histogram[0][argb[0] / HISTOGRAM_BIN_SIZE]++; + histogram[1][argb[1] / HISTOGRAM_BIN_SIZE]++; + histogram[2][argb[2] / HISTOGRAM_BIN_SIZE]++; + histogram[3][argb[3] / HISTOGRAM_BIN_SIZE]++; + } + } + + return histogram; + } + + private double compareHistograms(int[][] actualHistogram, int[][] expectedHistogram) + { + long diff = 0; + long total = 0; + + for(int i = 0; i < 256 / HISTOGRAM_BIN_SIZE; i ++) + { + diff += Math.abs(actualHistogram[0][i] - expectedHistogram[0][i]); + diff += Math.abs(actualHistogram[1][i] - expectedHistogram[1][i]); + diff += Math.abs(actualHistogram[2][i] - expectedHistogram[2][i]); + diff += Math.abs(actualHistogram[3][i] - expectedHistogram[3][i]); + + total += actualHistogram[0][i]; + total += actualHistogram[1][i]; + total += actualHistogram[2][i]; + total += actualHistogram[3][i]; + } + + return (double) diff / total / 2; + } + + protected int dpToPixels(int dp) + { + return (int) UIHelper.dpToPixels(targetContext, dp); + } + + protected void tap(GestureDetector.OnGestureListener view, int x, int y) throws InterruptedException + { + long now = SystemClock.uptimeMillis(); + MotionEvent e = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, dpToPixels(x), + dpToPixels(y), 0); + view.onSingleTapUp(e); + e.recycle(); + } + + protected void refreshData(final HabitDataView view) + { + new BaseTask() + { + @Override + protected void doInBackground() + { + view.refreshData(); + } + }.execute(); + + try + { + waitForAsyncTasks(); + } + catch (Exception e) + { + throw new RuntimeException("Time out"); + } + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..be52ed256 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1ae537237..18e1367b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,21 +21,21 @@ + android:versionCode="14" + android:versionName="1.4.0"> + android:maxSdkVersion="18" /> + android:maxSdkVersion="18" /> - - diff --git a/app/src/main/assets/migrations/13.sql b/app/src/main/assets/migrations/13.sql new file mode 100644 index 000000000..1d7eeafcf --- /dev/null +++ b/app/src/main/assets/migrations/13.sql @@ -0,0 +1,4 @@ +create index idx_score_habit_timestamp on score(habit, timestamp); +create index idx_checkmark_habit_timestamp on checkmarks(habit, timestamp); +create index idx_repetitions_habit_timestamp on repetitions(habit, timestamp); +create index idx_streak_habit_end on streak(habit, end); \ No newline at end of file diff --git a/app/src/main/java/org/isoron/helpers/DialogHelper.java b/app/src/main/java/org/isoron/helpers/DialogHelper.java deleted file mode 100644 index e65a50430..000000000 --- a/app/src/main/java/org/isoron/helpers/DialogHelper.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (C) 2016 Álinson Santos Xavier - * - * 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 . - */ - -package org.isoron.helpers; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.Resources; -import android.graphics.Typeface; -import android.os.Vibrator; -import android.preference.PreferenceManager; -import android.util.AttributeSet; -import android.util.DisplayMetrics; -import android.view.View; -import android.view.inputmethod.InputMethodManager; - -import org.isoron.uhabits.BuildConfig; - -public abstract class DialogHelper -{ - - public static final String ISORON_NAMESPACE = "http://isoron.org/android"; - private static Typeface fontawesome; - - public interface OnSavedListener - { - void onSaved(Command command, Object savedObject); - } - - public static void showSoftKeyboard(View view) - { - InputMethodManager imm = (InputMethodManager) view.getContext() - .getSystemService(Context.INPUT_METHOD_SERVICE); - imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT); - } - - public static void vibrate(Context context, int duration) - { - Vibrator vb = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); - vb.vibrate(duration); - } - - - public static void incrementLaunchCount(Context context) - { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - int count = prefs.getInt("launch_count", 0); - prefs.edit().putInt("launch_count", count + 1).apply(); - } - - public static void updateLastAppVersion(Context context) - { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - prefs.edit().putInt("last_version", BuildConfig.VERSION_CODE).apply(); - } - - public static int getLaunchCount(Context context) - { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - return prefs.getInt("launch_count", 0); - } - - public static String getAttribute(Context context, AttributeSet attrs, String name) - { - int resId = attrs.getAttributeResourceValue(ISORON_NAMESPACE, name, 0); - - if(resId != 0) - return context.getResources().getString(resId); - else - return attrs.getAttributeValue(ISORON_NAMESPACE, name); - } - - public static float dpToPixels(Context context, float dp) - { - Resources resources = context.getResources(); - DisplayMetrics metrics = resources.getDisplayMetrics(); - return dp * (metrics.densityDpi / DisplayMetrics.DENSITY_DEFAULT); - } -} diff --git a/app/src/main/java/org/isoron/uhabits/AboutActivity.java b/app/src/main/java/org/isoron/uhabits/AboutActivity.java index 20af24efe..3f84528e1 100644 --- a/app/src/main/java/org/isoron/uhabits/AboutActivity.java +++ b/app/src/main/java/org/isoron/uhabits/AboutActivity.java @@ -28,7 +28,7 @@ import android.os.Bundle; import android.view.View; import android.widget.TextView; -import org.isoron.helpers.ColorHelper; +import org.isoron.uhabits.helpers.ColorHelper; public class AboutActivity extends Activity implements View.OnClickListener { @@ -68,7 +68,7 @@ public class AboutActivity extends Activity implements View.OnClickListener { Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); - intent.setData(Uri.parse("market://details?id=org.isoron.uhabits")); + intent.setData(Uri.parse(getString(R.string.playStoreURL))); startActivity(intent); break; } @@ -77,8 +77,7 @@ public class AboutActivity extends Activity implements View.OnClickListener { Intent intent = new Intent(); intent.setAction(Intent.ACTION_SENDTO); - intent.setData(Uri.parse("mailto:isoron+habits@gmail.com?" + - "subject=Feedback%20about%20Loop%20Habit%20Tracker")); + intent.setData(Uri.parse(getString(R.string.feedbackURL))); startActivity(intent); break; } @@ -87,7 +86,7 @@ public class AboutActivity extends Activity implements View.OnClickListener { Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); - intent.setData(Uri.parse("https://github.com/iSoron/uhabits")); + intent.setData(Uri.parse(getString(R.string.sourceCodeURL))); startActivity(intent); break; } diff --git a/app/src/main/java/org/isoron/helpers/ReplayableActivity.java b/app/src/main/java/org/isoron/uhabits/BaseActivity.java similarity index 77% rename from app/src/main/java/org/isoron/helpers/ReplayableActivity.java rename to app/src/main/java/org/isoron/uhabits/BaseActivity.java index d2fcdc350..4c8b2b75a 100644 --- a/app/src/main/java/org/isoron/helpers/ReplayableActivity.java +++ b/app/src/main/java/org/isoron/uhabits/BaseActivity.java @@ -17,7 +17,7 @@ * with this program. If not, see . */ -package org.isoron.helpers; +package org.isoron.uhabits; import android.app.Activity; import android.app.backup.BackupManager; @@ -25,11 +25,11 @@ import android.os.AsyncTask; import android.os.Bundle; import android.widget.Toast; -import org.isoron.uhabits.R; +import org.isoron.uhabits.commands.Command; import java.util.LinkedList; -abstract public class ReplayableActivity extends Activity +abstract public class BaseActivity extends Activity implements Thread.UncaughtExceptionHandler { private static int MAX_UNDO_LEVEL = 15; @@ -37,11 +37,16 @@ abstract public class ReplayableActivity extends Activity private LinkedList redoList; private Toast toast; + Thread.UncaughtExceptionHandler androidExceptionHandler; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + androidExceptionHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(this); + undoList = new LinkedList<>(); redoList = new LinkedList<>(); } @@ -103,7 +108,7 @@ abstract public class ReplayableActivity extends Activity @Override protected void onPostExecute(Void aVoid) { - ReplayableActivity.this.onPostExecuteCommand(refreshKey); + BaseActivity.this.onPostExecuteCommand(refreshKey); BackupManager.dataChanged("org.isoron.uhabits"); } }.execute(); @@ -115,4 +120,23 @@ abstract public class ReplayableActivity extends Activity public void onPostExecuteCommand(Long refreshKey) { } + + @Override + public void uncaughtException(Thread thread, Throwable ex) + { + try + { + ex.printStackTrace(); + HabitsApplication.generateLogFile(); + } + catch(Exception e) + { + // ignored + } + + if(androidExceptionHandler != null) + androidExceptionHandler.uncaughtException(thread, ex); + else + System.exit(1); + } } diff --git a/app/src/main/java/org/isoron/uhabits/HabitBroadcastReceiver.java b/app/src/main/java/org/isoron/uhabits/HabitBroadcastReceiver.java index 4546bdd24..057aedfba 100644 --- a/app/src/main/java/org/isoron/uhabits/HabitBroadcastReceiver.java +++ b/app/src/main/java/org/isoron/uhabits/HabitBroadcastReceiver.java @@ -36,9 +36,11 @@ import android.preference.PreferenceManager; import android.support.v4.app.NotificationCompat; import android.support.v4.content.LocalBroadcastManager; -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.helpers.DateHelper; import org.isoron.uhabits.helpers.ReminderHelper; +import org.isoron.uhabits.models.Checkmark; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.tasks.BaseTask; import java.util.Date; @@ -124,11 +126,7 @@ public class HabitBroadcastReceiver extends BroadcastReceiver private void dismissAllHabits() { - for (Habit h : Habit.getHighlightedHabits()) - { - h.highlight = 0; - h.save(); - } + } private void dismissNotification(Context context, Long habitId) @@ -141,62 +139,76 @@ public class HabitBroadcastReceiver extends BroadcastReceiver } - private void createNotification(Context context, Intent intent) + private void createNotification(final Context context, final Intent intent) { - Uri data = intent.getData(); - Habit habit = Habit.get(ContentUris.parseId(data)); - Long timestamp = intent.getLongExtra("timestamp", DateHelper.getStartOfToday()); - Long reminderTime = intent.getLongExtra("reminderTime", DateHelper.getStartOfToday()); + final Uri data = intent.getData(); + final Habit habit = Habit.get(ContentUris.parseId(data)); + final Long timestamp = intent.getLongExtra("timestamp", DateHelper.getStartOfToday()); + final Long reminderTime = intent.getLongExtra("reminderTime", DateHelper.getStartOfToday()); if (habit == null) return; - if (habit.repetitions.hasImplicitRepToday()) return; - - habit.highlight = 1; - habit.save(); - - if (!checkWeekday(intent, habit)) return; - - // Check if reminder has been turned off after alarm was scheduled - if (habit.reminderHour == null) return; - - Intent contentIntent = new Intent(context, MainActivity.class); - contentIntent.setData(data); - PendingIntent contentPendingIntent = - PendingIntent.getActivity(context, 0, contentIntent, 0); - - PendingIntent dismissPendingIntent = buildDismissIntent(context); - PendingIntent checkIntentPending = buildCheckIntent(context, habit, timestamp); - PendingIntent snoozeIntentPending = buildSnoozeIntent(context, habit); - - Uri soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); - - NotificationCompat.WearableExtender wearableExtender = - new NotificationCompat.WearableExtender().setBackground( - BitmapFactory.decodeResource(context.getResources(), R.drawable.stripe)); - - Notification notification = - new NotificationCompat.Builder(context).setSmallIcon(R.drawable.ic_notification) - .setContentTitle(habit.name) - .setContentText(habit.description) - .setContentIntent(contentPendingIntent) - .setDeleteIntent(dismissPendingIntent) - .addAction(R.drawable.ic_action_check, - context.getString(R.string.check), checkIntentPending) - .addAction(R.drawable.ic_action_snooze, - context.getString(R.string.snooze), snoozeIntentPending) - .setSound(soundUri) - .extend(wearableExtender) - .setWhen(reminderTime) - .setShowWhen(true) - .build(); - notification.flags |= Notification.FLAG_AUTO_CANCEL; + new BaseTask() + { + int todayValue; - NotificationManager notificationManager = - (NotificationManager) context.getSystemService(Activity.NOTIFICATION_SERVICE); + @Override + protected void doInBackground() + { + todayValue = habit.checkmarks.getTodayValue(); + } - int notificationId = (int) (habit.getId() % Integer.MAX_VALUE); - notificationManager.notify(notificationId, notification); + @Override + protected void onPostExecute(Void aVoid) + { + if (todayValue != Checkmark.UNCHECKED) return; + if (!checkWeekday(intent, habit)) return; + + // Check if reminder has been turned off after alarm was scheduled + if (habit.reminderHour == null) return; + + Intent contentIntent = new Intent(context, MainActivity.class); + contentIntent.setData(data); + PendingIntent contentPendingIntent = + PendingIntent.getActivity(context, 0, contentIntent, 0); + + PendingIntent dismissPendingIntent = buildDismissIntent(context); + PendingIntent checkIntentPending = buildCheckIntent(context, habit, timestamp); + PendingIntent snoozeIntentPending = buildSnoozeIntent(context, habit); + + Uri soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); + + NotificationCompat.WearableExtender wearableExtender = + new NotificationCompat.WearableExtender().setBackground( + BitmapFactory.decodeResource(context.getResources(), R.drawable.stripe)); + + Notification notification = + new NotificationCompat.Builder(context).setSmallIcon(R.drawable.ic_notification) + .setContentTitle(habit.name) + .setContentText(habit.description) + .setContentIntent(contentPendingIntent) + .setDeleteIntent(dismissPendingIntent) + .addAction(R.drawable.ic_action_check, + context.getString(R.string.check), checkIntentPending) + .addAction(R.drawable.ic_action_snooze, + context.getString(R.string.snooze), snoozeIntentPending) + .setSound(soundUri) + .extend(wearableExtender) + .setWhen(reminderTime) + .setShowWhen(true) + .build(); + + notification.flags |= Notification.FLAG_AUTO_CANCEL; + + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Activity.NOTIFICATION_SERVICE); + + int notificationId = (int) (habit.getId() % Integer.MAX_VALUE); + notificationManager.notify(notificationId, notification); + + super.onPostExecute(aVoid); + } + }.execute(); } public static PendingIntent buildSnoozeIntent(Context context, Habit habit) @@ -225,6 +237,13 @@ public class HabitBroadcastReceiver extends BroadcastReceiver return PendingIntent.getBroadcast(context, 0, deleteIntent, 0); } + public static PendingIntent buildViewHabitIntent(Context context, Habit habit) + { + Intent intent = new Intent(context, ShowHabitActivity.class); + intent.setData(Uri.parse("content://org.isoron.uhabits/habit/" + habit.getId())); + return PendingIntent.getActivity(context, 0, intent, 0); + } + private boolean checkWeekday(Intent intent, Habit habit) { Long timestamp = intent.getLongExtra("timestamp", DateHelper.getStartOfToday()); diff --git a/app/src/main/java/org/isoron/uhabits/HabitsApplication.java b/app/src/main/java/org/isoron/uhabits/HabitsApplication.java new file mode 100644 index 000000000..27a8fbf43 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/HabitsApplication.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits; + +import android.app.Application; +import android.content.Context; +import android.os.Environment; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.WindowManager; + +import com.activeandroid.ActiveAndroid; + +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.helpers.DateHelper; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStreamReader; + +public class HabitsApplication extends Application +{ + @Nullable + private static Context context; + + public static boolean isTestMode() + { + try + { + if(context != null) + context.getClassLoader().loadClass("org.isoron.uhabits.unit.models.HabitTest"); + return true; + } + catch (final Exception e) + { + return false; + } + } + + @Nullable + public static Context getContext() + { + return context; + } + + @Override + public void onCreate() + { + super.onCreate(); + HabitsApplication.context = this; + + if (isTestMode()) + { + File db = DatabaseHelper.getDatabaseFile(); + if(db.exists()) db.delete(); + } + + DatabaseHelper.initializeActiveAndroid(); + } + + @Override + public void onTerminate() + { + HabitsApplication.context = null; + ActiveAndroid.dispose(); + super.onTerminate(); + } + + public static String getLogcat() throws IOException + { + StringBuilder builder = new StringBuilder(); + + String[] command = new String[] { "logcat", "-d"}; + Process process = Runtime.getRuntime().exec(command); + + InputStreamReader in = new InputStreamReader(process.getInputStream()); + BufferedReader bufferedReader = new BufferedReader(in); + + String line; + while ((line = bufferedReader.readLine()) != null) + { + builder.append(line); + builder.append('\n'); + } + + return builder.toString(); + } + + public static String getDeviceInfo() + { + if(context == null) return ""; + + StringBuilder b = new StringBuilder(); + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + + b.append(String.format("App Version Name: %s\n", BuildConfig.VERSION_NAME)); + b.append(String.format("App Version Code: %s\n", BuildConfig.VERSION_CODE)); + b.append(String.format("OS Version: %s (%s)\n", System.getProperty("os.version"), + android.os.Build.VERSION.INCREMENTAL)); + b.append(String.format("OS API Level: %s\n", android.os.Build.VERSION.SDK)); + b.append(String.format("Device: %s\n", android.os.Build.DEVICE)); + b.append(String.format("Model (Product): %s (%s)\n", android.os.Build.MODEL, + android.os.Build.PRODUCT)); + b.append(String.format("Manufacturer: %s\n", android.os.Build.MANUFACTURER)); + b.append(String.format("Other tags: %s\n", android.os.Build.TAGS)); + b.append(String.format("Screen Width: %s\n", wm.getDefaultDisplay().getWidth())); + b.append(String.format("Screen Height: %s\n", wm.getDefaultDisplay().getHeight())); + b.append(String.format("SD Card state: %s\n\n", Environment.getExternalStorageState())); + + return b.toString(); + } + + @NonNull + public static File generateLogFile() throws IOException + { + String logcat = getLogcat(); + String deviceInfo = getDeviceInfo(); + String date = DateHelper.getBackupDateFormat().format(DateHelper.getLocalTime()); + + if(context == null) throw new RuntimeException("application context should not be null"); + File dir = DatabaseHelper.getFilesDir("Logs"); + if (dir == null) throw new IOException("log dir should not be null"); + + File logFile = new File(String.format("%s/Log %s.txt", dir.getPath(), date)); + FileWriter output = new FileWriter(logFile); + output.write(deviceInfo); + output.write(logcat); + output.close(); + + return logFile; + } +} diff --git a/app/src/main/java/org/isoron/uhabits/MainActivity.java b/app/src/main/java/org/isoron/uhabits/MainActivity.java index 256761aba..760ad134e 100644 --- a/app/src/main/java/org/isoron/uhabits/MainActivity.java +++ b/app/src/main/java/org/isoron/uhabits/MainActivity.java @@ -26,27 +26,32 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.v4.content.LocalBroadcastManager; import android.view.Menu; import android.view.MenuItem; -import org.isoron.helpers.DateHelper; -import org.isoron.helpers.DialogHelper; -import org.isoron.helpers.ReplayableActivity; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.helpers.UIHelper; import org.isoron.uhabits.fragments.ListHabitsFragment; import org.isoron.uhabits.helpers.ReminderHelper; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.tasks.BaseTask; import org.isoron.uhabits.widgets.CheckmarkWidgetProvider; import org.isoron.uhabits.widgets.FrequencyWidgetProvider; import org.isoron.uhabits.widgets.HistoryWidgetProvider; import org.isoron.uhabits.widgets.ScoreWidgetProvider; import org.isoron.uhabits.widgets.StreakWidgetProvider; -public class MainActivity extends ReplayableActivity +import java.io.File; +import java.io.IOException; + +public class MainActivity extends BaseActivity implements ListHabitsFragment.OnHabitClickListener { private ListHabitsFragment listHabitsFragment; @@ -56,6 +61,11 @@ public class MainActivity extends ReplayableActivity public static final String ACTION_REFRESH = "org.isoron.uhabits.ACTION_REFRESH"; + public static final int RESULT_IMPORT_DATA = 1; + public static final int RESULT_EXPORT_CSV = 2; + public static final int RESULT_EXPORT_DB = 3; + public static final int RESULT_BUG_REPORT = 4; + @Override protected void onCreate(Bundle savedInstanceState) { @@ -76,8 +86,8 @@ public class MainActivity extends ReplayableActivity private void onStartup() { PreferenceManager.setDefaultValues(this, R.xml.preferences, false); - DialogHelper.incrementLaunchCount(this); - DialogHelper.updateLastAppVersion(this); + UIHelper.incrementLaunchCount(this); + UIHelper.updateLastAppVersion(this); showTutorial(); new AsyncTask() { @@ -123,7 +133,7 @@ public class MainActivity extends ReplayableActivity case R.id.action_settings: { Intent intent = new Intent(this, SettingsActivity.class); - startActivity(intent); + startActivityForResult(intent, 0); return true; } @@ -134,11 +144,62 @@ public class MainActivity extends ReplayableActivity return true; } + case R.id.action_faq: + { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setData(Uri.parse(getString(R.string.helpURL))); + startActivity(intent); + return true; + } + default: return super.onOptionsItemSelected(item); } } + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) + { + switch (resultCode) + { + case RESULT_IMPORT_DATA: + listHabitsFragment.showImportDialog(); + break; + + case RESULT_EXPORT_CSV: + listHabitsFragment.exportAllHabits(); + break; + + case RESULT_EXPORT_DB: + listHabitsFragment.exportDB(); + break; + + case RESULT_BUG_REPORT: + generateBugReport(); + break; + } + } + + private void generateBugReport() + { + try + { + File logFile = HabitsApplication.generateLogFile(); + + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_SENDTO); + intent.setData(Uri.parse(getString(R.string.bugReportURL))); + intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(logFile)); + startActivity(intent); + } + catch (IOException e) + { + e.printStackTrace(); + showToast(R.string.bug_report_failed); + } + } + @Override public void onHabitClicked(Habit habit) { @@ -152,15 +213,14 @@ public class MainActivity extends ReplayableActivity { listHabitsFragment.onPostExecuteCommand(refreshKey); - new AsyncTask() + new BaseTask() { @Override - protected Void doInBackground(Void... params) + protected void doInBackground() { updateWidgets(MainActivity.this); - return null; } - }; + }.execute(); } public static void updateWidgets(Context context) @@ -197,4 +257,14 @@ public class MainActivity extends ReplayableActivity listHabitsFragment.onPostExecuteCommand(null); } } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) + { + if (grantResults.length <= 0) return; + if (grantResults[0] != PackageManager.PERMISSION_GRANTED) return; + + listHabitsFragment.showImportDialog(); + } } diff --git a/app/src/main/java/org/isoron/uhabits/ShowHabitActivity.java b/app/src/main/java/org/isoron/uhabits/ShowHabitActivity.java index 041685e6e..4fa707bd9 100644 --- a/app/src/main/java/org/isoron/uhabits/ShowHabitActivity.java +++ b/app/src/main/java/org/isoron/uhabits/ShowHabitActivity.java @@ -20,28 +20,16 @@ package org.isoron.uhabits; import android.app.ActionBar; -import android.content.BroadcastReceiver; import android.content.ContentUris; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; import android.graphics.drawable.ColorDrawable; import android.net.Uri; import android.os.Bundle; -import android.support.v4.content.LocalBroadcastManager; -import org.isoron.helpers.ReplayableActivity; -import org.isoron.uhabits.fragments.ShowHabitFragment; import org.isoron.uhabits.models.Habit; -public class ShowHabitActivity extends ReplayableActivity +public class ShowHabitActivity extends BaseActivity { - - public Habit habit; - private Receiver receiver; - private LocalBroadcastManager localBroadcastManager; - - private ShowHabitFragment fragment; + private Habit habit; @Override protected void onCreate(Bundle savedInstanceState) @@ -52,37 +40,18 @@ public class ShowHabitActivity extends ReplayableActivity habit = Habit.get(ContentUris.parseId(data)); ActionBar actionBar = getActionBar(); - if(actionBar != null) + if(actionBar != null && getHabit() != null) { - actionBar.setTitle(habit.name); - + actionBar.setTitle(getHabit().name); if (android.os.Build.VERSION.SDK_INT >= 21) - actionBar.setBackgroundDrawable(new ColorDrawable(habit.color)); + actionBar.setBackgroundDrawable(new ColorDrawable(getHabit().color)); } setContentView(R.layout.show_habit_activity); - - fragment = (ShowHabitFragment) getFragmentManager().findFragmentById(R.id.fragment2); - - receiver = new Receiver(); - localBroadcastManager = LocalBroadcastManager.getInstance(this); - localBroadcastManager.registerReceiver(receiver, - new IntentFilter(MainActivity.ACTION_REFRESH)); } - class Receiver extends BroadcastReceiver - { - @Override - public void onReceive(Context context, Intent intent) - { - fragment.refreshData(); - } - } - - @Override - protected void onDestroy() + public Habit getHabit() { - localBroadcastManager.unregisterReceiver(receiver); - super.onDestroy(); + return habit; } } diff --git a/app/src/main/java/org/isoron/uhabits/commands/ArchiveHabitsCommand.java b/app/src/main/java/org/isoron/uhabits/commands/ArchiveHabitsCommand.java index 3aee23dc1..25e998b7b 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/ArchiveHabitsCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/ArchiveHabitsCommand.java @@ -19,11 +19,9 @@ package org.isoron.uhabits.commands; -import org.isoron.helpers.Command; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; -import java.util.LinkedList; import java.util.List; public class ArchiveHabitsCommand extends Command @@ -31,12 +29,6 @@ public class ArchiveHabitsCommand extends Command private List habits; - public ArchiveHabitsCommand(Habit habit) - { - habits = new LinkedList<>(); - habits.add(habit); - } - public ArchiveHabitsCommand(List habits) { this.habits = habits; @@ -45,15 +37,13 @@ public class ArchiveHabitsCommand extends Command @Override public void execute() { - for(Habit h : habits) - h.archive(); + Habit.archive(habits); } @Override public void undo() { - for(Habit h : habits) - h.unarchive(); + Habit.unarchive(habits); } public Integer getExecuteStringId() diff --git a/app/src/main/java/org/isoron/uhabits/commands/ChangeHabitColorCommand.java b/app/src/main/java/org/isoron/uhabits/commands/ChangeHabitColorCommand.java index f9b00ba89..04ba83d7d 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/ChangeHabitColorCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/ChangeHabitColorCommand.java @@ -21,8 +21,8 @@ package org.isoron.uhabits.commands; import com.activeandroid.ActiveAndroid; -import org.isoron.helpers.Command; import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.DatabaseHelper; import org.isoron.uhabits.models.Habit; import java.util.ArrayList; @@ -47,44 +47,25 @@ public class ChangeHabitColorCommand extends Command @Override public void execute() { - ActiveAndroid.beginTransaction(); - - try - { - for(Habit h : habits) - { - h.color = newColor; - h.save(); - } - - ActiveAndroid.setTransactionSuccessful(); - } - finally - { - ActiveAndroid.endTransaction(); - } + Habit.setColor(habits, newColor); } @Override public void undo() { - ActiveAndroid.beginTransaction(); - - try + DatabaseHelper.executeAsTransaction(new DatabaseHelper.Command() { - int k = 0; - for(Habit h : habits) + @Override + public void execute() { - h.color = originalColors.get(k++); - h.save(); + int k = 0; + for(Habit h : habits) + { + h.color = originalColors.get(k++); + h.save(); + } } - - ActiveAndroid.setTransactionSuccessful(); - } - finally - { - ActiveAndroid.endTransaction(); - } + }); } public Integer getExecuteStringId() diff --git a/app/src/main/java/org/isoron/helpers/Command.java b/app/src/main/java/org/isoron/uhabits/commands/Command.java similarity index 96% rename from app/src/main/java/org/isoron/helpers/Command.java rename to app/src/main/java/org/isoron/uhabits/commands/Command.java index 7472b096f..b9427e38a 100644 --- a/app/src/main/java/org/isoron/helpers/Command.java +++ b/app/src/main/java/org/isoron/uhabits/commands/Command.java @@ -17,7 +17,7 @@ * with this program. If not, see . */ -package org.isoron.helpers; +package org.isoron.uhabits.commands; public abstract class Command { diff --git a/app/src/main/java/org/isoron/uhabits/commands/CreateHabitCommand.java b/app/src/main/java/org/isoron/uhabits/commands/CreateHabitCommand.java index e3fba3e35..7cc9ad51c 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/CreateHabitCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/CreateHabitCommand.java @@ -19,7 +19,6 @@ package org.isoron.uhabits.commands; -import org.isoron.helpers.Command; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; @@ -51,7 +50,10 @@ public class CreateHabitCommand extends Command @Override public void undo() { - Habit.get(savedId).delete(); + Habit habit = Habit.get(savedId); + if(habit == null) throw new RuntimeException("Habit not found"); + + habit.cascadeDelete(); } @Override diff --git a/app/src/main/java/org/isoron/uhabits/commands/DeleteHabitsCommand.java b/app/src/main/java/org/isoron/uhabits/commands/DeleteHabitsCommand.java index b1c2ee217..34e26c50c 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/DeleteHabitsCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/DeleteHabitsCommand.java @@ -19,7 +19,6 @@ package org.isoron.uhabits.commands; -import org.isoron.helpers.Command; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; diff --git a/app/src/main/java/org/isoron/uhabits/commands/EditHabitCommand.java b/app/src/main/java/org/isoron/uhabits/commands/EditHabitCommand.java index ccd641c8b..7a7787d6a 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/EditHabitCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/EditHabitCommand.java @@ -19,7 +19,6 @@ package org.isoron.uhabits.commands; -import org.isoron.helpers.Command; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; @@ -40,29 +39,36 @@ public class EditHabitCommand extends Command !this.original.freqNum.equals(this.modified.freqNum)); } + @Override public void execute() { - Habit habit = Habit.get(savedId); - habit.copyAttributes(modified); - habit.save(); - if (hasIntervalChanged) - { - habit.checkmarks.deleteNewerThan(0); - habit.streaks.deleteNewerThan(0); - habit.scores.deleteNewerThan(0); - } + copyAttributes(this.modified); } + @Override public void undo() + { + copyAttributes(this.original); + } + + private void copyAttributes(Habit model) { Habit habit = Habit.get(savedId); - habit.copyAttributes(original); + if(habit == null) throw new RuntimeException("Habit not found"); + + habit.copyAttributes(model); habit.save(); + + invalidateIfNeeded(habit); + } + + private void invalidateIfNeeded(Habit habit) + { if (hasIntervalChanged) { habit.checkmarks.deleteNewerThan(0); habit.streaks.deleteNewerThan(0); - habit.scores.deleteNewerThan(0); + habit.scores.invalidateNewerThan(0); } } diff --git a/app/src/main/java/org/isoron/uhabits/commands/ToggleRepetitionCommand.java b/app/src/main/java/org/isoron/uhabits/commands/ToggleRepetitionCommand.java index fe573ddaf..451908433 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/ToggleRepetitionCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/ToggleRepetitionCommand.java @@ -19,7 +19,6 @@ package org.isoron.uhabits.commands; -import org.isoron.helpers.Command; import org.isoron.uhabits.models.Habit; public class ToggleRepetitionCommand extends Command diff --git a/app/src/main/java/org/isoron/uhabits/commands/UnarchiveHabitsCommand.java b/app/src/main/java/org/isoron/uhabits/commands/UnarchiveHabitsCommand.java index 08d34af69..612481fa7 100644 --- a/app/src/main/java/org/isoron/uhabits/commands/UnarchiveHabitsCommand.java +++ b/app/src/main/java/org/isoron/uhabits/commands/UnarchiveHabitsCommand.java @@ -19,11 +19,9 @@ package org.isoron.uhabits.commands; -import org.isoron.helpers.Command; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; -import java.util.LinkedList; import java.util.List; public class UnarchiveHabitsCommand extends Command @@ -31,12 +29,6 @@ public class UnarchiveHabitsCommand extends Command private List habits; - public UnarchiveHabitsCommand(Habit habit) - { - habits = new LinkedList<>(); - habits.add(habit); - } - public UnarchiveHabitsCommand(List habits) { this.habits = habits; @@ -45,15 +37,13 @@ public class UnarchiveHabitsCommand extends Command @Override public void execute() { - for(Habit h : habits) - h.unarchive(); + Habit.unarchive(habits); } @Override public void undo() { - for(Habit h : habits) - h.archive(); + Habit.archive(habits); } public Integer getExecuteStringId() diff --git a/app/src/main/java/org/isoron/uhabits/dialogs/FilePickerDialog.java b/app/src/main/java/org/isoron/uhabits/dialogs/FilePickerDialog.java new file mode 100644 index 000000000..94c574fbd --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/dialogs/FilePickerDialog.java @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.dialogs; + +import android.app.Activity; +import android.app.Dialog; +import android.support.annotation.NonNull; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager.LayoutParams; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import java.io.File; +import java.io.FileFilter; +import java.util.Arrays; + +public class FilePickerDialog implements AdapterView.OnItemClickListener +{ + private static final String PARENT_DIR = ".."; + + private final Activity activity; + private ListView list; + private Dialog dialog; + private File currentPath; + + public interface OnFileSelectedListener + { + void onFileSelected(File file); + } + + private OnFileSelectedListener listener; + + public FilePickerDialog(Activity activity, File initialDirectory) + { + this.activity = activity; + + list = new ListView(activity); + list.setOnItemClickListener(this); + + dialog = new Dialog(activity); + dialog.setContentView(list); + dialog.getWindow().setLayout(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + + navigateTo(initialDirectory); + } + + @Override + public void onItemClick(AdapterView parent, View view, int which, long id) + { + String filename = (String) list.getItemAtPosition(which); + File file; + + if (filename.equals(PARENT_DIR)) + file = currentPath.getParentFile(); + else + file = new File(currentPath, filename); + + if (file.isDirectory()) + { + navigateTo(file); + } + else + { + if (listener != null) listener.onFileSelected(file); + dialog.dismiss(); + } + } + + public void show() + { + dialog.show(); + } + + public void setListener(OnFileSelectedListener listener) + { + this.listener = listener; + } + + private void navigateTo(File path) + { + if (!path.exists()) return; + + File[] dirs = path.listFiles(new ReadableDirFilter()); + File[] files = path.listFiles(new RegularReadableFileFilter()); + if(dirs == null || files == null) return; + + this.currentPath = path; + dialog.setTitle(currentPath.getPath()); + list.setAdapter(new FilePickerAdapter(getFileList(path, dirs, files))); + } + + @NonNull + private String[] getFileList(File path, File[] dirs, File[] files) + { + int count = 0; + int length = dirs.length + files.length; + String[] fileList; + + if (path.getParentFile() == null || !path.getParentFile().canRead()) + { + fileList = new String[length]; + } + else + { + fileList = new String[length + 1]; + fileList[count++] = PARENT_DIR; + } + + Arrays.sort(dirs); + Arrays.sort(files); + + for (File dir : dirs) + fileList[count++] = dir.getName(); + + for (File file : files) + fileList[count++] = file.getName(); + + return fileList; + } + + private class FilePickerAdapter extends ArrayAdapter + { + public FilePickerAdapter(@NonNull String[] fileList) + { + super(FilePickerDialog.this.activity, android.R.layout.simple_list_item_1, fileList); + } + + @Override + public View getView(int pos, View view, ViewGroup parent) + { + view = super.getView(pos, view, parent); + TextView tv = (TextView) view; + tv.setSingleLine(true); + return view; + } + } + + private static class ReadableDirFilter implements FileFilter + { + @Override + public boolean accept(File file) + { + return (file.isDirectory() && file.canRead()); + } + } + + private class RegularReadableFileFilter implements FileFilter + { + @Override + public boolean accept(File file) + { + return !file.isDirectory() && file.canRead(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/isoron/uhabits/dialogs/HistoryEditorDialog.java b/app/src/main/java/org/isoron/uhabits/dialogs/HistoryEditorDialog.java index 59d4c1645..7654fb0ed 100644 --- a/app/src/main/java/org/isoron/uhabits/dialogs/HistoryEditorDialog.java +++ b/app/src/main/java/org/isoron/uhabits/dialogs/HistoryEditorDialog.java @@ -30,6 +30,7 @@ import android.util.Log; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.tasks.BaseTask; import org.isoron.uhabits.views.HabitHistoryView; public class HistoryEditorDialog extends DialogFragment @@ -44,7 +45,6 @@ public class HistoryEditorDialog extends DialogFragment { Context context = getActivity(); historyView = new HabitHistoryView(context, null); - int p = (int) getResources().getDimension(R.dimen.history_editor_padding); if(savedInstanceState != null) { @@ -52,7 +52,8 @@ public class HistoryEditorDialog extends DialogFragment if(id > 0) this.habit = Habit.get(id); } - historyView.setPadding(p, 0, p, 0); + int padding = (int) getResources().getDimension(R.dimen.history_editor_padding); + historyView.setPadding(padding, 0, padding, 0); historyView.setHabit(habit); historyView.setIsEditable(true); @@ -61,9 +62,23 @@ public class HistoryEditorDialog extends DialogFragment .setView(historyView) .setPositiveButton(android.R.string.ok, this); + refreshData(); + return builder.create(); } + private void refreshData() + { + new BaseTask() + { + @Override + protected void doInBackground() + { + historyView.refreshData(); + } + }.execute(); + } + @Override public void onResume() { diff --git a/app/src/main/java/org/isoron/uhabits/dialogs/WeekdayPickerDialog.java b/app/src/main/java/org/isoron/uhabits/dialogs/WeekdayPickerDialog.java index 300dcd3ea..f2d54f5a2 100644 --- a/app/src/main/java/org/isoron/uhabits/dialogs/WeekdayPickerDialog.java +++ b/app/src/main/java/org/isoron/uhabits/dialogs/WeekdayPickerDialog.java @@ -25,7 +25,7 @@ import android.app.DialogFragment; import android.content.DialogInterface; import android.os.Bundle; -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.helpers.DateHelper; import org.isoron.uhabits.R; public class WeekdayPickerDialog extends DialogFragment diff --git a/app/src/main/java/org/isoron/uhabits/fragments/EditHabitFragment.java b/app/src/main/java/org/isoron/uhabits/fragments/EditHabitFragment.java index 28d1608cf..3f1a117c9 100644 --- a/app/src/main/java/org/isoron/uhabits/fragments/EditHabitFragment.java +++ b/app/src/main/java/org/isoron/uhabits/fragments/EditHabitFragment.java @@ -19,9 +19,9 @@ package org.isoron.uhabits.fragments; +import android.annotation.SuppressLint; import android.app.DialogFragment; import android.content.SharedPreferences; -import android.graphics.Color; import android.os.Bundle; import android.preference.PreferenceManager; import android.text.format.DateFormat; @@ -29,8 +29,10 @@ import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; +import android.widget.AdapterView; import android.widget.Button; import android.widget.ImageButton; +import android.widget.Spinner; import android.widget.TextView; import com.android.colorpicker.ColorPickerDialog; @@ -38,11 +40,11 @@ import com.android.colorpicker.ColorPickerSwatch; import com.android.datetimepicker.time.RadialPickerLayout; import com.android.datetimepicker.time.TimePickerDialog; -import org.isoron.helpers.ColorHelper; -import org.isoron.helpers.Command; -import org.isoron.helpers.DateHelper; -import org.isoron.helpers.DialogHelper.OnSavedListener; +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.helpers.UIHelper.OnSavedListener; import org.isoron.uhabits.R; +import org.isoron.uhabits.commands.Command; import org.isoron.uhabits.commands.CreateHabitCommand; import org.isoron.uhabits.commands.EditHabitCommand; import org.isoron.uhabits.dialogs.WeekdayPickerDialog; @@ -52,7 +54,7 @@ import java.util.Arrays; public class EditHabitFragment extends DialogFragment implements OnClickListener, WeekdayPickerDialog.OnWeekdaysPickedListener, - TimePickerDialog.OnTimeSetListener + TimePickerDialog.OnTimeSetListener, Spinner.OnItemSelectedListener { private Integer mode; static final int EDIT_MODE = 0; @@ -70,6 +72,10 @@ public class EditHabitFragment extends DialogFragment private TextView tvReminderTime; private TextView tvReminderDays; + private Spinner sFrequency; + private ViewGroup llCustomFrequency; + private ViewGroup llReminderDays; + private SharedPreferences prefs; private boolean is24HourMode; @@ -104,6 +110,10 @@ public class EditHabitFragment extends DialogFragment tvReminderTime = (TextView) view.findViewById(R.id.inputReminderTime); tvReminderDays = (TextView) view.findViewById(R.id.inputReminderDays); + sFrequency = (Spinner) view.findViewById(R.id.sFrequency); + llCustomFrequency = (ViewGroup) view.findViewById(R.id.llCustomFrequency); + llReminderDays = (ViewGroup) view.findViewById(R.id.llReminderDays); + Button buttonSave = (Button) view.findViewById(R.id.buttonSave); Button buttonDiscard = (Button) view.findViewById(R.id.buttonDiscard); ImageButton buttonPickColor = (ImageButton) view.findViewById(R.id.buttonPickColor); @@ -113,6 +123,7 @@ public class EditHabitFragment extends DialogFragment tvReminderTime.setOnClickListener(this); tvReminderDays.setOnClickListener(this); buttonPickColor.setOnClickListener(this); + sFrequency.setOnItemSelectedListener(this); prefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); @@ -125,18 +136,16 @@ public class EditHabitFragment extends DialogFragment { getDialog().setTitle(R.string.create_habit); modifiedHabit = new Habit(); - - int defaultNum = prefs.getInt("pref_default_habit_freq_num", modifiedHabit.freqNum); - int defaultDen = prefs.getInt("pref_default_habit_freq_den", modifiedHabit.freqDen); - int defaultColor = prefs.getInt("pref_default_habit_color", modifiedHabit.color); - - modifiedHabit.color = defaultColor; - modifiedHabit.freqNum = defaultNum; - modifiedHabit.freqDen = defaultDen; + modifiedHabit.freqNum = 1; + modifiedHabit.freqDen = 1; + modifiedHabit.color = prefs.getInt("pref_default_habit_color", modifiedHabit.color); } else if (mode == EDIT_MODE) { - originalHabit = Habit.get((Long) args.get("habitId")); + Long habitId = (Long) args.get("habitId"); + if(habitId == null) throw new IllegalArgumentException("habitId must be specified"); + + originalHabit = Habit.get(habitId); modifiedHabit = new Habit(originalHabit); getDialog().setTitle(R.string.edit_habit); @@ -152,17 +161,14 @@ public class EditHabitFragment extends DialogFragment modifiedHabit.reminderDays = savedInstanceState.getInt("reminderDays", -1); if(modifiedHabit.reminderMin < 0) - { - modifiedHabit.reminderMin = null; - modifiedHabit.reminderHour = null; - modifiedHabit.reminderDays = 127; - } + modifiedHabit.clearReminder(); } tvFreqNum.append(modifiedHabit.freqNum.toString()); tvFreqDen.append(modifiedHabit.freqDen.toString()); changeColor(modifiedHabit.color); + updateFrequency(); updateReminder(); return view; @@ -178,24 +184,23 @@ public class EditHabitFragment extends DialogFragment editor.apply(); } + @SuppressWarnings("ConstantConditions") private void updateReminder() { - if (modifiedHabit.reminderHour != null) + if (modifiedHabit.hasReminder()) { - tvReminderTime.setTextColor(Color.BLACK); tvReminderTime.setText(DateHelper.formatTime(getActivity(), modifiedHabit.reminderHour, modifiedHabit.reminderMin)); - tvReminderDays.setVisibility(View.VISIBLE); + llReminderDays.setVisibility(View.VISIBLE); + + boolean weekdays[] = DateHelper.unpackWeekdayList(modifiedHabit.reminderDays); + tvReminderDays.setText(DateHelper.formatWeekdayList(getActivity(), weekdays)); } else { - tvReminderTime.setTextColor(Color.GRAY); tvReminderTime.setText(R.string.reminder_off); - tvReminderDays.setVisibility(View.GONE); + llReminderDays.setVisibility(View.GONE); } - - boolean weekdays[] = DateHelper.unpackWeekdayList(modifiedHabit.reminderDays); - tvReminderDays.setText(DateHelper.formatWeekdayList(getActivity(), weekdays)); } public void setOnSavedListener(OnSavedListener onSavedListener) @@ -257,11 +262,6 @@ public class EditHabitFragment extends DialogFragment if (!validate()) return; - SharedPreferences.Editor editor = prefs.edit(); - editor.putInt("pref_default_habit_freq_num", modifiedHabit.freqNum); - editor.putInt("pref_default_habit_freq_den", modifiedHabit.freqDen); - editor.apply(); - Command command = null; Habit savedHabit = null; @@ -305,12 +305,13 @@ public class EditHabitFragment extends DialogFragment return valid; } + @SuppressWarnings("ConstantConditions") private void onDateSpinnerClick() { int defaultHour = 8; int defaultMin = 0; - if (modifiedHabit.reminderHour != null) + if (modifiedHabit.hasReminder()) { defaultHour = modifiedHabit.reminderHour; defaultMin = modifiedHabit.reminderMin; @@ -321,8 +322,11 @@ public class EditHabitFragment extends DialogFragment timePicker.show(getFragmentManager(), "timePicker"); } + @SuppressWarnings("ConstantConditions") private void onWeekdayClick() { + if(!modifiedHabit.hasReminder()) return; + WeekdayPickerDialog dialog = new WeekdayPickerDialog(); dialog.setListener(this); dialog.setSelectedDays(DateHelper.unpackWeekdayList(modifiedHabit.reminderDays)); @@ -334,14 +338,14 @@ public class EditHabitFragment extends DialogFragment { modifiedHabit.reminderHour = hour; modifiedHabit.reminderMin = minute; + modifiedHabit.reminderDays = DateHelper.ALL_WEEK_DAYS; updateReminder(); } @Override public void onTimeCleared(RadialPickerLayout view) { - modifiedHabit.reminderHour = null; - modifiedHabit.reminderMin = null; + modifiedHabit.clearReminder(); updateReminder(); } @@ -359,16 +363,93 @@ public class EditHabitFragment extends DialogFragment } @Override + @SuppressWarnings("ConstantConditions") public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putInt("color", modifiedHabit.color); - if(modifiedHabit.reminderHour != null) + + if(modifiedHabit.hasReminder()) { outState.putInt("reminderMin", modifiedHabit.reminderMin); outState.putInt("reminderHour", modifiedHabit.reminderHour); outState.putInt("reminderDays", modifiedHabit.reminderDays); } } + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) + { + if(parent.getId() == R.id.sFrequency) + { + switch (position) + { + case 0: + modifiedHabit.freqNum = 1; + modifiedHabit.freqDen = 1; + break; + + case 1: + modifiedHabit.freqNum = 1; + modifiedHabit.freqDen = 7; + break; + + case 2: + modifiedHabit.freqNum = 2; + modifiedHabit.freqDen = 7; + break; + + case 3: + modifiedHabit.freqNum = 5; + modifiedHabit.freqDen = 7; + break; + + case 4: + modifiedHabit.freqNum = 3; + modifiedHabit.freqDen = 7; + break; + } + } + + updateFrequency(); + } + + @SuppressLint("SetTextI18n") + private void updateFrequency() + { + int quickSelectPosition = -1; + + if(modifiedHabit.freqNum.equals(modifiedHabit.freqDen)) + quickSelectPosition = 0; + + else if(modifiedHabit.freqNum == 1 && modifiedHabit.freqDen == 7) + quickSelectPosition = 1; + + else if(modifiedHabit.freqNum == 2 && modifiedHabit.freqDen == 7) + quickSelectPosition = 2; + + else if(modifiedHabit.freqNum == 5 && modifiedHabit.freqDen == 7) + quickSelectPosition = 3; + + if(quickSelectPosition >= 0) + { + sFrequency.setVisibility(View.VISIBLE); + sFrequency.setSelection(quickSelectPosition); + llCustomFrequency.setVisibility(View.GONE); + tvFreqNum.setText(modifiedHabit.freqNum.toString()); + tvFreqDen.setText(modifiedHabit.freqDen.toString()); + } + else + { + sFrequency.setVisibility(View.GONE); + llCustomFrequency.setVisibility(View.VISIBLE); + } + } + + @Override + public void onNothingSelected(AdapterView parent) + { + + } } diff --git a/app/src/main/java/org/isoron/uhabits/fragments/HabitListAdapter.java b/app/src/main/java/org/isoron/uhabits/fragments/HabitListAdapter.java index 3ef39713e..dfbd959f2 100644 --- a/app/src/main/java/org/isoron/uhabits/fragments/HabitListAdapter.java +++ b/app/src/main/java/org/isoron/uhabits/fragments/HabitListAdapter.java @@ -27,7 +27,7 @@ import android.widget.BaseAdapter; import android.widget.LinearLayout; import android.widget.TextView; -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.helpers.DateHelper; import org.isoron.uhabits.R; import org.isoron.uhabits.helpers.ListHabitsHelper; import org.isoron.uhabits.loaders.HabitListLoader; diff --git a/app/src/main/java/org/isoron/uhabits/dialogs/HabitSelectionCallback.java b/app/src/main/java/org/isoron/uhabits/fragments/HabitSelectionCallback.java similarity index 76% rename from app/src/main/java/org/isoron/uhabits/dialogs/HabitSelectionCallback.java rename to app/src/main/java/org/isoron/uhabits/fragments/HabitSelectionCallback.java index 47a23cf28..9d124a691 100644 --- a/app/src/main/java/org/isoron/uhabits/dialogs/HabitSelectionCallback.java +++ b/app/src/main/java/org/isoron/uhabits/fragments/HabitSelectionCallback.java @@ -17,36 +17,29 @@ * with this program. If not, see . */ -package org.isoron.uhabits.dialogs; +package org.isoron.uhabits.fragments; import android.app.AlertDialog; import android.content.DialogInterface; -import android.content.Intent; -import android.net.Uri; -import android.os.AsyncTask; import android.view.ActionMode; import android.view.Menu; import android.view.MenuItem; -import android.view.View; import android.widget.ProgressBar; import com.android.colorpicker.ColorPickerDialog; import com.android.colorpicker.ColorPickerSwatch; -import org.isoron.helpers.ColorHelper; -import org.isoron.helpers.DialogHelper; -import org.isoron.helpers.ReplayableActivity; import org.isoron.uhabits.R; +import org.isoron.uhabits.BaseActivity; import org.isoron.uhabits.commands.ArchiveHabitsCommand; import org.isoron.uhabits.commands.ChangeHabitColorCommand; import org.isoron.uhabits.commands.DeleteHabitsCommand; import org.isoron.uhabits.commands.UnarchiveHabitsCommand; -import org.isoron.uhabits.fragments.EditHabitFragment; -import org.isoron.uhabits.io.CSVExporter; +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.helpers.UIHelper; import org.isoron.uhabits.loaders.HabitListLoader; import org.isoron.uhabits.models.Habit; -import java.io.File; import java.util.LinkedList; import java.util.List; @@ -54,9 +47,9 @@ public class HabitSelectionCallback implements ActionMode.Callback { private HabitListLoader loader; private List selectedPositions; - private ReplayableActivity activity; + private BaseActivity activity; private Listener listener; - private DialogHelper.OnSavedListener onSavedListener; + private UIHelper.OnSavedListener onSavedListener; private ProgressBar progressBar; public interface Listener @@ -64,7 +57,7 @@ public class HabitSelectionCallback implements ActionMode.Callback void onActionModeDestroyed(ActionMode mode); } - public HabitSelectionCallback(ReplayableActivity activity, HabitListLoader loader) + public HabitSelectionCallback(BaseActivity activity, HabitListLoader loader) { this.activity = activity; this.loader = loader; @@ -81,7 +74,7 @@ public class HabitSelectionCallback implements ActionMode.Callback this.progressBar = progressBar; } - public void setOnSavedListener(DialogHelper.OnSavedListener onSavedListener) + public void setOnSavedListener(UIHelper.OnSavedListener onSavedListener) { this.onSavedListener = onSavedListener; } @@ -205,12 +198,6 @@ public class HabitSelectionCallback implements ActionMode.Callback return true; } - - case R.id.action_export_csv: - { - onExportHabitsClick(selectedHabits); - return true; - } } return false; @@ -221,47 +208,4 @@ public class HabitSelectionCallback implements ActionMode.Callback { if(listener != null) listener.onActionModeDestroyed(mode); } - - private void onExportHabitsClick(final LinkedList selectedHabits) - { - new AsyncTask() - { - String filename; - - @Override - protected void onPreExecute() - { - if(progressBar != null) - { - progressBar.setIndeterminate(true); - progressBar.setVisibility(View.VISIBLE); - } - } - - @Override - protected void onPostExecute(Void aVoid) - { - if(filename != null) - { - Intent intent = new Intent(); - intent.setAction(Intent.ACTION_SEND); - intent.setType("application/zip"); - intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(new File(filename))); - - activity.startActivity(intent); - } - - if(progressBar != null) - progressBar.setVisibility(View.GONE); - } - - @Override - protected Void doInBackground(Void... params) - { - CSVExporter exporter = new CSVExporter(activity, selectedHabits); - filename = exporter.writeArchive(); - return null; - } - }.execute(); - } } diff --git a/app/src/main/java/org/isoron/uhabits/fragments/ListHabitsFragment.java b/app/src/main/java/org/isoron/uhabits/fragments/ListHabitsFragment.java index ef11a2301..1e9983833 100644 --- a/app/src/main/java/org/isoron/uhabits/fragments/ListHabitsFragment.java +++ b/app/src/main/java/org/isoron/uhabits/fragments/ListHabitsFragment.java @@ -21,12 +21,16 @@ package org.isoron.uhabits.fragments; import android.app.Activity; import android.app.Fragment; +import android.content.Intent; import android.content.SharedPreferences; +import android.net.Uri; import android.os.Bundle; import android.preference.PreferenceManager; +import android.support.annotation.Nullable; import android.view.ActionMode; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; +import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -46,20 +50,24 @@ import com.mobeta.android.dslv.DragSortController; import com.mobeta.android.dslv.DragSortListView; import com.mobeta.android.dslv.DragSortListView.DropListener; -import org.isoron.helpers.Command; -import org.isoron.helpers.DateHelper; -import org.isoron.helpers.DialogHelper; -import org.isoron.helpers.DialogHelper.OnSavedListener; -import org.isoron.helpers.ReplayableActivity; import org.isoron.uhabits.R; +import org.isoron.uhabits.BaseActivity; +import org.isoron.uhabits.commands.Command; import org.isoron.uhabits.commands.ToggleRepetitionCommand; -import org.isoron.uhabits.dialogs.HabitSelectionCallback; -import org.isoron.uhabits.dialogs.HintManager; +import org.isoron.uhabits.dialogs.FilePickerDialog; +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.helpers.UIHelper.OnSavedListener; +import org.isoron.uhabits.helpers.HintManager; import org.isoron.uhabits.helpers.ListHabitsHelper; import org.isoron.uhabits.helpers.ReminderHelper; import org.isoron.uhabits.loaders.HabitListLoader; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.tasks.ExportCSVTask; +import org.isoron.uhabits.tasks.ExportDBTask; +import org.isoron.uhabits.tasks.ImportDataTask; +import java.io.File; import java.util.Date; import java.util.LinkedList; import java.util.List; @@ -67,7 +75,8 @@ import java.util.List; public class ListHabitsFragment extends Fragment implements OnSavedListener, OnItemClickListener, OnLongClickListener, DropListener, OnClickListener, HabitListLoader.Listener, AdapterView.OnItemLongClickListener, - HabitSelectionCallback.Listener + HabitSelectionCallback.Listener, ImportDataTask.Listener, ExportCSVTask.Listener, + ExportDBTask.Listener { long lastLongClick = 0; private boolean isShortToggleEnabled; @@ -80,7 +89,7 @@ public class ListHabitsFragment extends Fragment private ListHabitsHelper helper; private List selectedPositions; private OnHabitClickListener habitClickListener; - private ReplayableActivity activity; + private BaseActivity activity; private SharedPreferences prefs; private DragSortListView listView; @@ -98,7 +107,9 @@ public class ListHabitsFragment extends Fragment listView = (DragSortListView) view.findViewById(R.id.listView); llButtonsHeader = (LinearLayout) view.findViewById(R.id.llButtonsHeader); llEmpty = view.findViewById(R.id.llEmpty); + progressBar = (ProgressBar) view.findViewById(R.id.progressBar); + progressBar.setVisibility(View.GONE); selectedPositions = new LinkedList<>(); loader = new HabitListLoader(); @@ -107,7 +118,6 @@ public class ListHabitsFragment extends Fragment loader.setListener(this); loader.setCheckmarkCount(helper.getButtonCount()); - loader.setProgressBar(progressBar); llHint.setOnClickListener(this); tvStarEmpty.setTypeface(helper.getFontawesome()); @@ -147,7 +157,7 @@ public class ListHabitsFragment extends Fragment public void onAttach(Activity activity) { super.onAttach(activity); - this.activity = (ReplayableActivity) activity; + this.activity = (BaseActivity) activity; habitClickListener = (OnHabitClickListener) activity; prefs = PreferenceManager.getDefaultSharedPreferences(activity); @@ -314,7 +324,6 @@ public class ListHabitsFragment extends Fragment if (isShortToggleEnabled) return; toggleCheck(v); - DialogHelper.vibrate(activity, 100); } private void toggleCheck(View v) @@ -327,6 +336,8 @@ public class ListHabitsFragment extends Fragment Habit habit = loader.habits.get(tag); if(habit == null) return; + listView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + helper.toggleCheckmarkView(v, habit); executeCommand(new ToggleRepetitionCommand(habit, timestamp), habit.getId()); } @@ -415,4 +426,96 @@ public class ListHabitsFragment extends Fragment selectItem(position); } } + + public void showImportDialog() + { + File dir = DatabaseHelper.getFilesDir(null); + if(dir == null) + { + activity.showToast(R.string.could_not_import); + return; + } + + FilePickerDialog picker = new FilePickerDialog(activity, dir); + picker.setListener(new FilePickerDialog.OnFileSelectedListener() + { + @Override + public void onFileSelected(File file) + { + ImportDataTask task = new ImportDataTask(file, progressBar); + task.setListener(ListHabitsFragment.this); + task.execute(); + } + }); + + picker.show(); + } + + @Override + public void onImportFinished(int result) + { + switch (result) + { + case ImportDataTask.SUCCESS: + loader.updateAllHabits(true); + activity.showToast(R.string.habits_imported); + break; + + case ImportDataTask.NOT_RECOGNIZED: + activity.showToast(R.string.file_not_recognized); + break; + + default: + activity.showToast(R.string.could_not_import); + break; + } + } + + public void exportAllHabits() + { + ExportCSVTask task = new ExportCSVTask(Habit.getAll(true), progressBar); + task.setListener(this); + task.execute(); + } + + @Override + public void onExportCSVFinished(@Nullable String archiveFilename) + { + if(archiveFilename != null) + { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_SEND); + intent.setType("application/zip"); + intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(new File(archiveFilename))); + activity.startActivity(intent); + } + else + { + activity.showToast(R.string.could_not_export); + } + } + + public void exportDB() + { + ExportDBTask task = new ExportDBTask(progressBar); + task.setListener(this); + task.execute(); + } + + @Override + public void onExportDBFinished(@Nullable String filename) + { + if(filename != null) + { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_SEND); + intent.setType("application/octet-stream"); + intent.putExtra(Intent.EXTRA_STREAM, Uri.fromFile(new File(filename))); + activity.startActivity(intent); + } + else + { + activity.showToast(R.string.could_not_export); + } + } } diff --git a/app/src/main/java/org/isoron/uhabits/fragments/SettingsFragment.java b/app/src/main/java/org/isoron/uhabits/fragments/SettingsFragment.java index fb5c04f60..0c4491b34 100644 --- a/app/src/main/java/org/isoron/uhabits/fragments/SettingsFragment.java +++ b/app/src/main/java/org/isoron/uhabits/fragments/SettingsFragment.java @@ -22,9 +22,13 @@ package org.isoron.uhabits.fragments; import android.app.backup.BackupManager; import android.content.SharedPreferences; import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceCategory; import android.preference.PreferenceFragment; +import org.isoron.uhabits.MainActivity; import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.UIHelper; public class SettingsFragment extends PreferenceFragment implements SharedPreferences.OnSharedPreferenceChangeListener @@ -34,6 +38,36 @@ public class SettingsFragment extends PreferenceFragment { super.onCreate(savedInstanceState); addPreferencesFromResource(R.xml.preferences); + + setResultOnPreferenceClick("importData", MainActivity.RESULT_IMPORT_DATA); + setResultOnPreferenceClick("exportCSV", MainActivity.RESULT_EXPORT_CSV); + setResultOnPreferenceClick("exportDB", MainActivity.RESULT_EXPORT_DB); + setResultOnPreferenceClick("bugReport", MainActivity.RESULT_BUG_REPORT); + + if(UIHelper.isLocaleFullyTranslated()) + removePreference("translate", "linksCategory"); + } + + private void removePreference(String preferenceKey, String categoryKey) + { + PreferenceCategory cat = (PreferenceCategory) findPreference(categoryKey); + Preference pref = findPreference(preferenceKey); + cat.removePreference(pref); + } + + private void setResultOnPreferenceClick(String key, final int result) + { + Preference pref = findPreference(key); + pref.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() + { + @Override + public boolean onPreferenceClick(Preference preference) + { + getActivity().setResult(result); + getActivity().finish(); + return true; + } + }); } @Override diff --git a/app/src/main/java/org/isoron/uhabits/fragments/ShowHabitFragment.java b/app/src/main/java/org/isoron/uhabits/fragments/ShowHabitFragment.java index e1dde9c66..1afb5dabd 100644 --- a/app/src/main/java/org/isoron/uhabits/fragments/ShowHabitFragment.java +++ b/app/src/main/java/org/isoron/uhabits/fragments/ShowHabitFragment.java @@ -20,42 +20,63 @@ package org.isoron.uhabits.fragments; import android.app.Fragment; +import android.content.SharedPreferences; import android.graphics.Color; import android.os.Bundle; +import android.preference.PreferenceManager; +import android.support.annotation.Nullable; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.widget.AdapterView; import android.widget.Button; +import android.widget.Spinner; import android.widget.TextView; -import org.isoron.helpers.ColorHelper; -import org.isoron.helpers.Command; -import org.isoron.helpers.DialogHelper; import org.isoron.uhabits.HabitBroadcastReceiver; import org.isoron.uhabits.R; import org.isoron.uhabits.ShowHabitActivity; +import org.isoron.uhabits.commands.Command; import org.isoron.uhabits.dialogs.HistoryEditorDialog; +import org.isoron.uhabits.helpers.ColorHelper; import org.isoron.uhabits.helpers.ReminderHelper; +import org.isoron.uhabits.helpers.UIHelper; import org.isoron.uhabits.models.Habit; import org.isoron.uhabits.models.Score; -import org.isoron.uhabits.views.HabitHistoryView; +import org.isoron.uhabits.tasks.BaseTask; +import org.isoron.uhabits.views.HabitDataView; import org.isoron.uhabits.views.HabitFrequencyView; +import org.isoron.uhabits.views.HabitHistoryView; import org.isoron.uhabits.views.HabitScoreView; import org.isoron.uhabits.views.HabitStreakView; import org.isoron.uhabits.views.RingView; +import java.util.LinkedList; +import java.util.List; + public class ShowHabitFragment extends Fragment - implements DialogHelper.OnSavedListener, HistoryEditorDialog.Listener + implements UIHelper.OnSavedListener, HistoryEditorDialog.Listener, + Spinner.OnItemSelectedListener { + @Nullable protected ShowHabitActivity activity; + + @Nullable private Habit habit; - private HabitStreakView streakView; + + @Nullable + private List dataViews; + + @Nullable private HabitScoreView scoreView; - private HabitHistoryView historyView; - private HabitFrequencyView punchcardView; + + @Nullable + private SharedPreferences prefs; + + private int previousScoreInterval; @Override public void onStart() @@ -69,23 +90,34 @@ public class ShowHabitFragment extends Fragment { View view = inflater.inflate(R.layout.show_habit, container, false); activity = (ShowHabitActivity) getActivity(); - habit = activity.habit; + habit = activity.getHabit(); - habit.checkmarks.rebuild(); + dataViews = new LinkedList<>(); Button btEditHistory = (Button) view.findViewById(R.id.btEditHistory); - streakView = (HabitStreakView) view.findViewById(R.id.streakView); + Spinner sStrengthInterval = (Spinner) view.findViewById(R.id.sStrengthInterval); + scoreView = (HabitScoreView) view.findViewById(R.id.scoreView); - historyView = (HabitHistoryView) view.findViewById(R.id.historyView); - punchcardView = (HabitFrequencyView) view.findViewById(R.id.punchcardView); + + prefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + int defaultScoreInterval = prefs.getInt("pref_score_view_interval", 1); + if(defaultScoreInterval > 5 || defaultScoreInterval < 0) defaultScoreInterval = 1; + previousScoreInterval = defaultScoreInterval; + setScoreBucketSize(defaultScoreInterval); + + + sStrengthInterval.setSelection(defaultScoreInterval); + sStrengthInterval.setOnItemSelectedListener(this); + + dataViews.add((HabitScoreView) view.findViewById(R.id.scoreView)); + dataViews.add((HabitHistoryView) view.findViewById(R.id.historyView)); + dataViews.add((HabitFrequencyView) view.findViewById(R.id.punchcardView)); + dataViews.add((HabitStreakView) view.findViewById(R.id.streakView)); updateHeaders(view); - updateScoreRing(view); - streakView.setHabit(habit); - scoreView.setHabit(habit); - historyView.setHabit(habit); - punchcardView.setHabit(habit); + for(HabitDataView dataView : dataViews) + dataView.setHabit(habit); btEditHistory.setOnClickListener(new View.OnClickListener() { @@ -111,18 +143,34 @@ public class ShowHabitFragment extends Fragment } setHasOptionsMenu(true); + return view; } + @Override + public void onResume() + { + super.onResume(); + refreshData(); + } + private void updateScoreRing(View view) { + if(habit == null) return; + if(view == null) return; + + float todayValue = (float) habit.scores.getTodayValue(); + float percentage = todayValue / Score.MAX_VALUE; + RingView scoreRing = (RingView) view.findViewById(R.id.scoreRing); scoreRing.setColor(habit.color); - scoreRing.setPercentage((float) habit.scores.getNewestValue() / Score.MAX_SCORE); + scoreRing.setPercentage(percentage); } private void updateHeaders(View view) { + if(habit == null | activity == null) return; + if (android.os.Build.VERSION.SDK_INT >= 21) { int darkerHabitColor = ColorHelper.mixColors(habit.color, Color.BLACK, 0.75f); @@ -138,6 +186,8 @@ public class ShowHabitFragment extends Fragment private void updateColor(View view, int viewId) { + if(habit == null) return; + TextView textView = (TextView) view.findViewById(viewId); textView.setTextColor(habit.color); } @@ -151,6 +201,8 @@ public class ShowHabitFragment extends Fragment @Override public boolean onOptionsItemSelected(MenuItem item) { + if(habit == null) return false; + switch (item.getItemId()) { case R.id.action_edit_habit: @@ -168,6 +220,7 @@ public class ShowHabitFragment extends Fragment @Override public void onSaved(Command command, Object savedObject) { + if(activity == null) return; Habit h = (Habit) savedObject; if (h == null) activity.executeCommand(command, null); @@ -186,10 +239,60 @@ public class ShowHabitFragment extends Fragment public void refreshData() { - streakView.refreshData(); - historyView.refreshData(); - scoreView.refreshData(); - punchcardView.refreshData(); - updateScoreRing(getView()); + new BaseTask() + { + float percentage; + + @Override + protected void doInBackground() + { + if(dataViews == null) return; + + int count = 0; + for(HabitDataView view : dataViews) + { + view.refreshData(); + onProgressUpdate(count++); + } + } + + @Override + protected void onProgressUpdate(Integer... values) + { + updateScoreRing(getView()); + if(dataViews == null) return; + dataViews.get(values[0]).postInvalidate(); + } + }.execute(); + + } + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) + { + if(parent.getId() == R.id.sStrengthInterval) + setScoreBucketSize(position); + } + + private void setScoreBucketSize(int position) + { + if(scoreView == null) return; + + int sizes[] = { 1, 7, 31, 92, 365 }; + int size = sizes[position]; + + scoreView.setBucketSize(size); + if(position != previousScoreInterval) refreshData(); + + if(prefs != null) + prefs.edit().putInt("pref_score_view_interval", position).apply(); + + previousScoreInterval = position; + } + + @Override + public void onNothingSelected(AdapterView parent) + { + } } diff --git a/app/src/main/java/org/isoron/helpers/ColorHelper.java b/app/src/main/java/org/isoron/uhabits/helpers/ColorHelper.java similarity index 95% rename from app/src/main/java/org/isoron/helpers/ColorHelper.java rename to app/src/main/java/org/isoron/uhabits/helpers/ColorHelper.java index de49d654d..e58a73745 100644 --- a/app/src/main/java/org/isoron/helpers/ColorHelper.java +++ b/app/src/main/java/org/isoron/uhabits/helpers/ColorHelper.java @@ -17,7 +17,7 @@ * with this program. If not, see . */ -package org.isoron.helpers; +package org.isoron.uhabits.helpers; import android.graphics.Color; @@ -91,4 +91,9 @@ public class ColorHelper hsv[index] = newValue; return Color.HSVToColor(hsv); } + + public static String toHTML(int color) + { + return String.format("#%06X", 0xFFFFFF & color); + } } \ No newline at end of file diff --git a/app/src/main/java/org/isoron/uhabits/helpers/DatabaseHelper.java b/app/src/main/java/org/isoron/uhabits/helpers/DatabaseHelper.java new file mode 100644 index 000000000..d3c3d21e5 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/helpers/DatabaseHelper.java @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.helpers; + +import android.content.Context; +import android.database.Cursor; +import android.os.Environment; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; +import android.util.Log; + +import com.activeandroid.ActiveAndroid; +import com.activeandroid.Cache; +import com.activeandroid.Configuration; + +import org.isoron.uhabits.BuildConfig; +import org.isoron.uhabits.HabitsApplication; +import org.isoron.uhabits.models.Checkmark; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.models.Repetition; +import org.isoron.uhabits.models.Score; +import org.isoron.uhabits.models.Streak; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.SimpleDateFormat; + +public class DatabaseHelper +{ + public static void copy(File src, File dst) throws IOException + { + FileInputStream inStream = new FileInputStream(src); + FileOutputStream outStream = new FileOutputStream(dst); + copy(inStream, outStream); + } + + public static void copy(InputStream inStream, File dst) throws IOException + { + FileOutputStream outStream = new FileOutputStream(dst); + copy(inStream, outStream); + } + + public static void copy(InputStream in, OutputStream out) throws IOException + { + int numBytes; + byte[] buffer = new byte[1024]; + + while ((numBytes = in.read(buffer)) != -1) + out.write(buffer, 0, numBytes); + } + + public interface Command + { + void execute(); + } + + public static void executeAsTransaction(Command command) + { + ActiveAndroid.beginTransaction(); + try + { + command.execute(); + ActiveAndroid.setTransactionSuccessful(); + } + finally + { + ActiveAndroid.endTransaction(); + } + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + public static String saveDatabaseCopy(File dir) throws IOException + { + File db = getDatabaseFile(); + + SimpleDateFormat dateFormat = DateHelper.getBackupDateFormat(); + String date = dateFormat.format(DateHelper.getLocalTime()); + File dbCopy = new File(String.format("%s/Loop Habits Backup %s.db", dir.getAbsolutePath(), date)); + + copy(db, dbCopy); + + return dbCopy.getAbsolutePath(); + } + + @NonNull + public static File getDatabaseFile() + { + Context context = HabitsApplication.getContext(); + if(context == null) throw new RuntimeException("No application context found"); + + String databaseFilename = getDatabaseFilename(); + + return new File(String.format("%s/../databases/%s", + context.getApplicationContext().getFilesDir().getPath(), databaseFilename)); + } + + @NonNull + public static String getDatabaseFilename() + { + String databaseFilename = BuildConfig.databaseFilename; + + if (HabitsApplication.isTestMode()) + databaseFilename = "test.db"; + + return databaseFilename; + } + + @Nullable + public static File getSDCardDir(@Nullable String relativePath) + { + File parents[] = new File[]{ Environment.getExternalStorageDirectory() }; + return getDir(parents, relativePath); + } + + @Nullable + public static File getFilesDir(@Nullable String relativePath) + { + Context context = HabitsApplication.getContext(); + if(context == null) + { + Log.e("DatabaseHelper", "getFilesDir: no application context available"); + return null; + } + + File externalFilesDirs[] = ContextCompat.getExternalFilesDirs(context, null); + + if(externalFilesDirs == null) + { + Log.e("DatabaseHelper", "getFilesDir: getExternalFilesDirs returned null"); + return null; + } + + return getDir(externalFilesDirs, relativePath); + } + + @Nullable + private static File getDir(@NonNull File potentialParentDirs[], @Nullable String relativePath) + { + if(relativePath == null) relativePath = ""; + + File chosenDir = null; + for(File dir : potentialParentDirs) + { + if (dir == null || !dir.canWrite()) continue; + chosenDir = dir; + break; + } + + if(chosenDir == null) + { + Log.e("DatabaseHelper", "getDir: all potential parents are null or non-writable"); + return null; + } + + File dir = new File(String.format("%s/%s/", chosenDir.getAbsolutePath(), relativePath)); + if (!dir.exists() && !dir.mkdirs()) + { + Log.e("DatabaseHelper", "getDir: chosen dir does not exist and cannot be created"); + return null; + } + + return dir; + } + + @SuppressWarnings("unchecked") + public static void initializeActiveAndroid() + { + Context context = HabitsApplication.getContext(); + if(context == null) throw new RuntimeException("application context should not be null"); + + Configuration dbConfig = new Configuration.Builder(context) + .setDatabaseName(getDatabaseFilename()) + .setDatabaseVersion(BuildConfig.databaseVersion) + .addModelClasses(Checkmark.class, Habit.class, Repetition.class, Score.class, + Streak.class) + .create(); + + ActiveAndroid.initialize(dbConfig); + } + + public static long longQuery(String query, String args[]) + { + Cursor c = null; + + try + { + c = Cache.openDatabase().rawQuery(query, args); + if (!c.moveToFirst()) return 0; + return c.getLong(0); + } + finally + { + if(c != null) c.close(); + } + } +} diff --git a/app/src/main/java/org/isoron/helpers/DateHelper.java b/app/src/main/java/org/isoron/uhabits/helpers/DateHelper.java similarity index 79% rename from app/src/main/java/org/isoron/helpers/DateHelper.java rename to app/src/main/java/org/isoron/uhabits/helpers/DateHelper.java index 173c5c627..b2c300453 100644 --- a/app/src/main/java/org/isoron/helpers/DateHelper.java +++ b/app/src/main/java/org/isoron/uhabits/helpers/DateHelper.java @@ -17,13 +17,14 @@ * with this program. If not, see . */ -package org.isoron.helpers; +package org.isoron.uhabits.helpers; import android.content.Context; import android.text.format.DateFormat; import org.isoron.uhabits.R; +import java.text.SimpleDateFormat; import java.util.Date; import java.util.GregorianCalendar; import java.util.Locale; @@ -32,14 +33,24 @@ import java.util.TimeZone; public class DateHelper { public static long millisecondsInOneDay = 24 * 60 * 60 * 1000; + public static int ALL_WEEK_DAYS = 127; + + private static Long fixedLocalTime = null; public static long getLocalTime() { + if(fixedLocalTime != null) return fixedLocalTime; + TimeZone tz = TimeZone.getDefault(); long now = new Date().getTime(); return now + tz.getOffset(now); } + public static void setFixedLocalTime(Long timestamp) + { + fixedLocalTime = timestamp; + } + public static long toLocalTime(long timestamp) { TimeZone tz = TimeZone.getDefault(); @@ -54,9 +65,7 @@ public class DateHelper public static GregorianCalendar getStartOfTodayCalendar() { - GregorianCalendar day = new GregorianCalendar(TimeZone.getTimeZone("GMT")); - day.setTimeInMillis(DateHelper.getStartOfDay(DateHelper.getLocalTime())); - return day; + return getCalendar(getStartOfToday()); } public static GregorianCalendar getCalendar(long timestamp) @@ -88,6 +97,38 @@ public class DateHelper return df.format(date); } + public static SimpleDateFormat getDateFormat(String skeleton) + { + String pattern; + Locale locale = Locale.getDefault(); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR2) + pattern = DateFormat.getBestDateTimePattern(locale, skeleton); + else + pattern = skeleton; + + SimpleDateFormat format = new SimpleDateFormat(pattern, locale); + format.setTimeZone(TimeZone.getTimeZone("UTC")); + + return format; + } + + public static SimpleDateFormat getCSVDateFormat() + { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + + return dateFormat; + } + + public static SimpleDateFormat getBackupDateFormat() + { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HHmmss", Locale.US); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + + return dateFormat; + } + public static String formatHeaderDate(GregorianCalendar day) { String dayOfMonth = Integer.toString(day.get(GregorianCalendar.DAY_OF_MONTH)); @@ -187,5 +228,4 @@ public class DateHelper return weekday; } - } diff --git a/app/src/main/java/org/isoron/uhabits/dialogs/HintManager.java b/app/src/main/java/org/isoron/uhabits/helpers/HintManager.java similarity index 97% rename from app/src/main/java/org/isoron/uhabits/dialogs/HintManager.java rename to app/src/main/java/org/isoron/uhabits/helpers/HintManager.java index 0a3d4dead..998939ed9 100644 --- a/app/src/main/java/org/isoron/uhabits/dialogs/HintManager.java +++ b/app/src/main/java/org/isoron/uhabits/helpers/HintManager.java @@ -17,7 +17,7 @@ * with this program. If not, see . */ -package org.isoron.uhabits.dialogs; +package org.isoron.uhabits.helpers; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; @@ -27,7 +27,6 @@ import android.preference.PreferenceManager; import android.view.View; import android.widget.TextView; -import org.isoron.helpers.DateHelper; import org.isoron.uhabits.R; public class HintManager diff --git a/app/src/main/java/org/isoron/uhabits/helpers/ListHabitsHelper.java b/app/src/main/java/org/isoron/uhabits/helpers/ListHabitsHelper.java index 846283a63..0cd6567fd 100644 --- a/app/src/main/java/org/isoron/uhabits/helpers/ListHabitsHelper.java +++ b/app/src/main/java/org/isoron/uhabits/helpers/ListHabitsHelper.java @@ -30,7 +30,6 @@ import android.widget.Button; import android.widget.LinearLayout; import android.widget.TextView; -import org.isoron.helpers.DateHelper; import org.isoron.uhabits.R; import org.isoron.uhabits.loaders.HabitListLoader; import org.isoron.uhabits.models.Habit; @@ -191,6 +190,7 @@ public class ListHabitsHelper btCheck.setTypeface(fontawesome); btCheck.setOnLongClickListener(onLongClickListener); btCheck.setOnClickListener(onClickListener); + btCheck.setHapticFeedbackEnabled(false); ((LinearLayout) view.findViewById(R.id.llButtons)).addView(check); } diff --git a/app/src/main/java/org/isoron/uhabits/helpers/ReminderHelper.java b/app/src/main/java/org/isoron/uhabits/helpers/ReminderHelper.java index 9a0e691d0..876c229fb 100644 --- a/app/src/main/java/org/isoron/uhabits/helpers/ReminderHelper.java +++ b/app/src/main/java/org/isoron/uhabits/helpers/ReminderHelper.java @@ -25,9 +25,9 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; +import android.support.annotation.Nullable; import android.util.Log; -import org.isoron.helpers.DateHelper; import org.isoron.uhabits.HabitBroadcastReceiver; import org.isoron.uhabits.models.Habit; @@ -43,13 +43,17 @@ public class ReminderHelper createReminderAlarm(context, habit, null); } - public static void createReminderAlarm(Context context, Habit habit, Long reminderTime) + public static void createReminderAlarm(Context context, Habit habit, @Nullable Long reminderTime) { + if(!habit.hasReminder()) return; + if (reminderTime == null) { Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(System.currentTimeMillis()); + //noinspection ConstantConditions calendar.set(Calendar.HOUR_OF_DAY, habit.reminderHour); + //noinspection ConstantConditions calendar.set(Calendar.MINUTE, habit.reminderMin); calendar.set(Calendar.SECOND, 0); @@ -74,7 +78,10 @@ public class ReminderHelper alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT); AlarmManager manager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - if (Build.VERSION.SDK_INT >= 19) + + if (Build.VERSION.SDK_INT >= 23) + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, reminderTime, pendingIntent); + else if (Build.VERSION.SDK_INT >= 19) manager.setExact(AlarmManager.RTC_WAKEUP, reminderTime, pendingIntent); else manager.set(AlarmManager.RTC_WAKEUP, reminderTime, pendingIntent); diff --git a/app/src/main/java/org/isoron/uhabits/helpers/UIHelper.java b/app/src/main/java/org/isoron/uhabits/helpers/UIHelper.java new file mode 100644 index 000000000..46ae1572d --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/helpers/UIHelper.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.helpers; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Debug; +import android.os.Looper; +import android.preference.PreferenceManager; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.View; +import android.view.inputmethod.InputMethodManager; + +import org.isoron.uhabits.BuildConfig; +import org.isoron.uhabits.commands.Command; + +import java.util.Locale; + +public abstract class UIHelper +{ + + public static final String ISORON_NAMESPACE = "http://isoron.org/android"; + private static Typeface fontawesome; + + public interface OnSavedListener + { + void onSaved(Command command, Object savedObject); + } + + public static void showSoftKeyboard(View view) + { + InputMethodManager imm = (InputMethodManager) view.getContext() + .getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT); + } + + public static void incrementLaunchCount(Context context) + { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + int count = prefs.getInt("launch_count", 0); + prefs.edit().putInt("launch_count", count + 1).apply(); + } + + public static void updateLastAppVersion(Context context) + { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().putInt("last_version", BuildConfig.VERSION_CODE).apply(); + } + + public static int getLaunchCount(Context context) + { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getInt("launch_count", 0); + } + + public static String getAttribute(Context context, AttributeSet attrs, String name, + String defaultValue) + { + int resId = attrs.getAttributeResourceValue(ISORON_NAMESPACE, name, 0); + if (resId != 0) return context.getResources().getString(resId); + + String value = attrs.getAttributeValue(ISORON_NAMESPACE, name); + if(value != null) return value; + else return defaultValue; + } + + public static int getIntAttribute(Context context, AttributeSet attrs, String name, + int defaultValue) + { + String number = getAttribute(context, attrs, name, null); + if(number != null) return Integer.parseInt(number); + else return defaultValue; + } + + public static float getFloatAttribute(Context context, AttributeSet attrs, String name, + float defaultValue) + { + String number = getAttribute(context, attrs, name, null); + if(number != null) return Float.parseFloat(number); + else return defaultValue; + } + + public static float dpToPixels(Context context, float dp) + { + Resources resources = context.getResources(); + DisplayMetrics metrics = resources.getDisplayMetrics(); + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, metrics); + } + + public static float spToPixels(Context context, float sp) + { + Resources resources = context.getResources(); + DisplayMetrics metrics = resources.getDisplayMetrics(); + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, metrics); + } + + /** + * Throws a runtime exception if called from the main thread. Useful to make sure that + * slow methods never accidentally slow the application down. + * + * @throws RuntimeException when run from main thread + */ + public static void throwIfMainThread() throws RuntimeException + { + Looper looper = Looper.myLooper(); + if(looper == null) return; + + if(looper == Looper.getMainLooper()) + throw new RuntimeException("This method should never be called from the main thread"); + } + + public static void startTracing() + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + { + throw new UnsupportedOperationException(); + } + else + { + Debug.startMethodTracingSampling("Android/data/org.isoron.uhabits/perf", + 32 * 1024 * 1024, 100); + } + } + + public static void stopTracing() + { + Debug.stopMethodTracing(); + } + + public static boolean isLocaleFullyTranslated() + { + String fullyTranslatedLanguages[] = { "en", "ar", "cs", "de", "it", "ja", "ko", "po", "pl", + "pt", "ru", "sv", "zh", "es" }; + + final String currentLanguage = Locale.getDefault().getLanguage(); + + Log.d("UIHelper", String.format("lang=%s", currentLanguage)); + + for(String lang : fullyTranslatedLanguages) + if(currentLanguage.equals(lang)) return true; + + return false; + } +} diff --git a/app/src/main/java/org/isoron/uhabits/io/AbstractImporter.java b/app/src/main/java/org/isoron/uhabits/io/AbstractImporter.java new file mode 100644 index 000000000..83cfddcb8 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/io/AbstractImporter.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.io; + +import android.support.annotation.NonNull; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.Arrays; + +public abstract class AbstractImporter +{ + public abstract boolean canHandle(@NonNull File file) throws IOException; + + public abstract void importHabitsFromFile(@NonNull File file) throws IOException; + + public static boolean isSQLite3File(@NonNull File file) throws IOException + { + FileInputStream fis = new FileInputStream(file); + + byte[] sqliteHeader = "SQLite format 3".getBytes(); + byte[] buffer = new byte[sqliteHeader.length]; + + + int count = fis.read(buffer); + if(count < sqliteHeader.length) return false; + + return Arrays.equals(buffer, sqliteHeader); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/isoron/uhabits/io/CSVExporter.java b/app/src/main/java/org/isoron/uhabits/io/CSVExporter.java deleted file mode 100644 index c1a2c9599..000000000 --- a/app/src/main/java/org/isoron/uhabits/io/CSVExporter.java +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright (C) 2016 Álinson Santos Xavier - * - * 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 . - */ - -package org.isoron.uhabits.io; - -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.util.Log; - -import com.activeandroid.Cache; - -import org.isoron.helpers.DateHelper; -import org.isoron.uhabits.models.Habit; -import org.isoron.uhabits.models.Score; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.FileWriter; -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.LinkedList; -import java.util.List; -import java.util.Locale; -import java.util.TimeZone; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -public class CSVExporter -{ - private List habits; - private Context context; - private java.text.DateFormat dateFormat; - - private List generateDirs; - private List generateFilenames; - - private String basePath; - - public CSVExporter(Context context, List habits) - { - this.habits = habits; - this.context = context; - generateDirs = new LinkedList<>(); - generateFilenames = new LinkedList<>(); - - basePath = String.format("%s/export/", context.getFilesDir()); - - dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.US); - dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); - } - - public String formatDate(long timestamp) - { - return dateFormat.format(new Date(timestamp)); - } - - public String formatScore(int score) - { - return String.format("%.2f", ((float) score) / Score.MAX_SCORE); - } - - private void writeScores(String dirPath, Habit habit) throws IOException - { - String path = dirPath + "scores.csv"; - FileWriter out = new FileWriter(basePath + path); - generateFilenames.add(path); - - String query = "select timestamp, score from score where habit = ? order by timestamp"; - String params[] = { habit.getId().toString() }; - - SQLiteDatabase db = Cache.openDatabase(); - Cursor cursor = db.rawQuery(query, params); - - if(!cursor.moveToFirst()) return; - - do - { - String timestamp = formatDate(cursor.getLong(0)); - String score = formatScore(cursor.getInt(1)); - out.write(String.format("%s,%s\n", timestamp, score)); - - } while(cursor.moveToNext()); - - out.close(); - cursor.close(); - } - - private void writeCheckmarks(String dirPath, Habit habit) throws IOException - { - String path = dirPath + "checkmarks.csv"; - FileWriter out = new FileWriter(basePath + path); - generateFilenames.add(path); - - String query = "select timestamp, value from checkmarks where habit = ? order by timestamp"; - String params[] = { habit.getId().toString() }; - - SQLiteDatabase db = Cache.openDatabase(); - Cursor cursor = db.rawQuery(query, params); - - if(!cursor.moveToFirst()) return; - - do - { - String timestamp = formatDate(cursor.getLong(0)); - Integer value = cursor.getInt(1); - out.write(String.format("%s,%d\n", timestamp, value)); - - } while(cursor.moveToNext()); - - out.close(); - cursor.close(); - } - - private void writeFiles(Habit habit) throws IOException - { - String path = String.format("%s/", habit.name); - new File(basePath + path).mkdirs(); - generateDirs.add(path); - - writeScores(path, habit); - writeCheckmarks(path, habit); - } - - private void writeZipFile(String zipFilename) throws IOException - { - FileOutputStream fos = new FileOutputStream(zipFilename); - ZipOutputStream zos = new ZipOutputStream(fos); - - for(String filename : generateFilenames) - addFileToZip(zos, filename); - - zos.close(); - fos.close(); - } - - private void addFileToZip(ZipOutputStream zos, String filename) throws IOException - { - FileInputStream fis = new FileInputStream(new File(basePath + filename)); - ZipEntry ze = new ZipEntry(filename); - zos.putNextEntry(ze); - - int length; - byte bytes[] = new byte[1024]; - while((length = fis.read(bytes)) >= 0) - zos.write(bytes, 0, length); - - zos.closeEntry(); - fis.close(); - } - - private void cleanup() - { - for(String filename : generateFilenames) - new File(basePath + filename).delete(); - - for(String filename : generateDirs) - new File(basePath + filename).delete(); - - new File(basePath).delete(); - } - - public String writeArchive() - { - String date = formatDate(DateHelper.getStartOfToday()); - - File dir = context.getExternalCacheDir(); - - if(dir == null) - { - Log.e("CSVExporter", "No suitable directory found."); - return null; - } - - String zipFilename = String.format("%s/habits-%s.zip", dir, date); - - try - { - for (Habit h : habits) - writeFiles(h); - - writeZipFile(zipFilename); - cleanup(); - } - catch (IOException e) - { - e.printStackTrace(); - return null; - } - - return zipFilename; - } - - -} diff --git a/app/src/main/java/org/isoron/uhabits/io/GenericImporter.java b/app/src/main/java/org/isoron/uhabits/io/GenericImporter.java new file mode 100644 index 000000000..c08a3a72f --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/io/GenericImporter.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.io; + +import android.support.annotation.NonNull; + +import java.io.File; +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +public class GenericImporter extends AbstractImporter +{ + List importers; + + public GenericImporter() + { + importers = new LinkedList<>(); + importers.add(new LoopDBImporter()); + importers.add(new RewireDBImporter()); + importers.add(new TickmateDBImporter()); + importers.add(new HabitBullCSVImporter()); + } + + @Override + public boolean canHandle(@NonNull File file) throws IOException + { + for(AbstractImporter importer : importers) + if(importer.canHandle(file)) return true; + + return false; + } + + @Override + public void importHabitsFromFile(@NonNull File file) throws IOException + { + for(AbstractImporter importer : importers) + if(importer.canHandle(file)) + importer.importHabitsFromFile(file); + } +} diff --git a/app/src/main/java/org/isoron/uhabits/io/HabitBullCSVImporter.java b/app/src/main/java/org/isoron/uhabits/io/HabitBullCSVImporter.java new file mode 100644 index 000000000..46be626c9 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/io/HabitBullCSVImporter.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.io; + +import android.support.annotation.NonNull; + +import com.activeandroid.ActiveAndroid; +import com.opencsv.CSVReader; + +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.Calendar; +import java.util.HashMap; + +public class HabitBullCSVImporter extends AbstractImporter +{ + @Override + public boolean canHandle(@NonNull File file) throws IOException + { + BufferedReader reader = new BufferedReader(new FileReader(file)); + String line = reader.readLine(); + + return line.startsWith("HabitName,HabitDescription,HabitCategory"); + } + + @Override + public void importHabitsFromFile(@NonNull final File file) throws IOException + { + ActiveAndroid.beginTransaction(); + try + { + parseFile(file); + ActiveAndroid.setTransactionSuccessful(); + } + finally + { + ActiveAndroid.endTransaction(); + } + } + + private void parseFile(@NonNull File file) throws IOException + { + CSVReader reader = new CSVReader(new FileReader(file)); + HashMap habits = new HashMap<>(); + + for(String line[] : reader) + { + String name = line[0]; + if(name.equals("HabitName")) continue; + + String description = line[1]; + String dateString[] = line[3].split("-"); + int year = Integer.parseInt(dateString[0]); + int month = Integer.parseInt(dateString[1]); + int day = Integer.parseInt(dateString[2]); + + Calendar date = DateHelper.getStartOfTodayCalendar(); + date.set(year, month - 1, day); + + long timestamp = date.getTimeInMillis(); + + int value = Integer.parseInt(line[4]); + if(value != 1) continue; + + Habit h = habits.get(name); + + if(h == null) + { + h = new Habit(); + h.name = name; + h.description = description; + h.freqNum = h.freqDen = 1; + h.save(); + + habits.put(name, h); + } + + if(!h.repetitions.contains(timestamp)) + h.repetitions.toggle(timestamp); + } + } +} diff --git a/app/src/main/java/org/isoron/uhabits/io/HabitsCSVExporter.java b/app/src/main/java/org/isoron/uhabits/io/HabitsCSVExporter.java new file mode 100644 index 000000000..f84ad666c --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/io/HabitsCSVExporter.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.io; + +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.CheckmarkList; +import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.models.ScoreList; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.LinkedList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +public class HabitsCSVExporter +{ + private List habits; + + private List generateDirs; + private List generateFilenames; + + private String exportDirName; + + public HabitsCSVExporter(List habits, File dir) + { + this.habits = habits; + this.exportDirName = dir.getAbsolutePath() + "/"; + + generateDirs = new LinkedList<>(); + generateFilenames = new LinkedList<>(); + } + + private void writeHabits() throws IOException + { + String filename = "Habits.csv"; + new File(exportDirName).mkdirs(); + FileWriter out = new FileWriter(exportDirName + filename); + generateFilenames.add(filename); + Habit.writeCSV(habits, out); + out.close(); + + for(Habit h : habits) + { + String habitDirName = String.format("%03d %s/", h.position + 1, h.name); + new File(exportDirName + habitDirName).mkdirs(); + generateDirs.add(habitDirName); + + writeScores(habitDirName, h.scores); + writeCheckmarks(habitDirName, h.checkmarks); + } + } + + private void writeScores(String habitDirName, ScoreList scores) throws IOException + { + String path = habitDirName + "Scores.csv"; + FileWriter out = new FileWriter(exportDirName + path); + generateFilenames.add(path); + scores.writeCSV(out); + out.close(); + } + + private void writeCheckmarks(String habitDirName, CheckmarkList checkmarks) throws IOException + { + String filename = habitDirName + "Checkmarks.csv"; + FileWriter out = new FileWriter(exportDirName + filename); + generateFilenames.add(filename); + checkmarks.writeCSV(out); + out.close(); + } + + private String writeZipFile() throws IOException + { + SimpleDateFormat dateFormat = DateHelper.getCSVDateFormat(); + String date = dateFormat.format(DateHelper.getStartOfToday()); + String zipFilename = String.format("%s/Loop Habits CSV %s.zip", exportDirName, date); + + FileOutputStream fos = new FileOutputStream(zipFilename); + ZipOutputStream zos = new ZipOutputStream(fos); + + for(String filename : generateFilenames) + addFileToZip(zos, filename); + + zos.close(); + fos.close(); + + return zipFilename; + } + + private void addFileToZip(ZipOutputStream zos, String filename) throws IOException + { + FileInputStream fis = new FileInputStream(new File(exportDirName + filename)); + ZipEntry ze = new ZipEntry(filename); + zos.putNextEntry(ze); + + int length; + byte bytes[] = new byte[1024]; + while((length = fis.read(bytes)) >= 0) + zos.write(bytes, 0, length); + + zos.closeEntry(); + fis.close(); + } + + public String writeArchive() throws IOException + { + String zipFilename; + + writeHabits(); + zipFilename = writeZipFile(); + cleanup(); + + return zipFilename; + } + + private void cleanup() + { + for(String filename : generateFilenames) + new File(exportDirName + filename).delete(); + + for(String filename : generateDirs) + new File(exportDirName + filename).delete(); + + new File(exportDirName).delete(); + } +} diff --git a/app/src/main/java/org/isoron/uhabits/io/LoopDBImporter.java b/app/src/main/java/org/isoron/uhabits/io/LoopDBImporter.java new file mode 100644 index 000000000..27b7ecb15 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/io/LoopDBImporter.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.io; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.support.annotation.NonNull; + +import com.activeandroid.ActiveAndroid; + +import org.isoron.uhabits.helpers.DatabaseHelper; + +import java.io.File; +import java.io.IOException; + +public class LoopDBImporter extends AbstractImporter +{ + @Override + public boolean canHandle(@NonNull File file) throws IOException + { + if(!isSQLite3File(file)) return false; + + SQLiteDatabase db = SQLiteDatabase.openDatabase(file.getPath(), null, + SQLiteDatabase.OPEN_READONLY); + + Cursor c = db.rawQuery("select count(*) from SQLITE_MASTER where name=? or name=?", + new String[]{"Checkmarks", "Repetitions"}); + + boolean result = (c.moveToFirst() && c.getInt(0) == 2); + + c.close(); + db.close(); + return result; + } + + @Override + public void importHabitsFromFile(@NonNull File file) throws IOException + { + ActiveAndroid.dispose(); + File originalDB = DatabaseHelper.getDatabaseFile(); + DatabaseHelper.copy(file, originalDB); + DatabaseHelper.initializeActiveAndroid(); + } +} diff --git a/app/src/main/java/org/isoron/uhabits/io/RewireDBImporter.java b/app/src/main/java/org/isoron/uhabits/io/RewireDBImporter.java new file mode 100644 index 000000000..47fc92020 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/io/RewireDBImporter.java @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.io; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.support.annotation.NonNull; + +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; + +import java.io.File; +import java.io.IOException; +import java.util.GregorianCalendar; + +public class RewireDBImporter extends AbstractImporter +{ + @Override + public boolean canHandle(@NonNull File file) throws IOException + { + if(!isSQLite3File(file)) return false; + + SQLiteDatabase db = SQLiteDatabase.openDatabase(file.getPath(), null, + SQLiteDatabase.OPEN_READONLY); + + Cursor c = db.rawQuery("select count(*) from SQLITE_MASTER where name=? or name=?", + new String[]{"CHECKINS", "UNIT"}); + + boolean result = (c.moveToFirst() && c.getInt(0) == 2); + + c.close(); + db.close(); + return result; + } + + @Override + public void importHabitsFromFile(@NonNull File file) throws IOException + { + final SQLiteDatabase db = SQLiteDatabase.openDatabase(file.getPath(), null, + SQLiteDatabase.OPEN_READONLY); + + DatabaseHelper.executeAsTransaction(new DatabaseHelper.Command() + { + @Override + public void execute() + { + createHabits(db); + } + }); + + db.close(); + } + + private void createHabits(SQLiteDatabase db) + { + Cursor c = null; + + try + { + c = db.rawQuery("select _id, name, description, schedule, active_days, " + + "repeating_count, days, period from habits", new String[0]); + if (!c.moveToFirst()) return; + + do + { + int id = c.getInt(0); + String name = c.getString(1); + String description = c.getString(2); + int schedule = c.getInt(3); + String activeDays = c.getString(4); + int repeatingCount = c.getInt(5); + int days = c.getInt(6); + int periodIndex = c.getInt(7); + + Habit habit = new Habit(); + habit.name = name; + habit.description = description; + + int periods[] = { 7, 31, 365 }; + + switch (schedule) + { + case 0: + habit.freqNum = activeDays.split(",").length; + habit.freqDen = 7; + break; + + case 1: + habit.freqNum = days; + habit.freqDen = periods[periodIndex]; + break; + + case 2: + habit.freqNum = 1; + habit.freqDen = repeatingCount; + break; + } + + habit.save(); + + createReminder(db, habit, id); + createCheckmarks(db, habit, id); + + } + while (c.moveToNext()); + } + finally + { + if (c != null) c.close(); + } + } + + private void createReminder(SQLiteDatabase db, Habit habit, int rewireHabitId) + { + String[] params = { Integer.toString(rewireHabitId) }; + Cursor c = null; + + try + { + c = db.rawQuery("select time, active_days from reminders where habit_id=? limit 1", params); + + if (!c.moveToFirst()) return; + int rewireReminder = Integer.parseInt(c.getString(0)); + if (rewireReminder <= 0 || rewireReminder >= 1440) return; + + boolean reminderDays[] = new boolean[7]; + + String activeDays[] = c.getString(1).split(","); + for(String d : activeDays) + { + int idx = (Integer.parseInt(d) + 1) % 7; + reminderDays[idx] = true; + } + + habit.reminderDays = DateHelper.packWeekdayList(reminderDays); + habit.reminderHour = rewireReminder / 60; + habit.reminderMin = rewireReminder % 60; + habit.save(); + } + finally + { + if(c != null) c.close(); + } + } + + private void createCheckmarks(@NonNull SQLiteDatabase db, @NonNull Habit habit, int rewireHabitId) + { + Cursor c = null; + + try + { + String[] params = { Integer.toString(rewireHabitId) }; + c = db.rawQuery("select distinct date from checkins where habit_id=? and type=2", params); + if (!c.moveToFirst()) return; + + do + { + String date = c.getString(0); + int year = Integer.parseInt(date.substring(0, 4)); + int month = Integer.parseInt(date.substring(4, 6)); + int day = Integer.parseInt(date.substring(6, 8)); + + GregorianCalendar cal = DateHelper.getStartOfTodayCalendar(); + cal.set(year, month - 1, day); + + habit.repetitions.toggle(cal.getTimeInMillis()); + } + while (c.moveToNext()); + } + finally + { + if (c != null) c.close(); + } + } +} diff --git a/app/src/main/java/org/isoron/uhabits/io/TickmateDBImporter.java b/app/src/main/java/org/isoron/uhabits/io/TickmateDBImporter.java new file mode 100644 index 000000000..f0b6b9770 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/io/TickmateDBImporter.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.io; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.support.annotation.NonNull; + +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.models.Habit; + +import java.io.File; +import java.io.IOException; +import java.util.GregorianCalendar; + +public class TickmateDBImporter extends AbstractImporter +{ + @Override + public boolean canHandle(@NonNull File file) throws IOException + { + if(!isSQLite3File(file)) return false; + + SQLiteDatabase db = SQLiteDatabase.openDatabase(file.getPath(), null, + SQLiteDatabase.OPEN_READONLY); + + Cursor c = db.rawQuery("select count(*) from SQLITE_MASTER where name=? or name=?", + new String[]{"tracks", "track2groups"}); + + boolean result = (c.moveToFirst() && c.getInt(0) == 2); + + c.close(); + db.close(); + return result; + } + + @Override + public void importHabitsFromFile(@NonNull File file) throws IOException + { + final SQLiteDatabase db = SQLiteDatabase.openDatabase(file.getPath(), null, + SQLiteDatabase.OPEN_READONLY); + + DatabaseHelper.executeAsTransaction(new DatabaseHelper.Command() + { + @Override + public void execute() + { + createHabits(db); + } + }); + + db.close(); + } + + private void createHabits(SQLiteDatabase db) + { + Cursor c = null; + + try + { + c = db.rawQuery("select _id, name, description from tracks", new String[0]); + if (!c.moveToFirst()) return; + + do + { + int id = c.getInt(0); + String name = c.getString(1); + String description = c.getString(2); + + Habit habit = new Habit(); + habit.name = name; + habit.description = description; + habit.freqNum = 1; + habit.freqDen = 1; + habit.save(); + + createCheckmarks(db, habit, id); + + } + while (c.moveToNext()); + } + finally + { + if (c != null) c.close(); + } + } + + private void createCheckmarks(@NonNull SQLiteDatabase db, @NonNull Habit habit, int tickmateTrackId) + { + Cursor c = null; + + try + { + String[] params = { Integer.toString(tickmateTrackId) }; + c = db.rawQuery("select distinct year, month, day from ticks where _track_id=?", params); + if (!c.moveToFirst()) return; + + do + { + int year = c.getInt(0); + int month = c.getInt(1); + int day = c.getInt(2); + + GregorianCalendar cal = DateHelper.getStartOfTodayCalendar(); + cal.set(year, month, day); + + habit.repetitions.toggle(cal.getTimeInMillis()); + } + while (c.moveToNext()); + } + finally + { + if (c != null) c.close(); + } + } +} diff --git a/app/src/main/java/org/isoron/uhabits/loaders/HabitListLoader.java b/app/src/main/java/org/isoron/uhabits/loaders/HabitListLoader.java index 8a0d93661..12a8e9e9f 100644 --- a/app/src/main/java/org/isoron/uhabits/loaders/HabitListLoader.java +++ b/app/src/main/java/org/isoron/uhabits/loaders/HabitListLoader.java @@ -19,13 +19,9 @@ package org.isoron.uhabits.loaders; -import android.os.AsyncTask; -import android.os.Handler; -import android.view.View; -import android.widget.ProgressBar; - -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.helpers.DateHelper; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.tasks.BaseTask; import java.util.HashMap; import java.util.List; @@ -37,9 +33,8 @@ public class HabitListLoader void onLoadFinished(); } - private AsyncTask currentFetchTask; + private BaseTask currentFetchTask; private int checkmarkCount; - private ProgressBar progressBar; private Listener listener; private Long lastLoadTimestamp; @@ -56,11 +51,6 @@ public class HabitListLoader this.includeArchived = includeArchived; } - public void setProgressBar(ProgressBar progressBar) - { - this.progressBar = progressBar; - } - public void setCheckmarkCount(int checkmarkCount) { this.checkmarkCount = checkmarkCount; @@ -98,7 +88,7 @@ public class HabitListLoader { if (currentFetchTask != null) currentFetchTask.cancel(true); - currentFetchTask = new AsyncTask() + currentFetchTask = new BaseTask() { public HashMap newHabits; public HashMap newCheckmarks; @@ -106,7 +96,7 @@ public class HabitListLoader public List newHabitList; @Override - protected Void doInBackground(Void... params) + protected void doInBackground() { newHabits = new HashMap<>(); newCheckmarks = new HashMap<>(); @@ -136,21 +126,19 @@ public class HabitListLoader commit(); - if(!updateScoresAndCheckmarks) return null; + if(!updateScoresAndCheckmarks) return; int current = 0; for (Habit h : newHabitList) { - if (isCancelled()) return null; + if (isCancelled()) return; Long id = h.getId(); - newScores.put(id, h.scores.getNewestValue()); + newScores.put(id, h.scores.getTodayValue()); newCheckmarks.put(id, h.checkmarks.getValues(dateFrom, dateTo)); publishProgress(current++, newHabits.size()); } - - return null; } private void commit() @@ -161,26 +149,9 @@ public class HabitListLoader habitsList = newHabitList; } - @Override - protected void onPreExecute() - { - if(progressBar != null) - { - progressBar.setIndeterminate(false); - progressBar.setProgress(0); - progressBar.setVisibility(View.VISIBLE); - } - } - @Override protected void onProgressUpdate(Integer... values) { - if(progressBar != null) - { - progressBar.setMax(values[1]); - progressBar.setProgress(values[0]); - } - if(listener != null) listener.onLoadFinished(); } @@ -189,11 +160,12 @@ public class HabitListLoader { if (isCancelled()) return; - if(progressBar != null) progressBar.setVisibility(View.INVISIBLE); lastLoadTimestamp = DateHelper.getStartOfToday(); currentFetchTask = null; if(listener != null) listener.onLoadFinished(); + + super.onPostExecute(null); } }; @@ -203,50 +175,29 @@ public class HabitListLoader public void updateHabit(final Long id) { - new AsyncTask() + new BaseTask() { @Override - protected Void doInBackground(Void... params) + protected void doInBackground() { long dateTo = DateHelper.getStartOfDay(DateHelper.getLocalTime()); long dateFrom = dateTo - (checkmarkCount - 1) * DateHelper.millisecondsInOneDay; Habit h = Habit.get(id); + if(h == null) return; + habits.put(id, h); - scores.put(id, h.scores.getNewestValue()); + scores.put(id, h.scores.getTodayValue()); checkmarks.put(id, h.checkmarks.getValues(dateFrom, dateTo)); - - return null; - } - - @Override - protected void onPreExecute() - { - new Handler().postDelayed(new Runnable() - { - @Override - public void run() - { - if (getStatus() == Status.RUNNING) - { - if(progressBar != null) - { - progressBar.setIndeterminate(true); - progressBar.setVisibility(View.VISIBLE); - } - } - } - }, 500); } @Override protected void onPostExecute(Void aVoid) { - if(progressBar != null) progressBar.setVisibility(View.GONE); - if(listener != null) listener.onLoadFinished(); + super.onPostExecute(null); } }.execute(); } diff --git a/app/src/main/java/org/isoron/uhabits/models/Checkmark.java b/app/src/main/java/org/isoron/uhabits/models/Checkmark.java index e1007a26f..a6c5ec06f 100644 --- a/app/src/main/java/org/isoron/uhabits/models/Checkmark.java +++ b/app/src/main/java/org/isoron/uhabits/models/Checkmark.java @@ -26,22 +26,40 @@ import com.activeandroid.annotation.Table; @Table(name = "Checkmarks") public class Checkmark extends Model { - + /** + * Indicates that there was no repetition at the timestamp, even though a repetition was + * expected. + */ public static final int UNCHECKED = 0; + + /** + * Indicates that there was no repetition at the timestamp, but one was not expected in any + * case, due to the frequency of the habit. + */ public static final int CHECKED_IMPLICITLY = 1; + + /** + * Indicates that there was a repetition at the timestamp. + */ public static final int CHECKED_EXPLICITLY = 2; + /** + * The habit to which this checkmark belongs. + */ @Column(name = "habit") public Habit habit; + /** + * Timestamp of the day to which this checkmark corresponds. Time of the day must be midnight + * (UTC). + */ @Column(name = "timestamp") public Long timestamp; /** - * Indicates whether there is a checkmark at the given timestamp or not, and whether the - * checkmark is explicit or implicit. An explicit checkmark indicates that there is a - * repetition at that day. An implicit checkmark indicates that there is no repetition at that - * day, but a repetition was not needed, due to the frequency of the habit. + * Indicates whether there is a repetition at the given timestamp or not, and whether the + * repetition was expected. Assumes one of the values UNCHECKED, CHECKED_EXPLICITLY or + * CHECKED_IMPLICITLY. */ @Column(name = "value") public Integer value; diff --git a/app/src/main/java/org/isoron/uhabits/models/CheckmarkList.java b/app/src/main/java/org/isoron/uhabits/models/CheckmarkList.java index 04c8fe458..80b565c9c 100644 --- a/app/src/main/java/org/isoron/uhabits/models/CheckmarkList.java +++ b/app/src/main/java/org/isoron/uhabits/models/CheckmarkList.java @@ -21,14 +21,21 @@ package org.isoron.uhabits.models; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteStatement; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; -import com.activeandroid.ActiveAndroid; import com.activeandroid.Cache; import com.activeandroid.query.Delete; import com.activeandroid.query.Select; -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.helpers.UIHelper; +import java.io.IOException; +import java.io.Writer; +import java.text.SimpleDateFormat; +import java.util.Date; import java.util.List; public class CheckmarkList @@ -40,6 +47,12 @@ public class CheckmarkList this.habit = habit; } + /** + * Deletes every checkmark that has timestamp either equal or newer than a given timestamp. + * These checkmarks will be recomputed at the next time they are queried. + * + * @param timestamp the timestamp + */ public void deleteNewerThan(long timestamp) { new Delete().from(Checkmark.class) @@ -48,9 +61,22 @@ public class CheckmarkList .execute(); } - public int[] getValues(Long fromTimestamp, Long toTimestamp) + /** + * Returns the values of the checkmarks that fall inside a certain interval of time. + * + * The values are returned in an array containing one integer value for each day of the + * interval. The first entry corresponds to the most recent day in the interval. Each subsequent + * entry corresponds to one day older than the previous entry. The boundaries of the time + * interval are included. + * + * @param fromTimestamp timestamp for the oldest checkmark + * @param toTimestamp timestamp for the newest checkmark + * @return values for the checkmarks inside the given interval + */ + @NonNull + public int[] getValues(long fromTimestamp, long toTimestamp) { - rebuild(); + compute(fromTimestamp, toTimestamp); if(fromTimestamp > toTimestamp) return new int[0]; @@ -58,8 +84,8 @@ public class CheckmarkList "habit = ? and timestamp >= ? and timestamp <= ?"; SQLiteDatabase db = Cache.openDatabase(); - String args[] = { habit.getId().toString(), fromTimestamp.toString(), - toTimestamp.toString() }; + String args[] = { habit.getId().toString(), Long.toString(fromTimestamp), + Long.toString(toTimestamp) }; Cursor cursor = db.rawQuery(query, args); long day = DateHelper.millisecondsInOneDay; @@ -81,53 +107,76 @@ public class CheckmarkList return checks; } + /** + * Returns the values for all the checkmarks, since the oldest repetition of the habit until + * today. If there are no repetitions at all, returns an empty array. + * + * The values are returned in an array containing one integer value for each day since the + * first repetition of the habit until today. The first entry corresponds to today, the second + * entry corresponds to yesterday, and so on. + * + * @return values for the checkmarks in the interval + */ + @NonNull public int[] getAllValues() { Repetition oldestRep = habit.repetitions.getOldest(); if(oldestRep == null) return new int[0]; - Long toTimestamp = DateHelper.getStartOfToday(); Long fromTimestamp = oldestRep.timestamp; + Long toTimestamp = DateHelper.getStartOfToday(); + return getValues(fromTimestamp, toTimestamp); } - public void rebuild() + /** + * Computes and stores one checkmark for each day, since the first repetition until today. + * Days that already have a corresponding checkmark are skipped. + */ + protected void computeAll() { - long beginning; - long today = DateHelper.getStartOfToday(); - long day = DateHelper.millisecondsInOneDay; + long fromTimestamp = habit.repetitions.getOldestTimestamp(); + if(fromTimestamp == 0) return; - Checkmark newestCheckmark = getNewest(); - if (newestCheckmark == null) - { - Repetition oldestRep = habit.repetitions.getOldest(); - if (oldestRep == null) return; + Long toTimestamp = DateHelper.getStartOfToday(); - beginning = oldestRep.timestamp; - } - else - { - beginning = newestCheckmark.timestamp + day; - } + compute(fromTimestamp, toTimestamp); + } - if (beginning > today) return; + /** + * Computes and stores one checkmark for each day that falls inside the specified interval of + * time. Days that already have a corresponding checkmark are skipped. + * + * @param from timestamp for the beginning of the interval + * @param to timestamp for the end of the interval + */ + protected void compute(long from, final long to) + { + UIHelper.throwIfMainThread(); + + final long day = DateHelper.millisecondsInOneDay; - long beginningExtended = beginning - (long) (habit.freqDen) * day; - List reps = habit.repetitions.selectFromTo(beginningExtended, today).execute(); + Checkmark newestCheckmark = findNewest(); + if(newestCheckmark != null) + from = Math.max(from, newestCheckmark.timestamp + day); - int nDays = (int) ((today - beginning) / day) + 1; - int nDaysExtended = (int) ((today - beginningExtended) / day) + 1; + if(from > to) return; - int checks[] = new int[nDaysExtended]; + long fromExtended = from - (long) (habit.freqDen) * day; + List reps = habit.repetitions + .selectFromTo(fromExtended, to) + .execute(); + + final int nDays = (int) ((to - from) / day) + 1; + int nDaysExtended = (int) ((to - fromExtended) / day) + 1; + final int checks[] = new int[nDaysExtended]; - // explicit checks for (Repetition rep : reps) { - int offset = (int) ((rep.timestamp - beginningExtended) / day); - checks[nDaysExtended - offset - 1] = 2; + int offset = (int) ((rep.timestamp - fromExtended) / day); + checks[nDaysExtended - offset - 1] = Checkmark.CHECKED_EXPLICITLY; } - // implicit checks for (int i = 0; i < nDays; i++) { int counter = 0; @@ -135,44 +184,119 @@ public class CheckmarkList for (int j = 0; j < habit.freqDen; j++) if (checks[i + j] == 2) counter++; - if (counter >= habit.freqNum) checks[i] = Math.max(checks[i], 1); + if (counter >= habit.freqNum) + if(checks[i] != Checkmark.CHECKED_EXPLICITLY) + checks[i] = Checkmark.CHECKED_IMPLICITLY; } - ActiveAndroid.beginTransaction(); + + long timestamps[] = new long[nDays]; + for (int i = 0; i < nDays; i++) + timestamps[i] = to - i * day; + + insert(timestamps, checks); + } + + private void insert(long timestamps[], int values[]) + { + String query = "insert into Checkmarks(habit, timestamp, value) values (?,?,?)"; + + SQLiteDatabase db = Cache.openDatabase(); + db.beginTransaction(); try { - for (int i = 0; i < nDays; i++) + SQLiteStatement statement = db.compileStatement(query); + + for (int i = 0; i < timestamps.length; i++) { - Checkmark c = new Checkmark(); - c.habit = habit; - c.timestamp = today - i * day; - c.value = checks[i]; - c.save(); + statement.bindLong(1, habit.getId()); + statement.bindLong(2, timestamps[i]); + statement.bindLong(3, values[i]); + statement.execute(); } - ActiveAndroid.setTransactionSuccessful(); - } finally + db.setTransactionSuccessful(); + } + finally { - ActiveAndroid.endTransaction(); + db.endTransaction(); } } - public Checkmark getNewest() + /** + * Returns newest checkmark that has already been computed. Ignores any checkmark that has + * timestamp in the future. This does not update the cache. + * + * @return newest checkmark already computed + */ + @Nullable + protected Checkmark findNewest() { return new Select().from(Checkmark.class) .where("habit = ?", habit.getId()) + .and("timestamp <= ?", DateHelper.getStartOfToday()) .orderBy("timestamp desc") .limit(1) .executeSingle(); } - public int getCurrentValue() + /** + * Returns the checkmark for today. + * + * @return checkmark for today + */ + @Nullable + public Checkmark getToday() { - rebuild(); - Checkmark c = getNewest(); + long today = DateHelper.getStartOfToday(); + compute(today, today); + return findNewest(); + } - if(c != null) return c.value; - else return 0; + /** + * Returns the value of today's checkmark. + * + * @return value of today's checkmark + */ + public int getTodayValue() + { + Checkmark today = getToday(); + if(today != null) return today.value; + else return Checkmark.UNCHECKED; + } + + /** + * Writes the entire list of checkmarks to the given writer, in CSV format. There is one + * line for each checkmark. Each line contains two fields: timestamp and value. + * + * @param out the writer where the CSV will be output + * @throws IOException in case write operations fail + */ + + public void writeCSV(Writer out) throws IOException + { + computeAll(); + + SimpleDateFormat dateFormat = DateHelper.getCSVDateFormat(); + + String query = "select timestamp, value from checkmarks where habit = ? order by timestamp"; + String params[] = { habit.getId().toString() }; + + SQLiteDatabase db = Cache.openDatabase(); + Cursor cursor = db.rawQuery(query, params); + + if(!cursor.moveToFirst()) return; + + do + { + String timestamp = dateFormat.format(new Date(cursor.getLong(0))); + Integer value = cursor.getInt(1); + out.write(String.format("%s,%d\n", timestamp, value)); + + } while(cursor.moveToNext()); + + cursor.close(); + out.close(); } } diff --git a/app/src/main/java/org/isoron/uhabits/models/Habit.java b/app/src/main/java/org/isoron/uhabits/models/Habit.java index 02e3e67b6..a3c51e3fe 100644 --- a/app/src/main/java/org/isoron/uhabits/models/Habit.java +++ b/app/src/main/java/org/isoron/uhabits/models/Habit.java @@ -21,6 +21,8 @@ package org.isoron.uhabits.models; import android.annotation.SuppressLint; import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import com.activeandroid.ActiveAndroid; import com.activeandroid.Model; @@ -31,58 +33,137 @@ import com.activeandroid.query.From; import com.activeandroid.query.Select; import com.activeandroid.query.Update; import com.activeandroid.util.SQLiteUtils; +import com.opencsv.CSVWriter; -import org.isoron.helpers.ColorHelper; +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.helpers.DateHelper; +import java.io.IOException; +import java.io.Writer; import java.util.List; +import java.util.Locale; @Table(name = "Habits") public class Habit extends Model { + /** + * Name of the habit + */ @Column(name = "name") public String name; + /** + * Description of the habit + */ @Column(name = "description") public String description; + /** + * Frequency numerator. If a habit is performed 3 times in 7 days, this field equals 3. + */ @Column(name = "freq_num") public Integer freqNum; + /** + * Frequency denominator. If a habit is performed 3 times in 7 days, this field equals 7. + */ @Column(name = "freq_den") public Integer freqDen; + /** + * Color of the habit. The format is the same as android.graphics.Color. + */ @Column(name = "color") public Integer color; + /** + * Position of the habit. Habits are usually sorted by this field. + */ @Column(name = "position") public Integer position; + /** + * Hour of the day the reminder should be shown. If there is no reminder, this equals to null. + */ + @Nullable @Column(name = "reminder_hour") public Integer reminderHour; + /** + * Minute the reminder should be shown. If there is no reminder, this equals to null. + */ + @Nullable @Column(name = "reminder_min") public Integer reminderMin; + /** + * Days of the week the reminder should be shown. This field can be converted to a list of + * booleans using the method DateHelper.unpackWeekdayList and converted back to an integer by + * using the method DateHelper.packWeekdayList. If the habit has no reminders, this value + * should be ignored. + */ + @NonNull @Column(name = "reminder_days") public Integer reminderDays; + /** + * Not currently used. + */ @Column(name = "highlight") public Integer highlight; + /** + * Flag that indicates whether the habit is archived. Archived habits are usually omitted from + * listings, unless explicitly included. + */ @Column(name = "archived") public Integer archived; + /** + * List of streaks belonging to this habit. + */ + @NonNull public StreakList streaks; + + /** + * List of scores belonging to this habit. + */ + @NonNull public ScoreList scores; + + /** + * List of repetitions belonging to this habit. + */ + @NonNull public RepetitionList repetitions; + + /** + * List of checkmarks belonging to this habit. + */ + @NonNull public CheckmarkList checkmarks; + /** + * Constructs a habit with the same attributes as the specified habit. + * + * @param model the model whose attributes should be copied from + */ public Habit(Habit model) { + reminderDays = DateHelper.ALL_WEEK_DAYS; + copyAttributes(model); - initializeLists(); + + checkmarks = new CheckmarkList(this); + streaks = new StreakList(this); + scores = new ScoreList(this); + repetitions = new RepetitionList(this); } + /** + * Constructs a habit with default attributes. The habit is not archived, not highlighted, has + * no reminders and is placed in the last position of the list of habits. + */ public Habit() { this.color = ColorHelper.palette[5]; @@ -91,67 +172,112 @@ public class Habit extends Model this.archived = 0; this.freqDen = 7; this.freqNum = 3; - this.reminderDays = 127; - initializeLists(); - } + this.reminderDays = DateHelper.ALL_WEEK_DAYS; - private void initializeLists() - { + checkmarks = new CheckmarkList(this); streaks = new StreakList(this); scores = new ScoreList(this); repetitions = new RepetitionList(this); - checkmarks = new CheckmarkList(this); } - public static Habit get(Long id) + /** + * Returns the habit with specified id. + * + * @param id the id of the habit + * @return the habit, or null if none exist + */ + @Nullable + public static Habit get(long id) { return Habit.load(Habit.class, id); } + /** + * Returns a list of all habits, optionally including archived habits. + * + * @param includeArchive whether archived habits should be included the list + * @return list of all habits + */ + @NonNull public static List getAll(boolean includeArchive) { if(includeArchive) return selectWithArchived().execute(); else return select().execute(); } + /** + * Returns the habit that occupies a certain position. + * + * @param position the position of the desired habit + * @return the habit at that position, or null if there is none + */ + @Nullable + public static Habit getByPosition(int position) + { + return selectWithArchived().where("position = ?", position).executeSingle(); + } + + /** + * Changes the id of a habit on the database. + * + * @param oldId the original id + * @param newId the new id + */ @SuppressLint("DefaultLocale") public static void updateId(long oldId, long newId) { SQLiteUtils.execSql(String.format("update Habits set Id = %d where Id = %d", newId, oldId)); } + @NonNull protected static From select() { return new Select().from(Habit.class).where("archived = 0").orderBy("position"); } - public static From selectWithArchived() + @NonNull + protected static From selectWithArchived() { return new Select().from(Habit.class).orderBy("position"); } + /** + * Returns the total number of unarchived habits. + * + * @return number of unarchived habits + */ public static int count() { return select().count(); } + /** + * Returns the total number of habits, including archived habits. + * + * @return number of habits, including archived + */ public static int countWithArchived() { return selectWithArchived().count(); } - public static java.util.List getHighlightedHabits() - { - return select().where("highlight = 1") - .orderBy("reminder_hour desc, reminder_min desc") - .execute(); - } - - public static java.util.List getHabitsWithReminder() + /** + * Returns a list the habits that have a reminder. Does not include archived habits. + * + * @return list of habits with reminder + */ + @NonNull + public static List getHabitsWithReminder() { return select().where("reminder_hour is not null").execute(); } + /** + * Changes the position of a habit on the list. + * + * @param from the habit that should be moved + * @param to the habit that currently occupies the desired position + */ public static void reorder(Habit from, Habit to) { if(from == to) return; @@ -173,6 +299,10 @@ public class Habit extends Model from.save(); } + /** + * Recomputes the position for every habit in the database. It should never be necessary + * to call this method. + */ public static void rebuildOrder() { List habits = selectWithArchived().execute(); @@ -196,7 +326,12 @@ public class Habit extends Model } - public void copyAttributes(Habit model) + /** + * Copies all the attributes of the specified habit into this habit + * + * @param model the model whose attributes should be copied from + */ + public void copyAttributes(@NonNull Habit model) { this.name = model.name; this.description = model.description; @@ -211,12 +346,21 @@ public class Habit extends Model this.archived = model.archived; } - public void save(Long id) + /** + * Saves the habit on the database, and assigns the specified id to it. + * + * @param id the id that the habit should receive + */ + public void save(long id) { save(); Habit.updateId(getId(), id); } + /** + * Deletes the habit and all data associated to it, including checkmarks, repetitions and + * scores. + */ public void cascadeDelete() { Long id = getId(); @@ -238,25 +382,121 @@ public class Habit extends Model } } + /** + * Returns the public URI that identifies this habit + * @return the uri + */ public Uri getUri() { - return Uri.parse(String.format("content://org.isoron.uhabits/habit/%d", getId())); + String s = String.format(Locale.US, "content://org.isoron.uhabits/habit/%d", getId()); + return Uri.parse(s); } - public void archive() + /** + * Returns whether the habit is archived or not. + * @return true if archived + */ + public boolean isArchived() { - archived = 1; - save(); + return archived != 0; } - public void unarchive() + private static void updateAttributes(@NonNull List habits, @Nullable Integer color, + @Nullable Integer archived) { - archived = 0; - save(); + ActiveAndroid.beginTransaction(); + + try + { + for (Habit h : habits) + { + if(color != null) h.color = color; + if(archived != null) h.archived = archived; + h.save(); + } + + ActiveAndroid.setTransactionSuccessful(); + } + finally + { + ActiveAndroid.endTransaction(); + } } - public boolean isArchived() + /** + * Archives an entire list of habits + * + * @param habits the habits to be archived + */ + public static void archive(@NonNull List habits) { - return archived != 0; + updateAttributes(habits, null, 1); + } + + /** + * Unarchives an entire list of habits + * + * @param habits the habits to be unarchived + */ + public static void unarchive(@NonNull List habits) + { + updateAttributes(habits, null, 0); + } + + /** + * Sets the color for an entire list of habits. + * + * @param habits the habits to be modified + * @param color the new color to be set + */ + public static void setColor(@NonNull List habits, int color) + { + updateAttributes(habits, color, null); + } + + /** + * Checks whether the habit has a reminder set. + * + * @return true if habit has reminder + */ + public boolean hasReminder() + { + return (reminderHour != null && reminderMin != null); + } + + /** + * Clears the reminder for a habit. This sets all the related fields to null. + */ + public void clearReminder() + { + reminderHour = null; + reminderMin = null; + reminderDays = DateHelper.ALL_WEEK_DAYS; + } + + /** + * Writes the list of habits to the given writer, in CSV format. There is one line for each + * habit, containing the fields name, description, frequency numerator, frequency denominator + * and color. The color is written in HTML format (#000000). + * + * @param habits the list of habits to write + * @param out the writer that will receive the result + * @throws IOException if write operations fail + */ + public static void writeCSV(List habits, Writer out) throws IOException + { + String header[] = { "Name", "Description", "NumRepetitions", "Interval", "Color" }; + + CSVWriter csv = new CSVWriter(out); + csv.writeNext(header, false); + + for(Habit habit : habits) + { + String[] cols = { habit.name, habit.description, Integer.toString(habit.freqNum), + Integer.toString(habit.freqDen), ColorHelper.toHTML(habit.color) }; + csv.writeNext(cols, false); + } + + csv.close(); } } diff --git a/app/src/main/java/org/isoron/uhabits/models/Repetition.java b/app/src/main/java/org/isoron/uhabits/models/Repetition.java index a2503706f..f43243698 100644 --- a/app/src/main/java/org/isoron/uhabits/models/Repetition.java +++ b/app/src/main/java/org/isoron/uhabits/models/Repetition.java @@ -26,10 +26,15 @@ import com.activeandroid.annotation.Table; @Table(name = "Repetitions") public class Repetition extends Model { - + /** + * Habit to which this repetition belong. + */ @Column(name = "habit") public Habit habit; + /** + * Timestamp of the day this repetition occurred. Time of day should be midnight (UTC). + */ @Column(name = "timestamp") public Long timestamp; } diff --git a/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java b/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java index f21038748..5bfe22fba 100644 --- a/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java +++ b/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java @@ -21,47 +21,64 @@ package org.isoron.uhabits.models; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import com.activeandroid.Cache; import com.activeandroid.query.Delete; import com.activeandroid.query.From; import com.activeandroid.query.Select; +import com.activeandroid.util.SQLiteUtils; -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.helpers.DateHelper; import java.util.Arrays; -import java.util.Calendar; import java.util.GregorianCalendar; import java.util.HashMap; public class RepetitionList { - + @NonNull private Habit habit; - public RepetitionList(Habit habit) + public RepetitionList(@NonNull Habit habit) { this.habit = habit; } + @NonNull protected From select() { return new Select().from(Repetition.class) .where("habit = ?", habit.getId()) + .and("timestamp <= ?", DateHelper.getStartOfToday()) .orderBy("timestamp"); } + @NonNull protected From selectFromTo(long timeFrom, long timeTo) { return select().and("timestamp >= ?", timeFrom).and("timestamp <= ?", timeTo); } + /** + * Checks whether there is a repetition at a given timestamp. + * + * @param timestamp the timestamp to check + * @return true if there is a repetition + */ public boolean contains(long timestamp) { int count = select().where("timestamp = ?", timestamp).count(); return (count > 0); } + /** + * Deletes the repetition at a given timestamp, if it exists. + * + * @param timestamp the timestamp of the repetition to delete + */ public void delete(long timestamp) { new Delete().from(Repetition.class) @@ -70,44 +87,70 @@ public class RepetitionList .execute(); } - public Repetition getOldestNewerThan(long timestamp) - { - return select().where("timestamp > ?", timestamp).limit(1).executeSingle(); - } - + /** + * Toggles the repetition at a certain timestamp. That is, deletes the repetition if it exists + * or creates one if it does not. + * + * @param timestamp the timestamp of the repetition to toggle + */ public void toggle(long timestamp) { timestamp = DateHelper.getStartOfDay(timestamp); if (contains(timestamp)) - { delete(timestamp); - } else - { - Repetition rep = new Repetition(); - rep.habit = habit; - rep.timestamp = timestamp; - rep.save(); - } + insert(timestamp); - habit.scores.deleteNewerThan(timestamp); + habit.scores.invalidateNewerThan(timestamp); habit.checkmarks.deleteNewerThan(timestamp); habit.streaks.deleteNewerThan(timestamp); } + private void insert(long timestamp) + { + String[] args = { habit.getId().toString(), Long.toString(timestamp) }; + SQLiteUtils.execSql("insert into Repetitions(habit, timestamp) values (?,?)", args); + } + + /** + * Returns the oldest repetition for the habit. If there is no repetition, returns null. + * Repetitions in the future are discarded. + * + * @return oldest repetition for the habit + */ + @Nullable public Repetition getOldest() { return (Repetition) select().limit(1).executeSingle(); } - public boolean hasImplicitRepToday() + /** + * Returns the timestamp of the oldest repetition. If there are no repetitions, returns zero. + * Repetitions in the future are discarded. + * + * @return timestamp of the oldest repetition + */ + public long getOldestTimestamp() { - long today = DateHelper.getStartOfToday(); - int reps[] = habit.checkmarks.getValues(today - DateHelper.millisecondsInOneDay, today); - return (reps[0] > 0); + String[] args = { habit.getId().toString(), Long.toString(DateHelper.getStartOfToday()) }; + String query = "select timestamp from Repetitions where habit = ? and timestamp <= ? " + + "order by timestamp limit 1"; + + return DatabaseHelper.longQuery(query, args); } + /** + * Returns the total number of repetitions for each month, from the first repetition until + * today, grouped by day of week. The repetitions are returned in a HashMap. The key is the + * timestamp for the first day of the month, at midnight (00:00). The value is an integer + * array with 7 entries. The first entry contains the total number of repetitions during + * the specified month that occurred on a Saturday. The second entry corresponds to Sunday, + * and so on. If there are no repetitions during a certain month, the value is null. + * + * @return total number of repetitions by month versus day of week + */ + @NonNull public HashMap getWeekdayFrequency() { Repetition oldestRep = getOldest(); @@ -117,10 +160,11 @@ public class RepetitionList "strftime('%m', timestamp / 1000, 'unixepoch') as month," + "strftime('%w', timestamp / 1000, 'unixepoch') as weekday, " + "count(*) from repetitions " + - "where habit = ? " + + "where habit = ? and timestamp <= ? " + "group by year, month, weekday"; - String[] params = { habit.getId().toString() }; + String[] params = { habit.getId().toString(), + Long.toString(DateHelper.getStartOfToday()) }; SQLiteDatabase db = Cache.openDatabase(); Cursor cursor = db.rawQuery(query, params); @@ -156,4 +200,16 @@ public class RepetitionList return map; } + + /** + * Returns the total number of repetitions that happened within the specified interval of time. + * + * @param from beginning of the interval + * @param to end of the interval + * @return number of repetition in the given interval + */ + public int count(long from, long to) + { + return selectFromTo(from, to).count(); + } } diff --git a/app/src/main/java/org/isoron/uhabits/models/Score.java b/app/src/main/java/org/isoron/uhabits/models/Score.java index 2c3cd1b9d..5eba480e9 100644 --- a/app/src/main/java/org/isoron/uhabits/models/Score.java +++ b/app/src/main/java/org/isoron/uhabits/models/Score.java @@ -26,16 +26,94 @@ import com.activeandroid.annotation.Table; @Table(name = "Score") public class Score extends Model { + /** + * Minimum score value required to earn half a star. + */ public static final int HALF_STAR_CUTOFF = 9629750; + + /** + * Minimum score value required to earn a full star. + */ public static final int FULL_STAR_CUTOFF = 15407600; - public static final int MAX_SCORE = 19259500; + /** + * Maximum score value attainable by any habit. + */ + public static final int MAX_VALUE = 19259478; + + /** + * Status indicating that the habit has not earned any star. + */ + public static final int EMPTY_STAR = 0; + + /** + * Status indicating that the habit has earned half a star. + */ + public static final int HALF_STAR = 1; + + /** + * Status indicating that the habit has earned a full star. + */ + public static final int FULL_STAR = 2; + + /** + * Habit to which this score belongs to. + */ @Column(name = "habit") public Habit habit; + /** + * Timestamp of the day to which this score applies. Time of day should be midnight (UTC). + */ @Column(name = "timestamp") public Long timestamp; + /** + * Value of the score. + */ @Column(name = "score") public Integer score; + + /** + * Given the frequency of the habit, the previous score, and the value of the current checkmark, + * computes the current score for the habit. + * + * The frequency of the habit is the number of repetitions divided by the length of the + * interval. For example, a habit that should be repeated 3 times in 8 days has frequency 3.0 / + * 8.0 = 0.375. + * + * The checkmarkValue should be UNCHECKED, CHECKED_IMPLICITLY or CHECK_EXPLICITLY. + * + * @param frequency the frequency of the habit + * @param previousScore the previous score of the habit + * @param checkmarkValue the value of the current checkmark + * + * @return the current score + */ + public static int compute(double frequency, int previousScore, int checkmarkValue) + { + double multiplier = Math.pow(0.5, 1.0 / (14.0 / frequency - 1)); + int score = (int) (previousScore * multiplier); + + if (checkmarkValue == Checkmark.CHECKED_EXPLICITLY) + { + score += 1000000; + score = Math.min(score, Score.MAX_VALUE); + } + + return score; + } + + /** + * Return the current star status for the habit, which can one of EMPTY_STAR, HALF_STAR or + * FULL_STAR. + * + * @return current star status + */ + public int getStarStatus() + { + if(score >= Score.FULL_STAR_CUTOFF) return Score.FULL_STAR; + if(score >= Score.HALF_STAR_CUTOFF) return Score.HALF_STAR; + return Score.EMPTY_STAR; + } } diff --git a/app/src/main/java/org/isoron/uhabits/models/ScoreList.java b/app/src/main/java/org/isoron/uhabits/models/ScoreList.java index c703e393a..eca89b9e0 100644 --- a/app/src/main/java/org/isoron/uhabits/models/ScoreList.java +++ b/app/src/main/java/org/isoron/uhabits/models/ScoreList.java @@ -21,42 +21,55 @@ package org.isoron.uhabits.models; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteStatement; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; -import com.activeandroid.ActiveAndroid; import com.activeandroid.Cache; import com.activeandroid.query.Delete; +import com.activeandroid.query.From; import com.activeandroid.query.Select; +import com.activeandroid.util.SQLiteUtils; -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.helpers.UIHelper; + +import java.io.IOException; +import java.io.Writer; +import java.text.SimpleDateFormat; +import java.util.Date; public class ScoreList { + @NonNull private Habit habit; - public ScoreList(Habit habit) + /** + * Constructs a new ScoreList associated with the given habit. + * + * @param habit the habit this list should be associated with + */ + public ScoreList(@NonNull Habit habit) { this.habit = habit; } - public int getCurrentStarStatus() - { - int score = getNewestValue(); - - if(score >= Score.FULL_STAR_CUTOFF) return 2; - else if(score >= Score.HALF_STAR_CUTOFF) return 1; - else return 0; - } - - public Score getNewest() + protected From select() { - return new Select().from(Score.class) + return new Select() + .from(Score.class) .where("habit = ?", habit.getId()) - .orderBy("timestamp desc") - .limit(1) - .executeSingle(); + .orderBy("timestamp desc"); } - public void deleteNewerThan(long timestamp) + /** + * Marks all scores that have timestamp equal to or newer than the given timestamp as invalid. + * Any following getValue calls will trigger the scores to be recomputed. + * + * @param timestamp the oldest timestamp that should be invalidated + */ + public void invalidateNewerThan(long timestamp) { new Delete().from(Score.class) .where("habit = ?", habit.getId()) @@ -64,79 +77,193 @@ public class ScoreList .execute(); } - public Integer getNewestValue() + /** + * Computes and saves the scores that are missing since the first repetition of the habit. + */ + private void computeAll() { - int beginningScore; - long beginningTime; + long fromTimestamp = habit.repetitions.getOldestTimestamp(); + if(fromTimestamp == 0) return; - long today = DateHelper.getStartOfDay(DateHelper.getLocalTime()); - long day = DateHelper.millisecondsInOneDay; + long toTimestamp = DateHelper.getStartOfToday(); + compute(fromTimestamp, toTimestamp); + } - double freq = ((double) habit.freqNum) / habit.freqDen; - double multiplier = Math.pow(0.5, 1.0 / (14.0 / freq - 1)); + /** + * Computes and saves the scores that are missing inside a given time interval. Scores that + * have already been computed are skipped, therefore there is no harm in calling this function + * more times, or with larger intervals, than strictly needed. The endpoints of the interval are + * included. + * + * This function assumes that there are no gaps on the scores. That is, if the newest score has + * timestamp t, then every score with timestamp lower than t has already been computed. + * + * @param from timestamp of the beginning of the interval + * @param to timestamp of the end of the time interval + */ + protected void compute(long from, long to) + { + UIHelper.throwIfMainThread(); - Score newestScore = getNewest(); - if (newestScore == null) - { - Repetition oldestRep = habit.repetitions.getOldest(); - if (oldestRep == null) return 0; - beginningTime = oldestRep.timestamp; - beginningScore = 0; - } - else + final long day = DateHelper.millisecondsInOneDay; + final double freq = ((double) habit.freqNum) / habit.freqDen; + + int newestScoreValue = findNewestValue(); + long newestTimestamp = findNewestTimestamp(); + + if(newestTimestamp > 0) + from = newestTimestamp + day; + + final int checkmarkValues[] = habit.checkmarks.getValues(from, to); + final long beginning = from; + + int lastScore = newestScoreValue; + int size = checkmarkValues.length; + + long timestamps[] = new long[size]; + long values[] = new long[size]; + + for (int i = 0; i < checkmarkValues.length; i++) { - beginningTime = newestScore.timestamp + day; - beginningScore = newestScore.score; + int checkmarkValue = checkmarkValues[checkmarkValues.length - i - 1]; + lastScore = Score.compute(freq, lastScore, checkmarkValue); + timestamps[i] = beginning + day * i; + values[i] = lastScore; } - long nDays = (today - beginningTime) / day; - if (nDays < 0) return newestScore.score; + insert(timestamps, values); + } + + /** + * Returns the value of the most recent score that was already computed. If no score has been + * computed yet, returns zero. + * + * @return value of newest score, or zero if none exist + */ + protected int findNewestValue() + { + String args[] = { habit.getId().toString() }; + String query = "select score from Score where habit = ? order by timestamp desc limit 1"; + return SQLiteUtils.intQuery(query, args); + } - int reps[] = habit.checkmarks.getValues(beginningTime, today); + private long findNewestTimestamp() + { + String args[] = { habit.getId().toString() }; + String query = "select timestamp from Score where habit = ? order by timestamp desc limit 1"; + return DatabaseHelper.longQuery(query, args); + } - ActiveAndroid.beginTransaction(); - int lastScore = beginningScore; + private void insert(long timestamps[], long values[]) + { + String query = "insert into Score(habit, timestamp, score) values (?,?,?)"; + + SQLiteDatabase db = Cache.openDatabase(); + db.beginTransaction(); try { - for (int i = 0; i < reps.length; i++) + SQLiteStatement statement = db.compileStatement(query); + + for (int i = 0; i < timestamps.length; i++) { - Score s = new Score(); - s.habit = habit; - s.timestamp = beginningTime + day * i; - s.score = (int) (lastScore * multiplier); - if (reps[reps.length - i - 1] == 2) - { - s.score += 1000000; - s.score = Math.min(s.score, Score.MAX_SCORE); - } - s.save(); - - lastScore = s.score; + statement.bindLong(1, habit.getId()); + statement.bindLong(2, timestamps[i]); + statement.bindLong(3, values[i]); + statement.execute(); } - ActiveAndroid.setTransactionSuccessful(); - } finally + db.setTransactionSuccessful(); + } + finally { - ActiveAndroid.endTransaction(); + db.endTransaction(); } + } + + /** + * Returns the score for a certain day. + * + * @param timestamp the timestamp for the day + * @return the score for the day + */ + @Nullable + protected Score get(long timestamp) + { + Repetition oldestRep = habit.repetitions.getOldest(); + if(oldestRep == null) return null; + + compute(oldestRep.timestamp, timestamp); + + return select().where("timestamp = ?", timestamp).executeSingle(); + } - return lastScore; + /** + * Returns the value of the score for a given day. + * + * @param timestamp the timestamp of a day + * @return score for that day + */ + public int getValue(long timestamp) + { + computeAll(); + String[] args = { habit.getId().toString(), Long.toString(timestamp) }; + return SQLiteUtils.intQuery("select score from Score where habit = ? and timestamp = ?", args); } - public int[] getAllValues(Long fromTimestamp, Long toTimestamp, Long divisor) + /** + * Returns the values of all the scores, from day of the first repetition until today, grouped + * in chunks of specified size. + * + * If the group size is one, then the value of each score is returned individually. If the group + * is, for example, seven, then the days are grouped in groups of seven consecutive days. + * + * The values are returned in an array of integers, with one entry for each group of days in the + * interval. This value corresponds to the average of the scores for the days inside the group. + * The first entry corresponds to the ending of the interval (that is, the most recent group of + * days). The last entry corresponds to the beginning of the interval. As usual, the time of the + * day for the timestamps should be midnight (UTC). The endpoints of the interval are included. + * + * The values are returned in an integer array. There is one entry for each day inside the + * interval. The first entry corresponds to today, while the last entry corresponds to the + * day of the oldest repetition. + * + * @param divisor the size of the groups + * @return array of values, with one entry for each group of days + */ + @NonNull + public int[] getAllValues(long divisor) { - // Force rebuild of the score table - getNewestValue(); + Repetition oldestRep = habit.repetitions.getOldest(); + if(oldestRep == null) return new int[0]; - Long offset = toTimestamp - (divisor - 1) * DateHelper.millisecondsInOneDay; + long fromTimestamp = oldestRep.timestamp; + long toTimestamp = DateHelper.getStartOfToday(); + return getValues(fromTimestamp, toTimestamp, divisor); + } + + /** + * Same as getAllValues(long), but using a specified interval. + * + * @param from beginning of the interval (included) + * @param to end of the interval (included) + * @param divisor size of the groups + * @return array of values, with one entry for each group of days + */ + @NonNull + protected int[] getValues(long from, long to, long divisor) + { + compute(from, to); + + divisor *= DateHelper.millisecondsInOneDay; + Long offset = to + divisor; String query = "select ((timestamp - ?) / ?) as time, avg(score) from Score " + - "where habit = ? and timestamp > ? and timestamp <= ? " + + "where habit = ? and timestamp >= ? and timestamp <= ? " + "group by time order by time desc"; - String params[] = { offset.toString(), divisor.toString(), habit.getId().toString(), - fromTimestamp.toString(), toTimestamp.toString()}; + String params[] = { offset.toString(), Long.toString(divisor), habit.getId().toString(), + Long.toString(from), Long.toString(to) }; SQLiteDatabase db = Cache.openDatabase(); Cursor cursor = db.rawQuery(query, params); @@ -148,22 +275,71 @@ public class ScoreList do { - scores[k++] = (int) cursor.getLong(1); + scores[k++] = (int) cursor.getFloat(1); } while (cursor.moveToNext()); cursor.close(); return scores; + } + /** + * Returns the score for today. + * + * @return score for today + */ + @Nullable + protected Score getToday() + { + return get(DateHelper.getStartOfToday()); } - public int[] getAllValues(long divisor) + /** + * Returns the value of the score for today. + * + * @return value of today's score + */ + public int getTodayValue() { - Repetition oldestRep = habit.repetitions.getOldest(); - if(oldestRep == null) return new int[0]; + return getValue(DateHelper.getStartOfToday()); + } - long fromTimestamp = oldestRep.timestamp; - long toTimestamp = DateHelper.getStartOfToday(); - return getAllValues(fromTimestamp, toTimestamp, divisor); + /** + * Returns the star status for today. The returned value is either Score.EMPTY_STAR, + * Score.HALF_STAR or Score.FULL_STAR. + * + * @return star status for today + */ + public int getTodayStarStatus() + { + Score score = getToday(); + if(score != null) return score.getStarStatus(); + else return Score.EMPTY_STAR; + } + + public void writeCSV(Writer out) throws IOException + { + computeAll(); + + SimpleDateFormat dateFormat = DateHelper.getCSVDateFormat(); + + String query = "select timestamp, score from score where habit = ? order by timestamp"; + String params[] = { habit.getId().toString() }; + + SQLiteDatabase db = Cache.openDatabase(); + Cursor cursor = db.rawQuery(query, params); + + if(!cursor.moveToFirst()) return; + + do + { + String timestamp = dateFormat.format(new Date(cursor.getLong(0))); + String score = String.format("%.4f", ((float) cursor.getInt(1)) / Score.MAX_VALUE); + out.write(String.format("%s,%s\n", timestamp, score)); + + } while(cursor.moveToNext()); + + cursor.close(); + out.close(); } } diff --git a/app/src/main/java/org/isoron/uhabits/models/StreakList.java b/app/src/main/java/org/isoron/uhabits/models/StreakList.java index 5f34e0634..691403d25 100644 --- a/app/src/main/java/org/isoron/uhabits/models/StreakList.java +++ b/app/src/main/java/org/isoron/uhabits/models/StreakList.java @@ -19,13 +19,19 @@ package org.isoron.uhabits.models; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + import com.activeandroid.ActiveAndroid; +import com.activeandroid.Cache; import com.activeandroid.query.Delete; import com.activeandroid.query.Select; -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.helpers.UIHelper; import java.util.ArrayList; +import java.util.LinkedList; import java.util.List; public class StreakList @@ -37,14 +43,37 @@ public class StreakList this.habit = habit; } - public List getAll() + public List getAll(int limit) { rebuild(); - return new Select().from(Streak.class) - .where("habit = ?", habit.getId()) - .orderBy("end asc") - .execute(); + String query = "select * from (select * from streak where habit=? " + + "order by end <> ?, length desc, end desc limit ?) order by end desc"; + + String params[] = {habit.getId().toString(), Long.toString(DateHelper.getStartOfToday()), + Integer.toString(limit)}; + + SQLiteDatabase db = Cache.openDatabase(); + Cursor cursor = db.rawQuery(query, params); + + if(!cursor.moveToFirst()) + { + cursor.close(); + return new LinkedList<>(); + } + + List streaks = new LinkedList<>(); + + do + { + Streak s = Streak.load(Streak.class, cursor.getInt(0)); + streaks.add(s); + } + while (cursor.moveToNext()); + + cursor.close(); + return streaks; + } public Streak getNewest() @@ -58,6 +87,8 @@ public class StreakList public void rebuild() { + UIHelper.throwIfMainThread(); + long beginning; long today = DateHelper.getStartOfToday(); long day = DateHelper.millisecondsInOneDay; diff --git a/app/src/main/java/org/isoron/uhabits/tasks/BaseTask.java b/app/src/main/java/org/isoron/uhabits/tasks/BaseTask.java new file mode 100644 index 000000000..d9542c84b --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/tasks/BaseTask.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.tasks; + +import android.os.AsyncTask; +import android.os.Build; + +import java.util.concurrent.TimeoutException; + +public abstract class BaseTask extends AsyncTask +{ + private static int activeTaskCount; + + @Override + protected void onPreExecute() + { + super.onPreExecute(); + activeTaskCount++; + } + + @Override + protected void onPostExecute(Void aVoid) + { + activeTaskCount--; + super.onPostExecute(null); + } + + @Override + protected final Void doInBackground(Void... params) + { + doInBackground(); + return null; + } + + protected abstract void doInBackground(); + + public static void waitForTasks(long timeout) + throws TimeoutException, InterruptedException + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) + throw new UnsupportedOperationException("waitForTasks requires API 16+"); + + int poolInterval = 100; + + while(timeout > 0) + { + if(activeTaskCount == 0) return; + + timeout -= poolInterval; + Thread.sleep(poolInterval); + } + + throw new TimeoutException(); + } +} diff --git a/app/src/main/java/org/isoron/uhabits/tasks/ExportCSVTask.java b/app/src/main/java/org/isoron/uhabits/tasks/ExportCSVTask.java new file mode 100644 index 000000000..5c85bc89e --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/tasks/ExportCSVTask.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.tasks; + +import android.support.annotation.Nullable; +import android.view.View; +import android.widget.ProgressBar; + +import org.isoron.uhabits.helpers.DatabaseHelper; +import org.isoron.uhabits.io.HabitsCSVExporter; +import org.isoron.uhabits.models.Habit; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +public class ExportCSVTask extends BaseTask +{ + public interface Listener + { + void onExportCSVFinished(@Nullable String archiveFilename); + } + + private ProgressBar progressBar; + private final List selectedHabits; + private String archiveFilename; + private ExportCSVTask.Listener listener; + + public ExportCSVTask(List selectedHabits, ProgressBar progressBar) + { + this.selectedHabits = selectedHabits; + this.progressBar = progressBar; + } + + public void setListener(Listener listener) + { + this.listener = listener; + } + + @Override + protected void onPreExecute() + { + super.onPreExecute(); + + if(progressBar != null) + { + progressBar.setIndeterminate(true); + progressBar.setVisibility(View.VISIBLE); + } + } + + @Override + protected void onPostExecute(Void aVoid) + { + if(listener != null) + listener.onExportCSVFinished(archiveFilename); + + if(progressBar != null) + progressBar.setVisibility(View.GONE); + + super.onPostExecute(null); + } + + @Override + protected void doInBackground() + { + try + { + File dir = DatabaseHelper.getFilesDir("CSV"); + if(dir == null) return; + + HabitsCSVExporter exporter = new HabitsCSVExporter(selectedHabits, dir); + archiveFilename = exporter.writeArchive(); + } + catch (IOException e) + { + e.printStackTrace(); + } + } +} diff --git a/app/src/main/java/org/isoron/uhabits/tasks/ExportDBTask.java b/app/src/main/java/org/isoron/uhabits/tasks/ExportDBTask.java new file mode 100644 index 000000000..4e184335f --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/tasks/ExportDBTask.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.tasks; + +import android.support.annotation.Nullable; +import android.view.View; +import android.widget.ProgressBar; + +import org.isoron.uhabits.helpers.DatabaseHelper; + +import java.io.File; +import java.io.IOException; + +public class ExportDBTask extends BaseTask +{ + public interface Listener + { + void onExportDBFinished(@Nullable String filename); + } + + private ProgressBar progressBar; + private String filename; + private Listener listener; + + public ExportDBTask(ProgressBar progressBar) + { + this.progressBar = progressBar; + } + + public void setListener(Listener listener) + { + this.listener = listener; + } + + @Override + protected void onPreExecute() + { + super.onPreExecute(); + + if(progressBar != null) + { + progressBar.setIndeterminate(true); + progressBar.setVisibility(View.VISIBLE); + } + } + + @Override + protected void onPostExecute(Void aVoid) + { + if(listener != null) + listener.onExportDBFinished(filename); + + if(progressBar != null) + progressBar.setVisibility(View.GONE); + + super.onPostExecute(null); + } + + @Override + protected void doInBackground() + { + filename = null; + + try + { + File dir = DatabaseHelper.getFilesDir("Backups"); + if(dir == null) return; + + filename = DatabaseHelper.saveDatabaseCopy(dir); + } + catch(IOException e) + { + e.printStackTrace(); + } + } +} diff --git a/app/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java b/app/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java new file mode 100644 index 000000000..477f1f51b --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.tasks; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.View; +import android.widget.ProgressBar; + +import org.isoron.uhabits.io.GenericImporter; + +import java.io.File; + +public class ImportDataTask extends BaseTask +{ + public static final int SUCCESS = 1; + public static final int NOT_RECOGNIZED = 2; + public static final int FAILED = 3; + + public interface Listener + { + void onImportFinished(int result); + } + + @Nullable + private final ProgressBar progressBar; + + @NonNull + private final File file; + + @Nullable + private Listener listener; + + int result; + + public ImportDataTask(@NonNull File file, @Nullable ProgressBar progressBar) + { + this.file = file; + this.progressBar = progressBar; + } + + public void setListener(@Nullable Listener listener) + { + this.listener = listener; + } + + @Override + protected void onPreExecute() + { + super.onPreExecute(); + + if(progressBar != null) + { + progressBar.setIndeterminate(true); + progressBar.setVisibility(View.VISIBLE); + } + } + + @Override + protected void onPostExecute(Void aVoid) + { + if(progressBar != null) + progressBar.setVisibility(View.GONE); + + if(listener != null) listener.onImportFinished(result); + + super.onPostExecute(null); + } + + @Override + protected void doInBackground() + { + try + { + GenericImporter importer = new GenericImporter(); + if(importer.canHandle(file)) + { + importer.importHabitsFromFile(file); + result = SUCCESS; + } + else + { + result = NOT_RECOGNIZED; + } + } + catch (Exception e) + { + result = FAILED; + e.printStackTrace(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/isoron/uhabits/tasks/ToggleRepetitionTask.java b/app/src/main/java/org/isoron/uhabits/tasks/ToggleRepetitionTask.java new file mode 100644 index 000000000..3b46ec95c --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/tasks/ToggleRepetitionTask.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.tasks; + +import org.isoron.uhabits.models.Habit; + +public class ToggleRepetitionTask extends BaseTask +{; + public interface Listener { + void onToggleRepetitionFinished(); + } + + private Listener listener; + private final Habit habit; + private final Long timestamp; + + public ToggleRepetitionTask(Habit habit, Long timestamp) + { + this.timestamp = timestamp; + this.habit = habit; + } + + @Override + protected void doInBackground() + { + habit.repetitions.toggle(timestamp); + } + + @Override + protected void onPostExecute(Void aVoid) + { + if(listener != null) listener.onToggleRepetitionFinished(); + super.onPostExecute(null); + } + + public void setListener(Listener listener) + { + this.listener = listener; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java b/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java index d6b6fcd5c..8819e8e37 100644 --- a/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java +++ b/app/src/main/java/org/isoron/uhabits/views/CheckmarkView.java @@ -32,39 +32,35 @@ import android.text.TextPaint; import android.util.AttributeSet; import android.view.View; -import org.isoron.helpers.ColorHelper; import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.ColorHelper; import org.isoron.uhabits.models.Habit; -public class CheckmarkView extends View +public class CheckmarkView extends View implements HabitDataView { private Paint pCard; private Paint pIcon; private int primaryColor; - private int backgroundColor; private int timesColor; private int darkGrey; private int width; private int height; - private int leftMargin; - private int topMargin; - private int padding; + private float leftMargin; + private float topMargin; + private float padding; private String label; private String fa_check; private String fa_times; - private String fa_full_star; - private String fa_half_star; - private String fa_empty_star; private int check_status; - private int star_status; private Rect rect; private TextPaint textPaint; private StaticLayout labelLayout; + private Habit habit; public CheckmarkView(Context context) { @@ -97,28 +93,19 @@ public class CheckmarkView extends View fa_check = context.getString(R.string.fa_check); fa_times = context.getString(R.string.fa_times); - fa_empty_star = context.getString(R.string.fa_star_o); - fa_half_star = context.getString(R.string.fa_star_half_o); - fa_full_star = context.getString(R.string.fa_star); primaryColor = ColorHelper.palette[10]; - backgroundColor = Color.argb(255, 255, 255, 255); timesColor = Color.argb(128, 255, 255, 255); darkGrey = Color.argb(64, 0, 0, 0); rect = new Rect(); - check_status = 2; - star_status = 0; - label = "Wake up early"; + check_status = 0; + label = "Habit"; } public void setHabit(Habit habit) { - this.check_status = habit.checkmarks.getCurrentValue(); - this.star_status = habit.scores.getCurrentStarStatus(); - this.primaryColor = Color.argb(230, Color.red(habit.color), Color.green(habit.color), Color.blue(habit.color)); - this.label = habit.name; - updateLabel(); + this.habit = habit; } @Override @@ -152,8 +139,6 @@ public class CheckmarkView extends View pIcon.setTextSize(width * 0.5f); pIcon.getTextBounds(text, 0, 1, rect); -// canvas.drawLine(0, 0.67f * height, width, 0.67f * height, pIcon); - int y = (int) ((0.67f * height - rect.bottom - rect.top) / 2); canvas.drawText(text, width / 2, y, pIcon); } @@ -188,19 +173,30 @@ public class CheckmarkView extends View this.width = getMeasuredWidth(); this.height = getMeasuredHeight(); - leftMargin = (int) (width * 0.015); - topMargin = (int) (height * 0.015); + leftMargin = (width * 0.015f); + topMargin = (height * 0.015f); padding = 8 * leftMargin; textPaint.setTextSize(0.15f * width); updateLabel(); } + public void refreshData() + { + this.check_status = habit.checkmarks.getTodayValue(); + this.primaryColor = Color.argb(230, Color.red(habit.color), Color.green(habit.color), + Color.blue(habit.color)); + this.label = habit.name; + + updateLabel(); + postInvalidate(); + } + private void updateLabel() { textPaint.setColor(Color.WHITE); - labelLayout = new StaticLayout(label, textPaint, width - 2 * leftMargin - 2 * padding, + labelLayout = new StaticLayout(label, textPaint, + (int) (width - 2 * leftMargin - 2 * padding), Layout.Alignment.ALIGN_CENTER, 1.0f, 0.0f, false); } - } diff --git a/app/src/main/java/org/isoron/uhabits/views/HabitDataView.java b/app/src/main/java/org/isoron/uhabits/views/HabitDataView.java new file mode 100644 index 000000000..b1e239d5e --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/views/HabitDataView.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.views; + +import org.isoron.uhabits.models.Habit; + +public interface HabitDataView +{ + void setHabit(Habit habit); + + void refreshData(); + + void postInvalidate(); +} diff --git a/app/src/main/java/org/isoron/uhabits/views/HabitFrequencyView.java b/app/src/main/java/org/isoron/uhabits/views/HabitFrequencyView.java index 42c41b64b..55abb3959 100644 --- a/app/src/main/java/org/isoron/uhabits/views/HabitFrequencyView.java +++ b/app/src/main/java/org/isoron/uhabits/views/HabitFrequencyView.java @@ -26,8 +26,8 @@ import android.graphics.Paint; import android.graphics.RectF; import android.util.AttributeSet; -import org.isoron.helpers.ColorHelper; -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.helpers.DateHelper; import org.isoron.uhabits.models.Habit; import java.text.SimpleDateFormat; @@ -35,11 +35,9 @@ import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; import java.util.HashMap; -import java.util.Locale; import java.util.Random; -import java.util.TimeZone; -public class HabitFrequencyView extends ScrollableDataView +public class HabitFrequencyView extends ScrollableDataView implements HabitDataView { private Paint pGrid; @@ -53,7 +51,7 @@ public class HabitFrequencyView extends ScrollableDataView private int baseSize; private int paddingTop; - private int columnWidth; + private float columnWidth; private int columnHeight; private int nColumns; @@ -66,12 +64,17 @@ public class HabitFrequencyView extends ScrollableDataView private HashMap frequency; private String wdays[]; + public HabitFrequencyView(Context context) + { + super(context); + init(); + } + public HabitFrequencyView(Context context, AttributeSet attrs) { super(context, attrs); this.primaryColor = ColorHelper.palette[7]; this.frequency = new HashMap<>(); - wdays = DateHelper.getShortDayNames(); init(); } @@ -79,21 +82,17 @@ public class HabitFrequencyView extends ScrollableDataView { this.habit = habit; createColors(); - refreshData(); - postInvalidate(); } private void init() { - refreshData(); createPaints(); createColors(); - dfMonth = new SimpleDateFormat("MMM", Locale.getDefault()); - dfYear = new SimpleDateFormat("yyyy", Locale.getDefault()); + wdays = DateHelper.getShortDayNames(); - dfMonth.setTimeZone(TimeZone.getTimeZone("GMT")); - dfYear.setTimeZone(TimeZone.getTimeZone("GMT")); + dfMonth = DateHelper.getDateFormat("MMM"); + dfYear = DateHelper.getDateFormat("yyyy"); rect = new RectF(); prevRect = new RectF(); @@ -155,16 +154,33 @@ public class HabitFrequencyView extends ScrollableDataView baseSize = height / 8; setScrollerBucketSize(baseSize); - columnWidth = baseSize; - columnHeight = 8 * baseSize; - nColumns = width / baseSize; - paddingTop = 0; - 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 float getMaxMonthWidth() + { + float maxMonthWidth = 0; + GregorianCalendar day = DateHelper.getStartOfTodayCalendar(); + + 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; } public void refreshData() @@ -174,7 +190,7 @@ public class HabitFrequencyView extends ScrollableDataView else if(habit != null) frequency = habit.repetitions.getWeekdayFrequency(); - invalidate(); + postInvalidate(); } private void generateRandomData() @@ -234,7 +250,7 @@ public class HabitFrequencyView extends ScrollableDataView for (int i = 0; i < 7; i++) { rect.set(0, 0, baseSize, baseSize); - rect.offset(prevRect.left, prevRect.top + columnWidth * i); + rect.offset(prevRect.left, prevRect.top + baseSize * i); if(values != null) drawMarker(canvas, rect, values[i]); diff --git a/app/src/main/java/org/isoron/uhabits/views/HabitHistoryView.java b/app/src/main/java/org/isoron/uhabits/views/HabitHistoryView.java index 45e1bfa1c..0958bf275 100644 --- a/app/src/main/java/org/isoron/uhabits/views/HabitHistoryView.java +++ b/app/src/main/java/org/isoron/uhabits/views/HabitHistoryView.java @@ -24,34 +24,37 @@ import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Paint.Align; -import android.graphics.Rect; -import android.os.AsyncTask; +import android.graphics.RectF; import android.util.AttributeSet; +import android.view.HapticFeedbackConstants; import android.view.MotionEvent; -import org.isoron.helpers.ColorHelper; -import org.isoron.helpers.DateHelper; import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.helpers.UIHelper; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.tasks.BaseTask; +import org.isoron.uhabits.tasks.ToggleRepetitionTask; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.GregorianCalendar; -import java.util.Locale; import java.util.Random; -public class HabitHistoryView extends ScrollableDataView +public class HabitHistoryView extends ScrollableDataView implements HabitDataView, + ToggleRepetitionTask.Listener { private Habit habit; private int[] checkmarks; private Paint pSquareBg, pSquareFg, pTextHeader; - private int squareSpacing; + private float squareSpacing; private float squareTextOffset; private float headerTextOffset; - private int columnWidth; - private int columnHeight; + private float columnWidth; + private float columnHeight; private int nColumns; private String wdays[]; @@ -62,19 +65,22 @@ public class HabitHistoryView extends ScrollableDataView private int nDays; private int todayWeekday; private int colors[]; - private Rect baseLocation; + private RectF baseLocation; private int primaryColor; private boolean isBackgroundTransparent; private int textColor; private boolean isEditable; + public HabitHistoryView(Context context) + { + super(context); + init(); + } + public HabitHistoryView(Context context, AttributeSet attrs) { super(context, attrs); - this.primaryColor = ColorHelper.palette[7]; - this.checkmarks = new int[0]; - this.isEditable = false; init(); } @@ -82,21 +88,21 @@ public class HabitHistoryView extends ScrollableDataView { this.habit = habit; createColors(); - refreshData(); - postInvalidate(); } private void init() { - refreshData(); createPaints(); createColors(); + isEditable = false; + checkmarks = new int[0]; + primaryColor = ColorHelper.palette[7]; wdays = DateHelper.getShortDayNames(); - dfMonth = new SimpleDateFormat("MMM", Locale.getDefault()); - dfYear = new SimpleDateFormat("yyyy", Locale.getDefault()); + dfMonth = DateHelper.getDateFormat("MMM"); + dfYear = DateHelper.getDateFormat("yyyy"); - baseLocation = new Rect(); + baseLocation = new RectF(); } private void updateDate() @@ -123,11 +129,11 @@ public class HabitHistoryView extends ScrollableDataView protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { if(height < 8) height = 200; - int baseSize = height / 8; - setScrollerBucketSize(baseSize); + float baseSize = height / 8.0f; + setScrollerBucketSize((int) baseSize); - squareSpacing = (int) Math.floor(baseSize / 15.0); - int maxTextSize = getResources().getDimensionPixelSize(R.dimen.history_max_font_size); + squareSpacing = UIHelper.dpToPixels(getContext(), 1.0f); + float maxTextSize = getResources().getDimensionPixelSize(R.dimen.regularTextSize); float textSize = Math.min(baseSize * 0.5f, maxTextSize); pSquareFg.setTextSize(textSize); @@ -135,26 +141,22 @@ public class HabitHistoryView extends ScrollableDataView squareTextOffset = pSquareFg.getFontSpacing() * 0.4f; headerTextOffset = pTextHeader.getFontSpacing() * 0.3f; - int rightLabelWidth = getWeekdayLabelWidth(); - int horizontalPadding = getPaddingRight() + getPaddingLeft(); + float rightLabelWidth = getWeekdayLabelWidth() + headerTextOffset; + float horizontalPadding = getPaddingRight() + getPaddingLeft(); columnWidth = baseSize; columnHeight = 8 * baseSize; - nColumns = (width - rightLabelWidth - horizontalPadding) / baseSize + 1; + nColumns = (int)((width - rightLabelWidth - horizontalPadding) / baseSize) + 1; updateDate(); } - private int getWeekdayLabelWidth() + private float getWeekdayLabelWidth() { - int width = 0; - Rect bounds = new Rect(); + float width = 0; for(String w : wdays) - { - pSquareFg.getTextBounds(w, 0, w.length(), bounds); - width = Math.max(width, bounds.right); - } + width = Math.max(width, pSquareFg.measureText(w)); return width; } @@ -215,7 +217,7 @@ public class HabitHistoryView extends ScrollableDataView } updateDate(); - invalidate(); + postInvalidate(); } private void generateRandomData() @@ -239,7 +241,6 @@ public class HabitHistoryView extends ScrollableDataView private String previousMonth; private String previousYear; - private boolean justPrintedYear; @Override protected void onDraw(Canvas canvas) @@ -249,10 +250,9 @@ public class HabitHistoryView extends ScrollableDataView baseLocation.set(0, 0, columnWidth - squareSpacing, columnWidth - squareSpacing); baseLocation.offset(getPaddingLeft(), getPaddingTop()); + headerOverflow = 0; previousMonth = ""; previousYear = ""; - justPrintedYear = false; - pTextHeader.setColor(textColor); updateDate(); @@ -267,7 +267,7 @@ public class HabitHistoryView extends ScrollableDataView drawAxis(canvas, baseLocation); } - private void drawColumn(Canvas canvas, Rect location, GregorianCalendar date, int column) + private void drawColumn(Canvas canvas, RectF location, GregorianCalendar date, int column) { drawColumnHeader(canvas, location, date); location.offset(0, columnWidth); @@ -285,7 +285,7 @@ public class HabitHistoryView extends ScrollableDataView } } - private void drawSquare(Canvas canvas, Rect location, GregorianCalendar date, + private void drawSquare(Canvas canvas, RectF location, GregorianCalendar date, int checkmarkOffset) { if (checkmarkOffset >= checkmarks.length) pSquareBg.setColor(colors[0]); @@ -296,7 +296,7 @@ public class HabitHistoryView extends ScrollableDataView canvas.drawText(text, location.centerX(), location.centerY() + squareTextOffset, pSquareFg); } - private void drawAxis(Canvas canvas, Rect location) + private void drawAxis(Canvas canvas, RectF location) { for (int i = 0; i < 7; i++) { @@ -306,47 +306,26 @@ public class HabitHistoryView extends ScrollableDataView } } - private boolean justSkippedColumn = false; + private float headerOverflow = 0; - private void drawColumnHeader(Canvas canvas, Rect location, GregorianCalendar date) + private void drawColumnHeader(Canvas canvas, RectF location, GregorianCalendar date) { - GregorianCalendar forwardDate = (GregorianCalendar) date.clone(); - forwardDate.add(Calendar.DAY_OF_YEAR, 6); - - String month = dfMonth.format(forwardDate.getTime()); - String year = dfYear.format(forwardDate.getTime()); + String month = dfMonth.format(date.getTime()); + String year = dfYear.format(date.getTime()); + String text = null; if (!month.equals(previousMonth)) - { - int offset = 0; - if (justPrintedYear) - { - offset += columnWidth; - justSkippedColumn = true; - } - - canvas.drawText(month, location.left + offset, location.bottom - headerTextOffset, - pTextHeader); + text = previousMonth = month; + else if(!year.equals(previousYear)) + text = previousYear = year; - previousMonth = month; - justPrintedYear = false; - } - else if (!year.equals(previousYear)) - { - if(!justSkippedColumn) - { - canvas.drawText(year, location.left, location.bottom - headerTextOffset, pTextHeader); - previousYear = year; - justPrintedYear = true; - } - - justSkippedColumn = false; - } - else + if(text != null) { - justSkippedColumn = false; - justPrintedYear = false; + canvas.drawText(text, location.left + headerOverflow, location.bottom - headerTextOffset, pTextHeader); + headerOverflow += pTextHeader.measureText(text) + columnWidth * 0.2f; } + + headerOverflow = Math.max(0, headerOverflow - columnWidth); } public void setIsBackgroundTransparent(boolean isBackgroundTransparent) @@ -355,11 +334,19 @@ public class HabitHistoryView extends ScrollableDataView createColors(); } + @Override + public void onLongPress(MotionEvent e) + { + onSingleTapUp(e); + } + @Override public boolean onSingleTapUp(MotionEvent e) { if(!isEditable) return false; + performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + int pointerId = e.getPointerId(0); float x = e.getX(pointerId); float y = e.getY(pointerId); @@ -367,22 +354,9 @@ public class HabitHistoryView extends ScrollableDataView final Long timestamp = positionToTimestamp(x, y); if(timestamp == null) return false; - new AsyncTask() - { - @Override - protected Void doInBackground(Void... params) - { - habit.repetitions.toggle(timestamp); - return null; - } - - @Override - protected void onPostExecute(Void aVoid) - { - refreshData(); - invalidate(); - } - }.execute(); + ToggleRepetitionTask task = new ToggleRepetitionTask(habit, timestamp); + task.setListener(this); + task.execute(); return true; } @@ -409,4 +383,24 @@ public class HabitHistoryView extends ScrollableDataView { this.isEditable = isEditable; } + + @Override + public void onToggleRepetitionFinished() + { + new BaseTask() + { + @Override + protected void doInBackground() + { + refreshData(); + } + + @Override + protected void onPostExecute(Void aVoid) + { + invalidate(); + super.onPostExecute(null); + } + }.execute(); + } } diff --git a/app/src/main/java/org/isoron/uhabits/views/HabitScoreView.java b/app/src/main/java/org/isoron/uhabits/views/HabitScoreView.java index 02f596e0b..4e1ae4c7c 100644 --- a/app/src/main/java/org/isoron/uhabits/views/HabitScoreView.java +++ b/app/src/main/java/org/isoron/uhabits/views/HabitScoreView.java @@ -26,20 +26,22 @@ import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.PorterDuffXfermode; import android.graphics.RectF; +import android.support.annotation.Nullable; import android.util.AttributeSet; -import org.isoron.helpers.ColorHelper; -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.helpers.DateHelper; import org.isoron.uhabits.models.Habit; import org.isoron.uhabits.models.Score; import java.text.SimpleDateFormat; -import java.util.Locale; +import java.util.Calendar; +import java.util.GregorianCalendar; import java.util.Random; -public class HabitScoreView extends ScrollableDataView +public class HabitScoreView extends ScrollableDataView implements HabitDataView { - public static final int BUCKET_SIZE = 7; public static final PorterDuffXfermode XFERMODE_CLEAR = new PorterDuffXfermode(PorterDuff.Mode.CLEAR); public static final PorterDuffXfermode XFERMODE_SRC = @@ -48,30 +50,41 @@ public class HabitScoreView extends ScrollableDataView private Paint pGrid; private float em; private Habit habit; + private SimpleDateFormat dfMonth; private SimpleDateFormat dfDay; + private SimpleDateFormat dfYear; private Paint pText, pGraph; private RectF rect, prevRect; private int baseSize; private int paddingTop; - private int columnWidth; + private float columnWidth; private int columnHeight; private int nColumns; private int textColor; private int dimmedTextColor; - private int[] colors; + + @Nullable private int[] scores; + private int primaryColor; private boolean isBackgroundTransparent; + private int bucketSize = 7; + private int footerHeight; + + public HabitScoreView(Context context) + { + super(context); + init(); + } public HabitScoreView(Context context, AttributeSet attrs) { super(context, attrs); this.primaryColor = ColorHelper.palette[7]; - this.scores = new int[0]; init(); } @@ -79,18 +92,16 @@ public class HabitScoreView extends ScrollableDataView { this.habit = habit; createColors(); - refreshData(); - postInvalidate(); } private void init() { - refreshData(); createPaints(); createColors(); - dfMonth = new SimpleDateFormat("MMM", Locale.getDefault()); - dfDay = new SimpleDateFormat("d", Locale.getDefault()); + dfYear = DateHelper.getDateFormat("yyyy"); + dfMonth = DateHelper.getDateFormat("MMM"); + dfDay = DateHelper.getDateFormat("d"); rect = new RectF(); prevRect = new RectF(); @@ -114,13 +125,6 @@ public class HabitScoreView extends ScrollableDataView textColor = Color.argb(64, 0, 0, 0); dimmedTextColor = Color.argb(16, 0, 0, 0); } - - colors = new int[4]; - - colors[0] = Color.rgb(230, 230, 230); - colors[3] = primaryColor; - colors[1] = ColorHelper.mixColors(colors[0], colors[3], 0.66f); - colors[2] = ColorHelper.mixColors(colors[0], colors[3], 0.33f); } protected void createPaints() @@ -149,19 +153,26 @@ public class HabitScoreView extends ScrollableDataView { if(height < 9) height = 200; - baseSize = height / 9; + int maxTextSize = getResources().getDimensionPixelSize(R.dimen.regularTextSize); + pText.setTextSize(Math.min(height * 0.047f, maxTextSize)); + em = pText.getFontSpacing(); + + footerHeight = (int)(3 * em); + paddingTop = (int) (em); + + baseSize = (height - footerHeight - paddingTop) / 8; setScrollerBucketSize(baseSize); columnWidth = baseSize; + columnWidth = Math.max(columnWidth, getMaxDayWidth() * 1.5f); + columnWidth = Math.max(columnWidth, getMaxMonthWidth() * 1.2f); + columnHeight = 8 * baseSize; - nColumns = width / baseSize; - paddingTop = (int) (baseSize * 0.15f); + nColumns = (int) (width / columnWidth); - pText.setTextSize(baseSize * 0.5f); pGraph.setTextSize(baseSize * 0.5f); pGraph.setStrokeWidth(baseSize * 0.1f); pGrid.setStrokeWidth(baseSize * 0.05f); - em = pText.getFontSpacing(); } public void refreshData() @@ -171,23 +182,28 @@ public class HabitScoreView extends ScrollableDataView else { if (habit == null) return; - scores = habit.scores.getAllValues(BUCKET_SIZE * DateHelper.millisecondsInOneDay); + scores = habit.scores.getAllValues(bucketSize); } - invalidate(); + postInvalidate(); + } + + public void setBucketSize(int bucketSize) + { + this.bucketSize = bucketSize; } private void generateRandomData() { Random random = new Random(); scores = new int[100]; - scores[0] = Score.MAX_SCORE / 2; + scores[0] = Score.MAX_VALUE / 2; for(int i = 1; i < 100; i++) { - int step = Score.MAX_SCORE / 10; + int step = Score.MAX_VALUE / 10; scores[i] = scores[i - 1] + random.nextInt(step * 2) - step; - scores[i] = Math.max(0, Math.min(Score.MAX_SCORE, scores[i])); + scores[i] = Math.max(0, Math.min(Score.MAX_VALUE, scores[i])); } } @@ -195,37 +211,34 @@ public class HabitScoreView extends ScrollableDataView protected void onDraw(Canvas canvas) { super.onDraw(canvas); - - float lineHeight = pText.getFontSpacing(); + if (habit == null || scores == null) return; rect.set(0, 0, nColumns * columnWidth, columnHeight); rect.offset(0, paddingTop); drawGrid(canvas, rect); - String previousMonth = ""; - - pText.setTextAlign(Paint.Align.CENTER); pText.setColor(textColor); pGraph.setColor(primaryColor); prevRect.setEmpty(); + previousMonthText = ""; + previousYearText = ""; + skipYear = 0; + long currentDate = DateHelper.getStartOfToday(); for(int k = 0; k < nColumns + getDataOffset() - 1; k++) - currentDate -= 7 * DateHelper.millisecondsInOneDay; + currentDate -= bucketSize * DateHelper.millisecondsInOneDay; for (int k = 0; k < nColumns; k++) { - String month = dfMonth.format(currentDate); - String day = dfDay.format(currentDate); - int score = 0; int offset = nColumns - k - 1 + getDataOffset(); if(offset < scores.length) score = scores[offset]; - double sRelative = ((double) score) / Score.MAX_SCORE; - int height = (int) (columnHeight * sRelative); + double relativeScore = ((double) score) / Score.MAX_VALUE; + int height = (int) (columnHeight * relativeScore); rect.set(0, 0, baseSize, baseSize); rect.offset(k * columnWidth, paddingTop + columnHeight - height - columnWidth / 2); @@ -239,20 +252,69 @@ public class HabitScoreView extends ScrollableDataView if (k == nColumns - 1) drawMarker(canvas, rect); prevRect.set(rect); - rect.set(0, 0, columnWidth, columnHeight); rect.offset(k * columnWidth, paddingTop); - if (!month.equals(previousMonth)) - canvas.drawText(month, rect.centerX(), rect.bottom + lineHeight * 1.2f, pText); + drawFooter(canvas, rect, currentDate); + + currentDate += bucketSize * DateHelper.millisecondsInOneDay; + } + } + + private int skipYear = 0; + private String previousYearText; + private String previousMonthText; + + private void drawFooter(Canvas canvas, RectF rect, long currentDate) + { + String yearText = dfYear.format(currentDate); + String monthText = dfMonth.format(currentDate); + String dayText = dfDay.format(currentDate); + + GregorianCalendar calendar = DateHelper.getCalendar(currentDate); + + 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 - canvas.drawText(day, rect.centerX(), rect.bottom + lineHeight * 1.2f, pText); + { + text = dayText; + } - previousMonth = month; - currentDate += 7 * DateHelper.millisecondsInOneDay; + 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; @@ -282,15 +344,15 @@ public class HabitScoreView extends ScrollableDataView private void drawMarker(Canvas canvas, RectF rect) { - rect.inset(columnWidth * 0.15f, columnWidth * 0.15f); + rect.inset(baseSize * 0.15f, baseSize * 0.15f); setModeOrColor(pGraph, XFERMODE_CLEAR, Color.WHITE); canvas.drawOval(rect, pGraph); - rect.inset(columnWidth * 0.1f, columnWidth * 0.1f); + rect.inset(baseSize * 0.1f, baseSize * 0.1f); setModeOrColor(pGraph, XFERMODE_SRC, primaryColor); canvas.drawOval(rect, pGraph); - rect.inset(columnWidth * 0.1f, columnWidth * 0.1f); + rect.inset(baseSize * 0.1f, baseSize * 0.1f); setModeOrColor(pGraph, XFERMODE_CLEAR, Color.WHITE); canvas.drawOval(rect, pGraph); @@ -311,4 +373,34 @@ public class HabitScoreView extends ScrollableDataView else p.setColor(color); } + + private float getMaxMonthWidth() + { + float maxMonthWidth = 0; + GregorianCalendar day = DateHelper.getStartOfTodayCalendar(); + + 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 float getMaxDayWidth() + { + float maxDayWidth = 0; + GregorianCalendar day = DateHelper.getStartOfTodayCalendar(); + + 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; + } } diff --git a/app/src/main/java/org/isoron/uhabits/views/HabitStreakView.java b/app/src/main/java/org/isoron/uhabits/views/HabitStreakView.java index 1fc61cc0c..37d2e16c9 100644 --- a/app/src/main/java/org/isoron/uhabits/views/HabitStreakView.java +++ b/app/src/main/java/org/isoron/uhabits/views/HabitStreakView.java @@ -23,69 +23,76 @@ import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; -import android.graphics.Rect; +import android.graphics.RectF; import android.util.AttributeSet; +import android.view.View; -import org.isoron.helpers.ColorHelper; -import org.isoron.helpers.DateHelper; +import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.ColorHelper; import org.isoron.uhabits.models.Habit; import org.isoron.uhabits.models.Streak; -import java.text.SimpleDateFormat; +import java.text.DateFormat; +import java.util.Collections; +import java.util.Date; import java.util.List; -import java.util.Locale; -import java.util.Random; +import java.util.TimeZone; -public class HabitStreakView extends ScrollableDataView +public class HabitStreakView extends View implements HabitDataView { private Habit habit; - private Paint pText, pBar; + private Paint paint; - private long[] startTimes; - private long[] endTimes; - private long[] lengths; + private long minLength; + private long maxLength; - private int columnWidth; - private int columnHeight; - private int headerHeight; - private int nColumns; - - private long maxStreakLength; private int[] colors; - private SimpleDateFormat dfMonth; - private Rect rect; + private RectF rect; private int baseSize; private int primaryColor; + private List streaks; private boolean isBackgroundTransparent; private int textColor; - private Paint pBarText; + private DateFormat dateFormat; + private int width; + private float em; + private float maxLabelWidth; + private float textMargin; + private boolean shouldShowLabels; + private int maxStreakCount; + + public HabitStreakView(Context context) + { + super(context); + init(); + } public HabitStreakView(Context context, AttributeSet attrs) { super(context, attrs); this.primaryColor = ColorHelper.palette[7]; - startTimes = endTimes = lengths = new long[0]; init(); } public void setHabit(Habit habit) { this.habit = habit; - createColors(); - refreshData(); - postInvalidate(); } private void init() { - refreshData(); createPaints(); createColors(); - dfMonth = new SimpleDateFormat("MMM", Locale.getDefault()); - rect = new Rect(); + streaks = Collections.emptyList(); + + dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM); + dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); + rect = new RectF(); + maxStreakCount = 10; + baseSize = getResources().getDimensionPixelSize(R.dimen.baseSize); } @Override @@ -99,16 +106,17 @@ public class HabitStreakView extends ScrollableDataView @Override protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { - baseSize = height / 10; - setScrollerBucketSize(baseSize); + maxStreakCount = height / baseSize; + this.width = width; - columnWidth = baseSize; - columnHeight = 8 * baseSize; - headerHeight = baseSize; - nColumns = width / baseSize - 1; + int maxTextSize = getResources().getDimensionPixelSize(R.dimen.regularTextSize); + float regularTextSize = Math.min(baseSize * 0.56f, maxTextSize); - pText.setTextSize(baseSize * 0.5f); - pBar.setTextSize(baseSize * 0.5f); + paint.setTextSize(regularTextSize); + em = paint.getFontSpacing(); + textMargin = 0.5f * em; + + updateMaxMin(); } private void createColors() @@ -134,7 +142,6 @@ public class HabitStreakView extends ScrollableDataView colors[1] = Color.argb(170, red, green, blue); colors[0] = Color.argb(128, red, green, blue); textColor = Color.rgb(255, 255, 255); - pBarText = pText; } else { @@ -144,114 +151,110 @@ public class HabitStreakView extends ScrollableDataView colors[1] = Color.argb(96, red, green, blue); colors[0] = Color.argb(32, 0, 0, 0); textColor = Color.argb(64, 0, 0, 0); - pBarText = pBar; } } protected void createPaints() { - pText = new Paint(); - pText.setTextAlign(Paint.Align.CENTER); - pText.setAntiAlias(true); - - pBar = new Paint(); - pBar.setTextAlign(Paint.Align.CENTER); - pBar.setAntiAlias(true); + paint = new Paint(); + paint.setTextAlign(Paint.Align.CENTER); + paint.setAntiAlias(true); } public void refreshData() { - if(isInEditMode()) - generateRandomData(); - else - { - if(habit == null) return; - - List streaks = habit.streaks.getAll(); - int size = streaks.size(); + if(habit == null) return; + streaks = habit.streaks.getAll(maxStreakCount); + updateMaxMin(); + postInvalidate(); + } - startTimes = new long[size]; - endTimes = new long[size]; - lengths = new long[size]; + @Override + protected void onDraw(Canvas canvas) + { + super.onDraw(canvas); + if(streaks.size() == 0) return; - int k = 0; - for (Streak s : streaks) - { - startTimes[k] = s.start; - endTimes[k] = s.end; - lengths[k] = s.length; - k++; + rect.set(0, 0, width, baseSize); - maxStreakLength = Math.max(maxStreakLength, s.length); - } + for(Streak s : streaks) + { + drawRow(canvas, s, rect); + rect.offset(0, baseSize); } - - invalidate(); } - private void generateRandomData() + private void updateMaxMin() { - int size = 30; - - startTimes = new long[size]; - endTimes = new long[size]; - lengths = new long[size]; + maxLength = 0; + minLength = Long.MAX_VALUE; + shouldShowLabels = true; - Random random = new Random(); - Long date = DateHelper.getStartOfToday(); - - for(int i = 0; i < size; i++) + for (Streak s : streaks) { - int l = (int) Math.pow(2, random.nextFloat() * 5 + 1); + maxLength = Math.max(maxLength, s.length); + minLength = Math.min(minLength, s.length); - endTimes[i] = date; - date -= l * DateHelper.millisecondsInOneDay; - lengths[i] = (long) l; - startTimes[i] = date; + float lw1 = paint.measureText(dateFormat.format(new Date(s.start))); + float lw2 = paint.measureText(dateFormat.format(new Date(s.end))); + maxLabelWidth = Math.max(maxLabelWidth, Math.max(lw1, lw2)); + } - maxStreakLength = Math.max(maxStreakLength, l); + if(width - 2 * maxLabelWidth < width * 0.25f) + { + maxLabelWidth = 0; + shouldShowLabels = false; } } - @Override - protected void onDraw(Canvas canvas) + private void drawRow(Canvas canvas, Streak streak, RectF rect) { - super.onDraw(canvas); + if(maxLength == 0) return; - float lineHeight = pText.getFontSpacing(); - float barHeaderOffset = lineHeight * 0.4f; + float percentage = (float) streak.length / maxLength; + float availableWidth = width - 2 * maxLabelWidth; + if(shouldShowLabels) availableWidth -= 2 * textMargin; - int nStreaks = startTimes.length; - int start = nStreaks - nColumns - getDataOffset(); + float barWidth = percentage * availableWidth; + float minBarWidth = paint.measureText(streak.length.toString()); + barWidth = Math.max(barWidth, minBarWidth); - pText.setColor(textColor); + float gap = (width - barWidth) / 2; + float paddingTopBottom = baseSize * 0.05f; - String previousMonth = ""; - - for (int offset = 0; offset < nColumns && start + offset < nStreaks; offset++) - { - if(start + offset < 0) continue; - String month = dfMonth.format(startTimes[start + offset]); + paint.setColor(percentageToColor(percentage)); - long l = lengths[offset + start]; - double lRelative = ((double) l) / maxStreakLength; + canvas.drawRect(rect.left + gap, rect.top + paddingTopBottom, rect.right - gap, + rect.bottom - paddingTopBottom, paint); - pBar.setColor(colors[(int) Math.floor(lRelative * 3)]); + float yOffset = rect.centerY() + 0.3f * em; - int height = (int) (columnHeight * lRelative); - rect.set(0, 0, columnWidth - 2, height); - rect.offset(offset * columnWidth, headerHeight + columnHeight - height); + paint.setColor(Color.WHITE); + paint.setTextAlign(Paint.Align.CENTER); + canvas.drawText(streak.length.toString(), rect.centerX(), yOffset, paint); - canvas.drawRect(rect, pBar); - canvas.drawText(Long.toString(l), rect.centerX(), rect.top - barHeaderOffset, pBarText); + if(shouldShowLabels) + { + String startLabel = dateFormat.format(new Date(streak.start)); + String endLabel = dateFormat.format(new Date(streak.end)); - if (!month.equals(previousMonth)) - canvas.drawText(month, rect.centerX(), rect.bottom + lineHeight * 1.2f, pText); + paint.setColor(textColor); + paint.setTextAlign(Paint.Align.RIGHT); + canvas.drawText(startLabel, gap - textMargin, yOffset, paint); - previousMonth = month; + paint.setTextAlign(Paint.Align.LEFT); + canvas.drawText(endLabel, width - gap + textMargin, yOffset, paint); } } + 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]; + } + public void setIsBackgroundTransparent(boolean isBackgroundTransparent) { this.isBackgroundTransparent = isBackgroundTransparent; diff --git a/app/src/main/java/org/isoron/uhabits/views/NumberView.java b/app/src/main/java/org/isoron/uhabits/views/NumberView.java new file mode 100644 index 000000000..2d5ab4402 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/views/NumberView.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.views; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RectF; +import android.text.Layout; +import android.text.StaticLayout; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.view.View; + +import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.ColorHelper; +import org.isoron.uhabits.helpers.UIHelper; + +public class NumberView extends View +{ + private int color; + private int number; + private float labelMarginTop; + private TextPaint pText; + private String label; + private RectF rect; + private StaticLayout labelLayout; + + private int width; + private int height; + + private float textSize; + private float labelTextSize; + private float numberTextSize; + private StaticLayout numberLayout; + + public NumberView(Context context) + { + super(context); + this.textSize = getResources().getDimension(R.dimen.regularTextSize); + init(); + } + + public NumberView(Context context, AttributeSet attrs) + { + super(context, attrs); + + this.textSize = getResources().getDimension(R.dimen.regularTextSize); + + this.label = UIHelper.getAttribute(context, attrs, "label", "Number"); + this.number = UIHelper.getIntAttribute(context, attrs, "number", 0); + this.textSize = UIHelper.getFloatAttribute(context, attrs, "textSize", + getResources().getDimension(R.dimen.regularTextSize)); + + this.color = ColorHelper.palette[7]; + init(); + } + + public void setColor(int color) + { + this.color = color; + pText.setColor(color); + postInvalidate(); + } + + public void setLabel(String label) + { + this.label = label; + requestLayout(); + postInvalidate(); + } + + public void setNumber(int number) + { + this.number = number; + postInvalidate(); + } + + public void setTextSize(float textSize) + { + this.textSize = textSize; + requestLayout(); + postInvalidate(); + } + + private void init() + { + pText = new TextPaint(); + pText.setAntiAlias(true); + pText.setTextAlign(Paint.Align.CENTER); + + rect = new RectF(); + } + + @Override + @SuppressLint("DrawAllocation") + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) + { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + width = MeasureSpec.getSize(widthMeasureSpec); + height = MeasureSpec.getSize(heightMeasureSpec); + + labelTextSize = textSize; + labelMarginTop = textSize * 0.35f; + numberTextSize = textSize * 2.85f; + + pText.setTextSize(numberTextSize); + numberLayout = new StaticLayout(Integer.toString(number), pText, width, + Layout.Alignment.ALIGN_NORMAL, 1.0f, 0f, false); + + int numberWidth = numberLayout.getWidth(); + int numberHeight = numberLayout.getHeight(); + + pText.setTextSize(labelTextSize); + labelLayout = new StaticLayout(label, pText, width, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0f, + false); + int labelWidth = labelLayout.getWidth(); + int labelHeight = labelLayout.getHeight(); + + width = Math.max(numberWidth, labelWidth); + height = (int) (numberHeight + labelHeight + labelMarginTop); + + setMeasuredDimension(width, height); + } + + @Override + protected void onDraw(Canvas canvas) + { + super.onDraw(canvas); + rect.set(0, 0, width, height); + + canvas.save(); + canvas.translate(rect.centerX(), 0); + pText.setColor(color); + pText.setTextSize(numberTextSize); + numberLayout.draw(canvas); + canvas.restore(); + + canvas.save(); + pText.setColor(Color.GRAY); + pText.setTextSize(labelTextSize); + canvas.translate(rect.centerX(), numberLayout.getHeight() + labelMarginTop); + labelLayout.draw(canvas); + canvas.restore(); + } +} diff --git a/app/src/main/java/org/isoron/uhabits/views/RepetitionCountView.java b/app/src/main/java/org/isoron/uhabits/views/RepetitionCountView.java new file mode 100644 index 000000000..e0f80aa90 --- /dev/null +++ b/app/src/main/java/org/isoron/uhabits/views/RepetitionCountView.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2016 Álinson Santos Xavier + * + * 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 . + */ + +package org.isoron.uhabits.views; + +import android.content.Context; +import android.util.AttributeSet; + +import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.DateHelper; +import org.isoron.uhabits.helpers.UIHelper; +import org.isoron.uhabits.models.Habit; + +import java.util.Calendar; +import java.util.GregorianCalendar; + +public class RepetitionCountView extends NumberView implements HabitDataView +{ + private int interval; + private Habit habit; + + public RepetitionCountView(Context context, AttributeSet attrs) + { + super(context, attrs); + this.interval = UIHelper.getIntAttribute(context, attrs, "interval", 7); + int labelValue = UIHelper.getIntAttribute(context, attrs, "labelValue", 7); + String labelFormat = UIHelper.getAttribute(context, attrs, "labelFormat", + getResources().getString(R.string.last_x_days)); + + setLabel(String.format(labelFormat, labelValue)); + } + + @Override + public void refreshData() + { + if(isInEditMode()) + { + setNumber(interval); + return; + } + + long to = DateHelper.getStartOfToday(); + long from; + + if(interval == 0) + { + from = 0; + } + else + { + GregorianCalendar fromCalendar = DateHelper.getStartOfTodayCalendar(); + fromCalendar.add(Calendar.DAY_OF_YEAR, -interval + 1); + from = fromCalendar.getTimeInMillis(); + } + + if(habit != null) + setNumber(habit.repetitions.count(from, to)); + + postInvalidate(); + } + + @Override + public void setHabit(Habit habit) + { + this.habit = habit; + setColor(habit.color); + } +} diff --git a/app/src/main/java/org/isoron/uhabits/views/RingView.java b/app/src/main/java/org/isoron/uhabits/views/RingView.java index 9e6bb7ff6..0d63ff958 100644 --- a/app/src/main/java/org/isoron/uhabits/views/RingView.java +++ b/app/src/main/java/org/isoron/uhabits/views/RingView.java @@ -19,6 +19,7 @@ package org.isoron.uhabits.views; +import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; @@ -30,14 +31,11 @@ import android.text.TextPaint; import android.util.AttributeSet; import android.view.View; -import org.isoron.helpers.ColorHelper; -import org.isoron.helpers.DialogHelper; import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.UIHelper; public class RingView extends View { - - private int size; private int color; private float percentage; private float labelMarginTop; @@ -46,24 +44,46 @@ public class RingView extends View private RectF rect; private StaticLayout labelLayout; + private int width; + private int height; + private float diameter; + + private float maxDiameter; + private float textSize; + private int fadedTextColor; + + public RingView(Context context) + { + super(context); + init(); + } + public RingView(Context context, AttributeSet attrs) { super(context, attrs); - this.size = (int) context.getResources().getDimension(R.dimen.small_square_size) * 4; - this.label = DialogHelper.getAttribute(context, attrs, "label"); - this.color = ColorHelper.palette[7]; - this.percentage = 0.75f; + this.label = UIHelper.getAttribute(context, attrs, "label", "Label"); + this.maxDiameter = UIHelper.getFloatAttribute(context, attrs, "maxDiameter", 100); + this.maxDiameter = UIHelper.dpToPixels(context, maxDiameter); init(); } public void setColor(int color) { this.color = color; - pRing.setColor(color); postInvalidate(); } + public void setMaxDiameter(float maxDiameter) + { + this.maxDiameter = maxDiameter; + } + + public void setLabel(String label) + { + this.label = label; + } + public void setPercentage(float percentage) { this.percentage = percentage; @@ -77,21 +97,30 @@ public class RingView extends View pRing.setColor(color); pRing.setTextAlign(Paint.Align.CENTER); - pRing.setTextSize(size * 0.15f); - labelMarginTop = size * 0.10f; - labelLayout = new StaticLayout(label, pRing, size, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0f, - false); + fadedTextColor = getResources().getColor(R.color.fadedTextColor); + textSize = getResources().getDimension(R.dimen.smallTextSize); rect = new RectF(); } @Override + @SuppressLint("DrawAllocation") protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); - int width = Math.max(size, labelLayout.getWidth()); - int height = (int) (size + labelLayout.getHeight() + labelMarginTop); + width = MeasureSpec.getSize(widthMeasureSpec); + height = MeasureSpec.getSize(heightMeasureSpec); + + diameter = Math.min(maxDiameter, width); + + pRing.setTextSize(textSize); + labelMarginTop = textSize * 0.80f; + labelLayout = new StaticLayout(label, pRing, width, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0f, + false); + + width = Math.max(width, labelLayout.getWidth()); + height = (int) (diameter + labelLayout.getHeight() + labelMarginTop); setMeasuredDimension(width, height); } @@ -100,27 +129,27 @@ public class RingView extends View protected void onDraw(Canvas canvas) { super.onDraw(canvas); - float thickness = size * 0.15f; + float thickness = diameter * 0.15f; pRing.setColor(color); - rect.set(0, 0, size, size); + rect.set(0, 0, diameter, diameter); + rect.offset((width - diameter) / 2, 0); canvas.drawArc(rect, -90, 360 * percentage, true, pRing); - pRing.setColor(Color.rgb(230, 230, 230)); + pRing.setColor(Color.argb(255, 230, 230, 230)); canvas.drawArc(rect, 360 * percentage - 90 + 2, 360 * (1 - percentage) - 4, true, pRing); pRing.setColor(Color.WHITE); rect.inset(thickness, thickness); canvas.drawArc(rect, -90, 360, true, pRing); - pRing.setColor(Color.GRAY); - pRing.setTextSize(size * 0.2f); + pRing.setColor(fadedTextColor); + pRing.setTextSize(textSize); float lineHeight = pRing.getFontSpacing(); canvas.drawText(String.format("%.0f%%", percentage * 100), rect.centerX(), rect.centerY() + lineHeight / 3, pRing); - - pRing.setTextSize(size * 0.15f); - canvas.translate(size / 2, size + labelMarginTop); + pRing.setTextSize(textSize); + canvas.translate(width / 2, diameter + labelMarginTop); labelLayout.draw(canvas); } } diff --git a/app/src/main/java/org/isoron/uhabits/views/ScrollableDataView.java b/app/src/main/java/org/isoron/uhabits/views/ScrollableDataView.java index 8ad2be7c5..fbae274b7 100644 --- a/app/src/main/java/org/isoron/uhabits/views/ScrollableDataView.java +++ b/app/src/main/java/org/isoron/uhabits/views/ScrollableDataView.java @@ -25,6 +25,7 @@ import android.util.AttributeSet; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; +import android.view.ViewParent; import android.widget.Scroller; public abstract class ScrollableDataView extends View implements GestureDetector.OnGestureListener, @@ -89,7 +90,10 @@ public abstract class ScrollableDataView extends View implements GestureDetector return false; if(Math.abs(dx) > Math.abs(dy)) - getParent().requestDisallowInterceptTouchEvent(true); + { + ViewParent parent = getParent(); + if(parent != null) parent.requestDisallowInterceptTouchEvent(true); + } scroller.startScroll(scroller.getCurrX(), scroller.getCurrY(), (int) -dx, (int) dy, 0); scroller.computeScrollOffset(); diff --git a/app/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.java b/app/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.java index 1610c243a..b75493a76 100644 --- a/app/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.java +++ b/app/src/main/java/org/isoron/uhabits/widgets/BaseWidgetProvider.java @@ -34,9 +34,10 @@ import android.view.View; import android.widget.ImageView; import android.widget.RemoteViews; -import org.isoron.helpers.DialogHelper; import org.isoron.uhabits.R; +import org.isoron.uhabits.helpers.UIHelper; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.tasks.BaseTask; import java.io.FileOutputStream; import java.io.IOException; @@ -92,12 +93,12 @@ public abstract class BaseWidgetProvider extends AppWidgetProvider } } - private void updateWidget(Context context, AppWidgetManager manager, int widgetId, Bundle options) + private void updateWidget(Context context, AppWidgetManager manager, + int widgetId, Bundle options) { updateWidgetSize(context, options); Context appContext = context.getApplicationContext(); - RemoteViews remoteViews = new RemoteViews(context.getPackageName(), getLayoutId()); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(appContext); Long habitId = prefs.getLong(getHabitIdKey(widgetId), -1L); @@ -112,24 +113,11 @@ public abstract class BaseWidgetProvider extends AppWidgetProvider return; } - View widgetView = buildCustomView(context, habit); - measureCustomView(context, width, height, widgetView); - - widgetView.setDrawingCacheEnabled(true); - widgetView.buildDrawingCache(true); - Bitmap drawingCache = widgetView.getDrawingCache(); - - remoteViews.setTextViewText(R.id.label, habit.name); - remoteViews.setImageViewBitmap(R.id.imageView, drawingCache); - - //savePreview(context, widgetId, drawingCache); - - PendingIntent onClickIntent = getOnClickPendingIntent(context, habit); - if(onClickIntent != null) remoteViews.setOnClickPendingIntent(R.id.imageView, onClickIntent); - - manager.updateAppWidget(widgetId, remoteViews); + new RenderWidgetTask(widgetId, context, habit, manager).execute(); } + protected abstract void refreshCustomViewData(View widgetView); + private void savePreview(Context context, int widgetId, Bitmap widgetCache) { try @@ -170,13 +158,13 @@ public abstract class BaseWidgetProvider extends AppWidgetProvider if (options != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - maxWidth = (int) DialogHelper.dpToPixels(context, + maxWidth = (int) UIHelper.dpToPixels(context, options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH)); - maxHeight = (int) DialogHelper.dpToPixels(context, + maxHeight = (int) UIHelper.dpToPixels(context, options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT)); - minWidth = (int) DialogHelper.dpToPixels(context, + minWidth = (int) UIHelper.dpToPixels(context, options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)); - minHeight = (int) DialogHelper.dpToPixels(context, + minHeight = (int) UIHelper.dpToPixels(context, options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT)); } @@ -204,4 +192,60 @@ public abstract class BaseWidgetProvider extends AppWidgetProvider customView.measure(specWidth, specHeight); customView.layout(0, 0, customView.getMeasuredWidth(), customView.getMeasuredHeight()); } + + private class RenderWidgetTask extends BaseTask + { + private final int widgetId; + private final Context context; + private final Habit habit; + private final AppWidgetManager manager; + public RemoteViews remoteViews; + public View widgetView; + + public RenderWidgetTask(int widgetId, Context context, Habit habit, + AppWidgetManager manager) + { + this.widgetId = widgetId; + this.context = context; + this.habit = habit; + this.manager = manager; + } + + @Override + protected void onPreExecute() + { + super.onPreExecute(); + + remoteViews = new RemoteViews(context.getPackageName(), getLayoutId()); + widgetView = buildCustomView(context, habit); + measureCustomView(context, width, height, widgetView); + manager.updateAppWidget(widgetId, remoteViews); + } + + @Override + protected void doInBackground() + { + refreshCustomViewData(widgetView); + } + + @Override + protected void onPostExecute(Void aVoid) + { + widgetView.invalidate(); + widgetView.setDrawingCacheEnabled(true); + widgetView.buildDrawingCache(true); + Bitmap drawingCache = widgetView.getDrawingCache(); + remoteViews.setTextViewText(R.id.label, habit.name); + remoteViews.setImageViewBitmap(R.id.imageView, drawingCache); + + //savePreview(context, widgetId, drawingCache); + + PendingIntent onClickIntent = getOnClickPendingIntent(context, habit); + if(onClickIntent != null) remoteViews.setOnClickPendingIntent(R.id.imageView, onClickIntent); + + manager.updateAppWidget(widgetId, remoteViews); + + super.onPostExecute(aVoid); + } + } } diff --git a/app/src/main/java/org/isoron/uhabits/widgets/CheckmarkWidgetProvider.java b/app/src/main/java/org/isoron/uhabits/widgets/CheckmarkWidgetProvider.java index 849fdf52e..bd04e8eb0 100644 --- a/app/src/main/java/org/isoron/uhabits/widgets/CheckmarkWidgetProvider.java +++ b/app/src/main/java/org/isoron/uhabits/widgets/CheckmarkWidgetProvider.java @@ -26,8 +26,9 @@ import org.isoron.uhabits.HabitBroadcastReceiver; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; import org.isoron.uhabits.views.CheckmarkView; +import org.isoron.uhabits.views.HabitDataView; -public class CheckmarkWidgetProvider extends BaseWidgetProvider +public class CheckmarkWidgetProvider extends BaseWidgetProvider { @Override protected View buildCustomView(Context context, Habit habit) @@ -37,6 +38,12 @@ public class CheckmarkWidgetProvider extends BaseWidgetProvider return view; } + @Override + protected void refreshCustomViewData(View view) + { + ((HabitDataView) view).refreshData(); + } + @Override protected PendingIntent getOnClickPendingIntent(Context context, Habit habit) { @@ -60,4 +67,6 @@ public class CheckmarkWidgetProvider extends BaseWidgetProvider { return R.layout.widget_checkmark; } + + } diff --git a/app/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.java b/app/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.java index 8ea2248df..2cac07c06 100644 --- a/app/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.java +++ b/app/src/main/java/org/isoron/uhabits/widgets/FrequencyWidgetProvider.java @@ -23,8 +23,10 @@ import android.app.PendingIntent; import android.content.Context; import android.view.View; +import org.isoron.uhabits.HabitBroadcastReceiver; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.views.HabitDataView; import org.isoron.uhabits.views.HabitFrequencyView; public class FrequencyWidgetProvider extends BaseWidgetProvider @@ -38,10 +40,16 @@ public class FrequencyWidgetProvider extends BaseWidgetProvider return view; } + @Override + protected void refreshCustomViewData(View view) + { + ((HabitDataView) view).refreshData(); + } + @Override protected PendingIntent getOnClickPendingIntent(Context context, Habit habit) { - return null; + return HabitBroadcastReceiver.buildViewHabitIntent(context, habit); } @Override diff --git a/app/src/main/java/org/isoron/uhabits/widgets/HistoryWidgetProvider.java b/app/src/main/java/org/isoron/uhabits/widgets/HistoryWidgetProvider.java index 2fd440910..e02cb608c 100644 --- a/app/src/main/java/org/isoron/uhabits/widgets/HistoryWidgetProvider.java +++ b/app/src/main/java/org/isoron/uhabits/widgets/HistoryWidgetProvider.java @@ -22,8 +22,10 @@ import android.app.PendingIntent; import android.content.Context; import android.view.View; +import org.isoron.uhabits.HabitBroadcastReceiver; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.views.HabitDataView; import org.isoron.uhabits.views.HabitHistoryView; public class HistoryWidgetProvider extends BaseWidgetProvider @@ -37,10 +39,16 @@ public class HistoryWidgetProvider extends BaseWidgetProvider return view; } + @Override + protected void refreshCustomViewData(View view) + { + ((HabitDataView) view).refreshData(); + } + @Override protected PendingIntent getOnClickPendingIntent(Context context, Habit habit) { - return null; + return HabitBroadcastReceiver.buildViewHabitIntent(context, habit); } @Override diff --git a/app/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.java b/app/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.java index 5eb9c1097..456491efe 100644 --- a/app/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.java +++ b/app/src/main/java/org/isoron/uhabits/widgets/ScoreWidgetProvider.java @@ -22,8 +22,10 @@ import android.app.PendingIntent; import android.content.Context; import android.view.View; +import org.isoron.uhabits.HabitBroadcastReceiver; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.views.HabitDataView; import org.isoron.uhabits.views.HabitScoreView; public class ScoreWidgetProvider extends BaseWidgetProvider @@ -37,10 +39,16 @@ public class ScoreWidgetProvider extends BaseWidgetProvider return view; } + @Override + protected void refreshCustomViewData(View view) + { + ((HabitDataView) view).refreshData(); + } + @Override protected PendingIntent getOnClickPendingIntent(Context context, Habit habit) { - return null; + return HabitBroadcastReceiver.buildViewHabitIntent(context, habit); } @Override diff --git a/app/src/main/java/org/isoron/uhabits/widgets/StreakWidgetProvider.java b/app/src/main/java/org/isoron/uhabits/widgets/StreakWidgetProvider.java index 40916a8fc..f5d81d4f6 100644 --- a/app/src/main/java/org/isoron/uhabits/widgets/StreakWidgetProvider.java +++ b/app/src/main/java/org/isoron/uhabits/widgets/StreakWidgetProvider.java @@ -22,8 +22,10 @@ import android.app.PendingIntent; import android.content.Context; import android.view.View; +import org.isoron.uhabits.HabitBroadcastReceiver; import org.isoron.uhabits.R; import org.isoron.uhabits.models.Habit; +import org.isoron.uhabits.views.HabitDataView; import org.isoron.uhabits.views.HabitStreakView; public class StreakWidgetProvider extends BaseWidgetProvider @@ -37,10 +39,16 @@ public class StreakWidgetProvider extends BaseWidgetProvider return view; } + @Override + protected void refreshCustomViewData(View view) + { + ((HabitDataView) view).refreshData(); + } + @Override protected PendingIntent getOnClickPendingIntent(Context context, Habit habit) { - return null; + return HabitBroadcastReceiver.buildViewHabitIntent(context, habit); } @Override diff --git a/app/src/main/res/drawable/widget_preview_streaks.png b/app/src/main/res/drawable/widget_preview_streaks.png index 9977ca6f0..b7771c2b8 100644 Binary files a/app/src/main/res/drawable/widget_preview_streaks.png and b/app/src/main/res/drawable/widget_preview_streaks.png differ diff --git a/app/src/main/res/layout/about.xml b/app/src/main/res/layout/about.xml index 726217d01..86a0f9f26 100644 --- a/app/src/main/res/layout/about.xml +++ b/app/src/main/res/layout/about.xml @@ -161,6 +161,14 @@ style="@style/aboutItemStyle" android:text="Ander Raso Vazquez (Español)"/> + + + + diff --git a/app/src/main/res/layout/edit_habit.xml b/app/src/main/res/layout/edit_habit.xml index 2dbbeab8d..493dc1c67 100644 --- a/app/src/main/res/layout/edit_habit.xml +++ b/app/src/main/res/layout/edit_habit.xml @@ -17,12 +17,13 @@ ~ with this program. If not, see . --> - + tools:ignore="MergeRootFrame"> + style="@style/dialogFormInput" + android:hint="@string/name"> - + + android:src="@drawable/ic_action_color_light"/> + style="@style/dialogFormInputMultiline" + android:hint="@string/description_hint"/> + style="@style/dialogFormRow"> - - + android:text="@string/repeat"/> - - - + android:entries="@array/frequencyQuickSelect" + android:visibility="gone"/> - + android:visibility="visible" + android:gravity="fill"> + + + + + + + + + + + android:text="@string/reminder"/> + style="@style/dialogFormSpinner"/> + + + + + + style="@style/dialogFormSpinner"/> @@ -108,20 +136,22 @@ android:layout_height="wrap_content" android:gravity="end" android:paddingEnd="16dp" - android:paddingRight="16dp"> + android:paddingLeft="0dp" + android:paddingRight="16dp" + android:paddingStart="0dp">