Merge pull request #709 from hiqua/dev

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

@ -49,7 +49,7 @@ Further resources:
## Code Style ## Code Style
For Kotlin, we follow [ktlint](https://ktlint.github.io/) style with default settings. This code style is enforced by our automated build pipeline. To make sure that IntelliJ and Android Studio are configured according to ktlint, run `./gradlew ktlintApplyToIdea`. To check that all code is properly formatted, run `./gradlew ktlintCheck`. See more details in [ktlint-gradle](https://github.com/jlleitschuh/ktlint-gradle). For Kotlin, we follow [ktlint](https://ktlint.github.io/) style with default settings. This code style is enforced by our automated build pipeline. To make sure that IntelliJ and Android Studio are configured according to ktlint, run `./gradlew ktlintApplyToIdea`. To check that all code is properly formatted, run `./gradlew ktlintCheck`. You can install a Git pre-commit hook to ensure that the code is properly formatted when you commit using `./gradlew addKtlintFormatGitPreCommitHook`. See more details in [ktlint-gradle](https://github.com/jlleitschuh/ktlint-gradle).
For legacy Java code, we don't have strict guidelines. Please follow a code style similar to the file you are modifying. Note that new classes should be written in Kotlin. Pull requests converting existing Java code to Kotlin are also welcome. For legacy Java code, we don't have strict guidelines. Please follow a code style similar to the file you are modifying. Note that new classes should be written in Kotlin. Pull requests converting existing Java code to Kotlin are also welcome.

@ -125,6 +125,7 @@ dependencies {
testImplementation "junit:junit:4.12" testImplementation "junit:junit:4.12"
testImplementation "org.mockito:mockito-core:2.28.2" testImplementation "org.mockito:mockito-core:2.28.2"
testImplementation "org.mockito:mockito-inline:2.8.9" testImplementation "org.mockito:mockito-inline:2.8.9"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
} }
kapt { kapt {

@ -38,19 +38,13 @@ import org.mockito.Mockito.mock
@Module @Module
class TestModule { class TestModule {
@Provides fun ListHabitsBehavior() = mock(ListHabitsBehavior::class.java) @Provides fun listHabitsBehavior(): ListHabitsBehavior = mock(ListHabitsBehavior::class.java)
} }
@ActivityScope @ActivityScope
@Component( @Component(
modules = arrayOf( modules = [ActivityContextModule::class, HabitsActivityModule::class, ListHabitsModule::class, HabitModule::class, TestModule::class],
ActivityContextModule::class, dependencies = [HabitsApplicationComponent::class]
HabitsActivityModule::class,
ListHabitsModule::class,
HabitModule::class,
TestModule::class
),
dependencies = arrayOf(HabitsApplicationComponent::class)
) )
interface HabitsActivityTestComponent { interface HabitsActivityTestComponent {
fun getCheckmarkPanelViewFactory(): CheckmarkPanelViewFactory fun getCheckmarkPanelViewFactory(): CheckmarkPanelViewFactory

@ -1,56 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance;
import androidx.test.filters.*;
import androidx.test.runner.*;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.isoron.uhabits.*;
import org.junit.*;
import org.junit.runner.*;
import static org.isoron.uhabits.acceptance.steps.CommonSteps.*;
import static org.isoron.uhabits.acceptance.steps.ListHabitsSteps.MenuItem.*;
import static org.isoron.uhabits.acceptance.steps.ListHabitsSteps.*;
@RunWith(AndroidJUnit4.class)
@LargeTest
public class AboutTest extends BaseUserInterfaceTest
{
@Test
public void shouldDisplayAboutScreen() {
launchApp();
clickMenu(ABOUT);
verifyDisplaysText("Loop Habit Tracker");
verifyDisplaysText("Rate this app on Google Play");
verifyDisplaysText("Developers");
verifyDisplaysText("Translators");
}
@Test
public void shouldDisplayAboutScreenFromSettings() {
launchApp();
clickMenu(SETTINGS);
clickText("About");
verifyDisplaysText("Translators");
}
}

@ -0,0 +1,52 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.isoron.uhabits.BaseUserInterfaceTest
import org.isoron.uhabits.acceptance.steps.CommonSteps.launchApp
import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyDisplaysText
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps.clickMenu
import org.isoron.uhabits.acceptance.steps.WidgetSteps.clickText
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class AboutTest : BaseUserInterfaceTest() {
@Test
fun shouldDisplayAboutScreen() {
launchApp()
clickMenu(ListHabitsSteps.MenuItem.ABOUT)
verifyDisplaysText("Loop Habit Tracker")
verifyDisplaysText("Rate this app on Google Play")
verifyDisplaysText("Developers")
verifyDisplaysText("Translators")
}
@Test
fun shouldDisplayAboutScreenFromSettings() {
launchApp()
clickMenu(ListHabitsSteps.MenuItem.SETTINGS)
clickText("About")
verifyDisplaysText("Translators")
}
}

@ -27,6 +27,7 @@ import org.isoron.uhabits.acceptance.steps.CommonSteps.longClickText
import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyDisplaysText import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyDisplaysText
import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyDoesNotDisplayText import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyDoesNotDisplayText
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps import org.isoron.uhabits.acceptance.steps.ListHabitsSteps
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps.clickMenu
import org.isoron.uhabits.acceptance.steps.clearBackupFolder import org.isoron.uhabits.acceptance.steps.clearBackupFolder
import org.isoron.uhabits.acceptance.steps.clearDownloadFolder import org.isoron.uhabits.acceptance.steps.clearDownloadFolder
import org.isoron.uhabits.acceptance.steps.copyBackupToDownloadFolder import org.isoron.uhabits.acceptance.steps.copyBackupToDownloadFolder
@ -45,7 +46,7 @@ class BackupTest : BaseUserInterfaceTest() {
copyBackupToDownloadFolder() copyBackupToDownloadFolder()
longClickText("Wake up early") longClickText("Wake up early")
ListHabitsSteps.clickMenu(ListHabitsSteps.MenuItem.DELETE) clickMenu(ListHabitsSteps.MenuItem.DELETE)
clickText("Yes") clickText("Yes")
verifyDoesNotDisplayText("Wake up early") verifyDoesNotDisplayText("Wake up early")

@ -1,200 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance;
import androidx.test.filters.*;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.isoron.uhabits.*;
import org.junit.*;
import org.junit.runner.*;
import static org.isoron.uhabits.acceptance.steps.CommonSteps.Screen.*;
import static org.isoron.uhabits.acceptance.steps.CommonSteps.*;
import static org.isoron.uhabits.acceptance.steps.EditHabitSteps.*;
import static org.isoron.uhabits.acceptance.steps.ListHabitsSteps.MenuItem.*;
import static org.isoron.uhabits.acceptance.steps.ListHabitsSteps.*;
@RunWith(AndroidJUnit4.class)
@LargeTest
public class HabitsTest extends BaseUserInterfaceTest
{
@Test
public void shouldCreateHabit() throws Exception {
shouldCreateHabit("this is a test description");
}
@Test
public void shouldCreateHabitBlankDescription() throws Exception {
shouldCreateHabit("");
}
private void shouldCreateHabit(String description) throws Exception
{
launchApp();
verifyShowsScreen(LIST_HABITS);
clickMenu(ADD);
verifyShowsScreen(SELECT_HABIT_TYPE);
clickText("Yes or No");
verifyShowsScreen(EDIT_HABIT);
String testName = "Hello world";
typeName(testName);
typeQuestion("Did you say hello to the world today?");
typeDescription(description);
pickFrequency();
pickColor(5);
clickSave();
verifyShowsScreen(LIST_HABITS);
verifyDisplaysText(testName);
}
@Test
public void shouldShowHabitStatistics() throws Exception
{
launchApp();
verifyShowsScreen(LIST_HABITS);
clickText("Track time");
verifyShowsScreen(SHOW_HABIT);
verifyDisplayGraphs();
}
@Test
public void shouldDeleteHabit() throws Exception
{
launchApp();
verifyShowsScreen(LIST_HABITS);
longClickText("Track time");
clickMenu(DELETE);
clickText("Yes");
verifyDoesNotDisplayText("Track time");
}
@Test
public void shouldEditHabit() throws Exception {
shouldEditHabit("this is a test description");
}
@Test
public void shouldEditHabitBlankDescription() throws Exception {
shouldEditHabit("");
}
private void shouldEditHabit(String description) throws Exception
{
launchApp();
verifyShowsScreen(LIST_HABITS);
longClickText("Track time");
clickMenu(EDIT);
verifyShowsScreen(EDIT_HABIT);
typeName("Take a walk");
typeQuestion("Did you take a walk today?");
typeDescription(description);
clickSave();
verifyShowsScreen(LIST_HABITS);
verifyDisplaysTextInSequence("Wake up early", "Take a walk", "Meditate");
verifyDoesNotDisplayText("Track time");
}
@Test
public void shouldEditHabit_fromStatisticsScreen() throws Exception
{
launchApp();
verifyShowsScreen(LIST_HABITS);
clickText("Track time");
verifyShowsScreen(SHOW_HABIT);
clickMenu(EDIT);
verifyShowsScreen(EDIT_HABIT);
typeName("Take a walk");
typeQuestion("Did you take a walk today?");
pickColor(10);
clickSave();
verifyShowsScreen(SHOW_HABIT);
verifyDisplaysText("Take a walk");
pressBack();
verifyShowsScreen(LIST_HABITS);
verifyDisplaysText("Take a walk");
verifyDoesNotDisplayText("Track time");
}
@Test
public void shouldArchiveAndUnarchiveHabits() throws Exception
{
launchApp();
verifyShowsScreen(LIST_HABITS);
longClickText("Track time");
clickMenu(ARCHIVE);
verifyDoesNotDisplayText("Track time");
clickMenu(TOGGLE_ARCHIVED);
verifyDisplaysText("Track time");
longClickText("Track time");
clickMenu(UNARCHIVE);
clickMenu(TOGGLE_ARCHIVED);
verifyDisplaysText("Track time");
}
@Test
public void shouldToggleCheckmarksAndUpdateScore() throws Exception
{
launchApp();
verifyShowsScreen(LIST_HABITS);
longPressCheckmarks("Wake up early", 2);
clickText("Wake up early");
verifyShowsScreen(SHOW_HABIT);
verifyDisplaysText("10%");
}
@Test
public void shouldHideCompleted() throws Exception
{
launchApp();
verifyShowsScreen(LIST_HABITS);
verifyDisplaysText("Track time");
verifyDisplaysText("Wake up early");
clickMenu(TOGGLE_COMPLETED);
verifyDoesNotDisplayText("Track time");
verifyDisplaysText("Wake up early");
longPressCheckmarks("Wake up early", 1);
verifyDoesNotDisplayText("Wake up early");
clickMenu(TOGGLE_COMPLETED);
verifyDisplaysText("Track time");
verifyDisplaysText("Wake up early");
}
}

@ -0,0 +1,193 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.isoron.uhabits.BaseUserInterfaceTest
import org.isoron.uhabits.acceptance.steps.CommonSteps
import org.isoron.uhabits.acceptance.steps.CommonSteps.clickText
import org.isoron.uhabits.acceptance.steps.CommonSteps.launchApp
import org.isoron.uhabits.acceptance.steps.CommonSteps.longClickText
import org.isoron.uhabits.acceptance.steps.CommonSteps.pressBack
import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyDisplayGraphs
import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyDisplaysText
import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyDisplaysTextInSequence
import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyDoesNotDisplayText
import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyShowsScreen
import org.isoron.uhabits.acceptance.steps.EditHabitSteps.clickSave
import org.isoron.uhabits.acceptance.steps.EditHabitSteps.pickColor
import org.isoron.uhabits.acceptance.steps.EditHabitSteps.pickFrequency
import org.isoron.uhabits.acceptance.steps.EditHabitSteps.typeDescription
import org.isoron.uhabits.acceptance.steps.EditHabitSteps.typeName
import org.isoron.uhabits.acceptance.steps.EditHabitSteps.typeQuestion
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps.clickMenu
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps.longPressCheckmarks
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class HabitsTest : BaseUserInterfaceTest() {
@Test
@Throws(Exception::class)
fun shouldCreateHabit() {
shouldCreateHabit("this is a test description")
}
@Test
@Throws(Exception::class)
fun shouldCreateHabitBlankDescription() {
shouldCreateHabit("")
}
@Throws(Exception::class)
private fun shouldCreateHabit(description: String) {
launchApp()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
clickMenu(ListHabitsSteps.MenuItem.ADD)
verifyShowsScreen(CommonSteps.Screen.SELECT_HABIT_TYPE)
clickText("Yes or No")
verifyShowsScreen(CommonSteps.Screen.EDIT_HABIT)
val testName = "Hello world"
typeName(testName)
typeQuestion("Did you say hello to the world today?")
typeDescription(description)
pickFrequency()
pickColor(5)
clickSave()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
verifyDisplaysText(testName)
}
@Test
@Throws(Exception::class)
fun shouldShowHabitStatistics() {
launchApp()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
clickText("Track time")
verifyShowsScreen(CommonSteps.Screen.SHOW_HABIT)
verifyDisplayGraphs()
}
@Test
@Throws(Exception::class)
fun shouldDeleteHabit() {
launchApp()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
longClickText("Track time")
clickMenu(ListHabitsSteps.MenuItem.DELETE)
clickText("Yes")
verifyDoesNotDisplayText("Track time")
}
@Test
@Throws(Exception::class)
fun shouldEditHabit() {
shouldEditHabit("this is a test description")
}
@Test
@Throws(Exception::class)
fun shouldEditHabitBlankDescription() {
shouldEditHabit("")
}
@Throws(Exception::class)
private fun shouldEditHabit(description: String) {
launchApp()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
longClickText("Track time")
clickMenu(ListHabitsSteps.MenuItem.EDIT)
verifyShowsScreen(CommonSteps.Screen.EDIT_HABIT)
typeName("Take a walk")
typeQuestion("Did you take a walk today?")
typeDescription(description)
clickSave()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
verifyDisplaysTextInSequence("Wake up early", "Take a walk", "Meditate")
verifyDoesNotDisplayText("Track time")
}
@Test
@Throws(Exception::class)
fun shouldEditHabit_fromStatisticsScreen() {
launchApp()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
clickText("Track time")
verifyShowsScreen(CommonSteps.Screen.SHOW_HABIT)
clickMenu(ListHabitsSteps.MenuItem.EDIT)
verifyShowsScreen(CommonSteps.Screen.EDIT_HABIT)
typeName("Take a walk")
typeQuestion("Did you take a walk today?")
pickColor(10)
clickSave()
verifyShowsScreen(CommonSteps.Screen.SHOW_HABIT)
verifyDisplaysText("Take a walk")
pressBack()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
verifyDisplaysText("Take a walk")
verifyDoesNotDisplayText("Track time")
}
@Test
@Throws(Exception::class)
fun shouldArchiveAndUnarchiveHabits() {
launchApp()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
longClickText("Track time")
clickMenu(ListHabitsSteps.MenuItem.ARCHIVE)
verifyDoesNotDisplayText("Track time")
clickMenu(ListHabitsSteps.MenuItem.TOGGLE_ARCHIVED)
verifyDisplaysText("Track time")
longClickText("Track time")
clickMenu(ListHabitsSteps.MenuItem.UNARCHIVE)
clickMenu(ListHabitsSteps.MenuItem.TOGGLE_ARCHIVED)
verifyDisplaysText("Track time")
}
@Test
@Throws(Exception::class)
fun shouldToggleCheckmarksAndUpdateScore() {
launchApp()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
longPressCheckmarks("Wake up early", 2)
clickText("Wake up early")
verifyShowsScreen(CommonSteps.Screen.SHOW_HABIT)
verifyDisplaysText("10%")
}
@Test
@Throws(Exception::class)
fun shouldHideCompleted() {
launchApp()
verifyShowsScreen(CommonSteps.Screen.LIST_HABITS)
verifyDisplaysText("Track time")
verifyDisplaysText("Wake up early")
clickMenu(ListHabitsSteps.MenuItem.TOGGLE_COMPLETED)
verifyDoesNotDisplayText("Track time")
verifyDisplaysText("Wake up early")
longPressCheckmarks("Wake up early", 1)
verifyDoesNotDisplayText("Wake up early")
clickMenu(ListHabitsSteps.MenuItem.TOGGLE_COMPLETED)
verifyDisplaysText("Track time")
verifyDisplaysText("Wake up early")
}
}

@ -1,71 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance;
import androidx.test.filters.*;
import androidx.test.runner.*;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.isoron.uhabits.*;
import org.junit.*;
import org.junit.runner.*;
import static org.isoron.uhabits.acceptance.steps.CommonSteps.*;
import static org.isoron.uhabits.acceptance.steps.ListHabitsSteps.MenuItem.*;
import static org.isoron.uhabits.acceptance.steps.ListHabitsSteps.*;
@RunWith(AndroidJUnit4.class)
@LargeTest
public class LinksTest extends BaseUserInterfaceTest
{
@Test
public void shouldLinkToSourceCode() throws Exception
{
launchApp();
clickMenu(ABOUT);
clickText("View source code at GitHub");
verifyOpensWebsite("github.com");
}
@Test
public void shouldLinkToTranslationWebsite() throws Exception
{
launchApp();
clickMenu(ABOUT);
clickText("Help translate this app");
verifyOpensWebsite("translate.loophabits.org");
}
@Test
public void shouldLinkToHelp() throws Exception {
launchApp();
clickMenu(HELP);
verifyOpensWebsite("github.com");
}
@Test
public void shouldLinkToHelpFromSettings() throws Exception {
launchApp();
clickMenu(SETTINGS);
clickText("Help & FAQ");
verifyOpensWebsite("github.com");
}
}

@ -0,0 +1,69 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.isoron.uhabits.BaseUserInterfaceTest
import org.isoron.uhabits.acceptance.steps.CommonSteps.launchApp
import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyOpensWebsite
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps
import org.isoron.uhabits.acceptance.steps.ListHabitsSteps.clickMenu
import org.isoron.uhabits.acceptance.steps.WidgetSteps.clickText
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class LinksTest : BaseUserInterfaceTest() {
@Test
@Throws(Exception::class)
fun shouldLinkToSourceCode() {
launchApp()
clickMenu(ListHabitsSteps.MenuItem.ABOUT)
clickText("View source code at GitHub")
verifyOpensWebsite("github.com")
}
@Test
@Throws(Exception::class)
fun shouldLinkToTranslationWebsite() {
launchApp()
clickMenu(ListHabitsSteps.MenuItem.ABOUT)
clickText("Help translate this app")
verifyOpensWebsite("translate.loophabits.org")
}
@Test
@Throws(Exception::class)
fun shouldLinkToHelp() {
launchApp()
clickMenu(ListHabitsSteps.MenuItem.HELP)
verifyOpensWebsite("github.com")
}
@Test
@Throws(Exception::class)
fun shouldLinkToHelpFromSettings() {
launchApp()
clickMenu(ListHabitsSteps.MenuItem.SETTINGS)
clickText("Help & FAQ")
verifyOpensWebsite("github.com")
}
}

@ -1,55 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance;
import androidx.test.filters.*;
import org.isoron.uhabits.*;
import org.junit.*;
import static org.isoron.uhabits.acceptance.steps.CommonSteps.*;
import static org.isoron.uhabits.acceptance.steps.WidgetSteps.*;
import static org.isoron.uhabits.acceptance.steps.WidgetSteps.clickText;
@LargeTest
public class WidgetTest extends BaseUserInterfaceTest
{
@Test
public void shouldCreateAndToggleCheckmarkWidget() throws Exception
{
dragCheckmarkWidgetToHomeScreen();
Thread.sleep(3000);
clickText("Wake up early");
clickText("Save");
verifyCheckmarkWidgetIsShown();
clickCheckmarkWidget();
launchApp();
clickText("Wake up early");
verifyDisplaysText("5%");
pressHome();
clickCheckmarkWidget();
launchApp();
clickText("Wake up early");
verifyDisplaysText("0%");
}
}

@ -0,0 +1,52 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance
import androidx.test.filters.LargeTest
import org.isoron.uhabits.BaseUserInterfaceTest
import org.isoron.uhabits.acceptance.steps.CommonSteps.launchApp
import org.isoron.uhabits.acceptance.steps.CommonSteps.pressHome
import org.isoron.uhabits.acceptance.steps.CommonSteps.verifyDisplaysText
import org.isoron.uhabits.acceptance.steps.WidgetSteps.clickCheckmarkWidget
import org.isoron.uhabits.acceptance.steps.WidgetSteps.clickText
import org.isoron.uhabits.acceptance.steps.WidgetSteps.dragCheckmarkWidgetToHomeScreen
import org.isoron.uhabits.acceptance.steps.WidgetSteps.verifyCheckmarkWidgetIsShown
import org.junit.Test
@LargeTest
class WidgetTest : BaseUserInterfaceTest() {
@Test
@Throws(Exception::class)
fun shouldCreateAndToggleCheckmarkWidget() {
dragCheckmarkWidgetToHomeScreen()
Thread.sleep(3000)
clickText("Wake up early")
clickText("Save")
verifyCheckmarkWidgetIsShown()
clickCheckmarkWidget()
launchApp()
clickText("Wake up early")
verifyDisplaysText("5%")
pressHome()
clickCheckmarkWidget()
launchApp()
clickText("Wake up early")
verifyDisplaysText("0%")
}
}

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

@ -1,177 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance.steps;
import android.view.*;
import androidx.annotation.*;
import androidx.recyclerview.widget.*;
import androidx.test.espresso.*;
import androidx.test.espresso.contrib.*;
import androidx.test.uiautomator.*;
import org.hamcrest.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.R;
import org.isoron.uhabits.activities.habits.list.*;
import static android.os.Build.VERSION.*;
import static androidx.test.espresso.Espresso.*;
import static androidx.test.espresso.action.ViewActions.*;
import static androidx.test.espresso.assertion.PositionAssertions.*;
import static androidx.test.espresso.assertion.ViewAssertions.*;
import static androidx.test.espresso.matcher.ViewMatchers.*;
import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
public class CommonSteps extends BaseUserInterfaceTest
{
public static void pressBack()
{
device.pressBack();
}
public static void clickText(String text)
{
scrollToText(text);
onView(withText(text)).perform(click());
}
public static void clickText(@StringRes int id)
{
onView(withText(id)).perform(click());
}
public static void launchApp()
{
startActivity(ListHabitsActivity.class);
assertTrue(
device.wait(Until.hasObject(By.pkg("org.isoron.uhabits")), 5000));
device.waitForIdle();
}
public static void longClickText(String text)
{
scrollToText(text);
onView(withText(text)).perform(longClick());
}
public static void pressHome()
{
device.pressHome();
device.waitForIdle();
}
public static void scrollToText(String text)
{
try
{
if (device
.findObject(new UiSelector().className(RecyclerView.class))
.exists())
{
onView(instanceOf(RecyclerView.class)).perform(
RecyclerViewActions.scrollTo(
hasDescendant(withText(text))));
}
else
{
onView(withText(text)).perform(scrollTo());
}
}
catch (PerformException e)
{
//ignored
}
}
public static void verifyDisplayGraphs()
{
verifyDisplaysView("HistoryCard");
verifyDisplaysView("ScoreCard");
}
public static void verifyDisplaysText(String text)
{
scrollToText(text);
onView(withText(text)).check(matches(isEnabled()));
}
public static void verifyDisplaysTextInSequence(String... text)
{
verifyDisplaysText(text[0]);
for(int i = 1; i < text.length; i++) {
verifyDisplaysText(text[i]);
onView(withText(text[i])).check(isCompletelyBelow(withText(text[i-1])));
}
}
private static void verifyDisplaysView(String className)
{
onView(withClassName(endsWith(className))).check(matches(isEnabled()));
}
public static void verifyDoesNotDisplayText(String text)
{
onView(withText(text)).check(doesNotExist());
}
public static void verifyOpensWebsite(String url) throws Exception
{
String browser_pkg = "org.chromium.webview_shell";
if(SDK_INT <= 23) {
browser_pkg = "com.android.browser";
}
assertTrue(device.wait(Until.hasObject(By.pkg(browser_pkg)), 5000));
device.waitForIdle();
assertTrue(device.findObject(new UiSelector().textContains(url)).exists());
}
public enum Screen
{
LIST_HABITS, SHOW_HABIT, EDIT_HABIT, SELECT_HABIT_TYPE
}
public static void verifyShowsScreen(Screen screen)
{
switch(screen)
{
case LIST_HABITS:
onView(withClassName(endsWith("ListHabitsRootView")))
.check(matches(isDisplayed()));
break;
case SHOW_HABIT:
onView(withId(R.id.subtitleCard)).check(matches(isDisplayed()));
break;
case EDIT_HABIT:
onView(withId(R.id.questionInput)).check(matches(isDisplayed()));
break;
case SELECT_HABIT_TYPE:
onView(withText(R.string.yes_or_no_example)).check(matches(isDisplayed()));
break;
default:
throw new IllegalStateException();
}
}
}

@ -0,0 +1,157 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance.steps
import android.os.Build.VERSION
import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso
import androidx.test.espresso.PerformException
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.PositionAssertions
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiSelector
import androidx.test.uiautomator.Until
import junit.framework.Assert.assertTrue
import org.hamcrest.CoreMatchers
import org.isoron.uhabits.BaseUserInterfaceTest
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.habits.list.ListHabitsActivity
object CommonSteps : BaseUserInterfaceTest() {
fun pressBack() {
device.pressBack()
}
fun clickText(text: String?) {
scrollToText(text)
Espresso.onView(ViewMatchers.withText(text)).perform(ViewActions.click())
}
fun clickText(@StringRes id: Int) {
Espresso.onView(ViewMatchers.withText(id)).perform(ViewActions.click())
}
fun launchApp() {
startActivity(ListHabitsActivity::class.java)
assertTrue(
device.wait(Until.hasObject(By.pkg("org.isoron.uhabits")), 5000)
)
device.waitForIdle()
}
fun longClickText(text: String?) {
scrollToText(text)
Espresso.onView(ViewMatchers.withText(text)).perform(ViewActions.longClick())
}
fun pressHome() {
device.pressHome()
device.waitForIdle()
}
fun scrollToText(text: String?) {
try {
if (device
.findObject(UiSelector().className(RecyclerView::class.java))
.exists()
) {
Espresso.onView(CoreMatchers.instanceOf(RecyclerView::class.java)).perform(
RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(
ViewMatchers.hasDescendant(ViewMatchers.withText(text))
)
)
} else {
Espresso.onView(ViewMatchers.withText(text)).perform(ViewActions.scrollTo())
}
} catch (e: PerformException) {
// ignored
}
}
fun verifyDisplayGraphs() {
verifyDisplaysView("HistoryCard")
verifyDisplaysView("ScoreCard")
}
fun verifyDisplaysText(text: String?) {
scrollToText(text)
Espresso.onView(ViewMatchers.withText(text))
.check(ViewAssertions.matches(ViewMatchers.isEnabled()))
}
fun verifyDisplaysTextInSequence(vararg text: String?) {
verifyDisplaysText(text[0])
for (i in 1 until text.size) {
verifyDisplaysText(text[i])
Espresso.onView(ViewMatchers.withText(text[i])).check(
PositionAssertions.isCompletelyBelow(
ViewMatchers.withText(
text[i - 1]
)
)
)
}
}
private fun verifyDisplaysView(className: String) {
Espresso.onView(ViewMatchers.withClassName(CoreMatchers.endsWith(className)))
.check(ViewAssertions.matches(ViewMatchers.isEnabled()))
}
fun verifyDoesNotDisplayText(text: String?) {
Espresso.onView(ViewMatchers.withText(text)).check(ViewAssertions.doesNotExist())
}
@Throws(Exception::class)
fun verifyOpensWebsite(url: String?) {
var browserPkg = "org.chromium.webview_shell"
if (VERSION.SDK_INT <= 23) {
browserPkg = "com.android.browser"
}
assertTrue(device.wait(Until.hasObject(By.pkg(browserPkg)), 5000))
device.waitForIdle()
assertTrue(device.findObject(UiSelector().textContains(url)).exists())
}
fun verifyShowsScreen(screen: Screen?) {
when (screen) {
Screen.LIST_HABITS ->
Espresso.onView(ViewMatchers.withClassName(CoreMatchers.endsWith("ListHabitsRootView")))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
Screen.SHOW_HABIT ->
Espresso.onView(ViewMatchers.withId(R.id.subtitleCard))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
Screen.EDIT_HABIT ->
Espresso.onView(ViewMatchers.withId(R.id.questionInput))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
Screen.SELECT_HABIT_TYPE ->
Espresso.onView(ViewMatchers.withText(R.string.yes_or_no_example))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
else -> throw IllegalStateException()
}
}
enum class Screen {
LIST_HABITS, SHOW_HABIT, EDIT_HABIT, SELECT_HABIT_TYPE
}
}

@ -1,92 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance.steps;
import androidx.test.uiautomator.*;
import org.isoron.uhabits.*;
import static androidx.test.espresso.Espresso.*;
import static androidx.test.espresso.action.ViewActions.*;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.matcher.ViewMatchers.*;
import static org.isoron.uhabits.BaseUserInterfaceTest.*;
public class EditHabitSteps
{
public static void clickSave()
{
onView(withId(R.id.buttonSave)).perform(click());
}
public static void pickFrequency()
{
onView(withId(R.id.boolean_frequency_picker)).perform(click());
onView(withText("SAVE")).perform(click());
}
public static void pickColor(int color)
{
onView(withId(R.id.colorButton)).perform(click());
device.findObject(By.descStartsWith(String.format("Color %d", color))).click();
}
public static void typeName(String name)
{
typeTextWithId(R.id.nameInput, name);
}
public static void typeQuestion(String name)
{
typeTextWithId(R.id.questionInput, name);
}
public static void typeDescription(String description)
{
typeTextWithId(R.id.notesInput, description);
}
public static void setReminder()
{
onView(withId(R.id.reminderTimePicker)).perform(click());
onView(withId(R.id.done_button)).perform(click());
}
public static void clickReminderDays()
{
onView(withId(R.id.reminderDatePicker)).perform(click());
}
public static void unselectAllDays()
{
onView(withText("Saturday")).perform(click());
onView(withText("Sunday")).perform(click());
onView(withText("Monday")).perform(click());
onView(withText("Tuesday")).perform(click());
onView(withText("Wednesday")).perform(click());
onView(withText("Thursday")).perform(click());
onView(withText("Friday")).perform(click());
}
private static void typeTextWithId(int id, String name)
{
onView(withId(id)).perform(clearText(), typeText(name), closeSoftKeyboard());
}
}

@ -0,0 +1,83 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance.steps
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.uiautomator.By
import org.isoron.uhabits.BaseUserInterfaceTest
import org.isoron.uhabits.R
object EditHabitSteps {
fun clickSave() {
Espresso.onView(ViewMatchers.withId(R.id.buttonSave)).perform(ViewActions.click())
}
fun pickFrequency() {
Espresso.onView(ViewMatchers.withId(R.id.boolean_frequency_picker))
.perform(ViewActions.click())
Espresso.onView(ViewMatchers.withText("SAVE")).perform(ViewActions.click())
}
fun pickColor(color: Int) {
Espresso.onView(ViewMatchers.withId(R.id.colorButton)).perform(ViewActions.click())
BaseUserInterfaceTest.device.findObject(By.descStartsWith(String.format("Color %d", color)))
.click()
}
fun typeName(name: String) {
typeTextWithId(R.id.nameInput, name)
}
fun typeQuestion(name: String) {
typeTextWithId(R.id.questionInput, name)
}
fun typeDescription(description: String) {
typeTextWithId(R.id.notesInput, description)
}
fun setReminder() {
Espresso.onView(ViewMatchers.withId(R.id.reminderTimePicker)).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withId(R.id.done_button)).perform(ViewActions.click())
}
fun clickReminderDays() {
Espresso.onView(ViewMatchers.withId(R.id.reminderDatePicker)).perform(ViewActions.click())
}
fun unselectAllDays() {
Espresso.onView(ViewMatchers.withText("Saturday")).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withText("Sunday")).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withText("Monday")).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withText("Tuesday")).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withText("Wednesday")).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withText("Thursday")).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withText("Friday")).perform(ViewActions.click())
}
private fun typeTextWithId(id: Int, name: String) {
Espresso.onView(ViewMatchers.withId(id)).perform(
ViewActions.clearText(),
ViewActions.typeText(name),
ViewActions.closeSoftKeyboard()
)
}
}

@ -1,161 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance.steps;
import androidx.test.espresso.*;
import android.view.*;
import org.hamcrest.*;
import org.isoron.uhabits.R;
import org.isoron.uhabits.activities.habits.list.views.*;
import java.util.*;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant;
import static androidx.test.espresso.matcher.ViewMatchers.isEnabled;
import static androidx.test.espresso.matcher.ViewMatchers.withClassName;
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withParent;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.CoreMatchers.*;
import static org.isoron.uhabits.BaseUserInterfaceTest.device;
import static org.isoron.uhabits.acceptance.steps.CommonSteps.clickText;
public abstract class ListHabitsSteps
{
public static void clickMenu(MenuItem item)
{
switch (item)
{
case ABOUT:
clickTextInsideOverflowMenu(R.string.about);
break;
case HELP:
clickTextInsideOverflowMenu(R.string.help);
break;
case SETTINGS:
clickTextInsideOverflowMenu(R.string.settings);
break;
case ADD:
clickViewWithId(R.id.actionCreateHabit);
break;
case EDIT:
clickViewWithId(R.id.action_edit_habit);
break;
case DELETE:
clickTextInsideOverflowMenu(R.string.delete);
break;
case ARCHIVE:
clickTextInsideOverflowMenu(R.string.archive);
break;
case UNARCHIVE:
clickTextInsideOverflowMenu(R.string.unarchive);
break;
case TOGGLE_ARCHIVED:
clickViewWithId(R.id.action_filter);
clickText(R.string.hide_archived);
break;
case TOGGLE_COMPLETED:
clickViewWithId(R.id.action_filter);
clickText(R.string.hide_completed);
break;
}
}
private static void clickTextInsideOverflowMenu(int id) {
onView(allOf(withContentDescription("More options"), withParent(withParent(withClassName(endsWith("Toolbar")))))).perform(click());
onView(withText(id)).perform(click());
}
private static void clickViewWithId(int id)
{
onView(withId(id)).perform(click());
}
private static ViewAction longClickDescendantWithClass(Class cls, int count)
{
return new ViewAction()
{
@Override
public Matcher<View> getConstraints()
{
return isEnabled();
}
@Override
public String getDescription()
{
return "perform on children";
}
@Override
public void perform(UiController uiController, View view)
{
LinkedList<ViewGroup> stack = new LinkedList<>();
if (view instanceof ViewGroup) stack.push((ViewGroup) view);
int countRemaining = count;
while (!stack.isEmpty())
{
ViewGroup vg = stack.pop();
for (int i = 0; i < vg.getChildCount(); i++)
{
View v = vg.getChildAt(i);
if (v instanceof ViewGroup) stack.push((ViewGroup) v);
if (cls.isInstance(v) && countRemaining > 0)
{
v.performLongClick();
uiController.loopMainThreadUntilIdle();
countRemaining--;
}
}
}
}
};
}
public static void longPressCheckmarks(String habit, int count)
{
CommonSteps.scrollToText(habit);
onView(allOf(hasDescendant(withText(habit)),
withClassName(endsWith("HabitCardView")))).perform(
longClickDescendantWithClass(CheckmarkButtonView.class, count));
device.waitForIdle();
}
public enum MenuItem
{
ABOUT, HELP, SETTINGS, EDIT, DELETE, ARCHIVE, TOGGLE_ARCHIVED,
UNARCHIVE, TOGGLE_COMPLETED, ADD
}
}

@ -0,0 +1,124 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance.steps
import android.view.View
import android.view.ViewGroup
import androidx.test.espresso.Espresso
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.matcher.ViewMatchers
import org.hamcrest.CoreMatchers.allOf
import org.hamcrest.CoreMatchers.endsWith
import org.hamcrest.Matcher
import org.isoron.uhabits.BaseUserInterfaceTest
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.habits.list.views.CheckmarkButtonView
import java.util.LinkedList
object ListHabitsSteps {
fun clickMenu(item: MenuItem?) {
when (item) {
MenuItem.ABOUT -> clickTextInsideOverflowMenu(R.string.about)
MenuItem.HELP -> clickTextInsideOverflowMenu(R.string.help)
MenuItem.SETTINGS -> clickTextInsideOverflowMenu(R.string.settings)
MenuItem.ADD -> clickViewWithId(R.id.actionCreateHabit)
MenuItem.EDIT -> clickViewWithId(R.id.action_edit_habit)
MenuItem.DELETE -> clickTextInsideOverflowMenu(R.string.delete)
MenuItem.ARCHIVE -> clickTextInsideOverflowMenu(R.string.archive)
MenuItem.UNARCHIVE -> clickTextInsideOverflowMenu(R.string.unarchive)
MenuItem.TOGGLE_ARCHIVED -> {
clickViewWithId(R.id.action_filter)
CommonSteps.clickText(R.string.hide_archived)
}
MenuItem.TOGGLE_COMPLETED -> {
clickViewWithId(R.id.action_filter)
CommonSteps.clickText(R.string.hide_completed)
}
}
}
private fun clickTextInsideOverflowMenu(id: Int) {
Espresso.onView(
allOf(
ViewMatchers.withContentDescription("More options"),
ViewMatchers.withParent(
ViewMatchers.withParent(
ViewMatchers.withClassName(
endsWith("Toolbar")
)
)
)
)
).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withText(id)).perform(ViewActions.click())
}
private fun clickViewWithId(id: Int) {
Espresso.onView(ViewMatchers.withId(id)).perform(ViewActions.click())
}
private fun longClickDescendantWithClass(cls: Class<*>, count: Int): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> {
return ViewMatchers.isEnabled()
}
override fun getDescription(): String {
return "perform on children"
}
override fun perform(uiController: UiController, view: View) {
val stack = LinkedList<ViewGroup>()
if (view is ViewGroup) stack.push(view)
var countRemaining = count
while (!stack.isEmpty()) {
val vg = stack.pop()
for (i in 0 until vg.childCount) {
val v = vg.getChildAt(i)
if (v is ViewGroup) stack.push(v)
if (cls.isInstance(v) && countRemaining > 0) {
v.performLongClick()
uiController.loopMainThreadUntilIdle()
countRemaining--
}
}
}
}
}
}
fun longPressCheckmarks(habit: String?, count: Int) {
CommonSteps.scrollToText(habit)
Espresso.onView(
allOf(
ViewMatchers.hasDescendant(ViewMatchers.withText(habit)),
ViewMatchers.withClassName(endsWith("HabitCardView"))
)
).perform(
longClickDescendantWithClass(CheckmarkButtonView::class.java, count)
)
BaseUserInterfaceTest.device.waitForIdle()
}
enum class MenuItem {
ABOUT, HELP, SETTINGS, EDIT, DELETE, ARCHIVE, TOGGLE_ARCHIVED, UNARCHIVE, TOGGLE_COMPLETED, ADD
}
}

@ -1,87 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance.steps;
import androidx.test.uiautomator.*;
import static android.os.Build.VERSION.SDK_INT;
import static org.junit.Assert.*;
import static org.isoron.uhabits.BaseUserInterfaceTest.*;
public class WidgetSteps {
public static void clickCheckmarkWidget() throws Exception {
String view_id = "org.isoron.uhabits:id/imageView";
device.findObject(new UiSelector().resourceId(view_id)).click();
}
public static void clickText(String s) throws Exception {
UiObject object = device.findObject(new UiSelector().text(s));
if (!object.waitForExists(1000)) {
object = device.findObject(new UiSelector().text(s.toUpperCase()));
}
object.click();
}
public static void dragCheckmarkWidgetToHomeScreen() throws Exception {
openWidgetScreen();
dragWidgetToHomeScreen();
}
private static void dragWidgetToHomeScreen() throws Exception {
int height = device.getDisplayHeight();
int width = device.getDisplayWidth();
device.findObject(new UiSelector().text("Checkmark"))
.dragTo(width / 2, height / 2, 40);
}
private static void openWidgetScreen() throws Exception {
int h = device.getDisplayHeight();
int w = device.getDisplayWidth();
if (SDK_INT <= 21) {
device.pressHome();
device.waitForIdle();
device.findObject(new UiSelector().description("Apps")).click();
device.findObject(new UiSelector().description("Apps")).click();
device.findObject(new UiSelector().description("Widgets")).click();
} else {
String list_id = "com.android.launcher3:id/widgets_list_view";
device.pressHome();
device.waitForIdle();
device.drag(w / 2, h / 2, w / 2, h / 2, 8);
UiObject button = device.findObject(new UiSelector().text("WIDGETS"));
if(!button.waitForExists(1000)) {
button = device.findObject(new UiSelector().text("Widgets"));
}
button.click();
if (SDK_INT >= 28) {
new UiScrollable(new UiSelector().resourceId(list_id))
.scrollForward();
}
new UiScrollable(new UiSelector().resourceId(list_id))
.scrollIntoView(new UiSelector().text("Checkmark"));
}
}
public static void verifyCheckmarkWidgetIsShown() throws Exception {
String view_id = "org.isoron.uhabits:id/imageView";
assertTrue(device.findObject(new UiSelector().resourceId(view_id)).exists());
assertFalse(device.findObject(new UiSelector().textStartsWith("Habit deleted")).exists());
}
}

@ -0,0 +1,98 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.acceptance.steps
import android.os.Build.VERSION
import androidx.test.uiautomator.UiScrollable
import androidx.test.uiautomator.UiSelector
import junit.framework.Assert.assertFalse
import junit.framework.Assert.assertTrue
import org.isoron.uhabits.BaseUserInterfaceTest
object WidgetSteps {
@Throws(Exception::class)
fun clickCheckmarkWidget() {
val viewId = "org.isoron.uhabits:id/imageView"
BaseUserInterfaceTest.device.findObject(UiSelector().resourceId(viewId)).click()
}
@Throws(Exception::class)
fun clickText(s: String) {
var textObject = BaseUserInterfaceTest.device.findObject(UiSelector().text(s))
if (!textObject.waitForExists(1000)) {
textObject = BaseUserInterfaceTest.device.findObject(UiSelector().text(s.toUpperCase()))
}
textObject.click()
}
@Throws(Exception::class)
fun dragCheckmarkWidgetToHomeScreen() {
openWidgetScreen()
dragWidgetToHomeScreen()
}
@Throws(Exception::class)
private fun dragWidgetToHomeScreen() {
val height = BaseUserInterfaceTest.device.displayHeight
val width = BaseUserInterfaceTest.device.displayWidth
BaseUserInterfaceTest.device.findObject(UiSelector().text("Checkmark"))
.dragTo(width / 2, height / 2, 40)
}
@Throws(Exception::class)
private fun openWidgetScreen() {
val h = BaseUserInterfaceTest.device.displayHeight
val w = BaseUserInterfaceTest.device.displayWidth
if (VERSION.SDK_INT <= 21) {
BaseUserInterfaceTest.device.pressHome()
BaseUserInterfaceTest.device.waitForIdle()
BaseUserInterfaceTest.device.findObject(UiSelector().description("Apps")).click()
BaseUserInterfaceTest.device.findObject(UiSelector().description("Apps")).click()
BaseUserInterfaceTest.device.findObject(UiSelector().description("Widgets")).click()
} else {
val listId = "com.android.launcher3:id/widgets_list_view"
BaseUserInterfaceTest.device.pressHome()
BaseUserInterfaceTest.device.waitForIdle()
BaseUserInterfaceTest.device.drag(w / 2, h / 2, w / 2, h / 2, 8)
var button = BaseUserInterfaceTest.device.findObject(UiSelector().text("WIDGETS"))
if (!button.waitForExists(1000)) {
button = BaseUserInterfaceTest.device.findObject(UiSelector().text("Widgets"))
}
button.click()
if (VERSION.SDK_INT >= 28) {
UiScrollable(UiSelector().resourceId(listId))
.scrollForward()
}
UiScrollable(UiSelector().resourceId(listId))
.scrollIntoView(UiSelector().text("Checkmark"))
}
}
@Throws(Exception::class)
fun verifyCheckmarkWidgetIsShown() {
val viewId = "org.isoron.uhabits:id/imageView"
assertTrue(
BaseUserInterfaceTest.device.findObject(UiSelector().resourceId(viewId)).exists()
)
assertFalse(
BaseUserInterfaceTest.device.findObject(UiSelector().textStartsWith("Habit deleted"))
.exists()
)
}
}

@ -34,11 +34,12 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
private const val PATH = "habits/list/CheckmarkPanelView"
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@MediumTest @MediumTest
class EntryPanelViewTest : BaseViewTest() { class EntryPanelViewTest : BaseViewTest() {
private val PATH = "habits/list/CheckmarkPanelView"
private lateinit var view: CheckmarkPanelView private lateinit var view: CheckmarkPanelView
@Before @Before
@ -75,27 +76,6 @@ class EntryPanelViewTest : BaseViewTest() {
assertRenders(view, "$PATH/render.png") assertRenders(view, "$PATH/render.png")
} }
// // Flaky test
// @Test
// fun testRender_withDifferentColor() {
// view.color = PaletteUtils.getAndroidTestColor(1)
// assertRenders(view, "$PATH/render_different_color.png")
// }
// // Flaky test
// @Test
// fun testRender_Reversed() {
// prefs.isCheckmarkSequenceReversed = true
// assertRenders(view, "$PATH/render_reversed.png")
// }
// // Flaky test
// @Test
// fun testRender_withOffset() {
// view.dataOffset = 3
// assertRenders(view, "$PATH/render_offset.png")
// }
@Test @Test
fun testToggle() { fun testToggle() {
val timestamps = mutableListOf<Timestamp>() val timestamps = mutableListOf<Timestamp>()

@ -56,7 +56,7 @@ class HabitCardViewTest : BaseViewTest() {
view = component.getHabitCardViewFactory().create().apply { view = component.getHabitCardViewFactory().create().apply {
habit = habit1 habit = habit1
values = entries values = entries
score = habit1.scores.get(today).value score = habit1.scores[today].value
isSelected = false isSelected = false
buttonCount = 5 buttonCount = 5
} }

@ -73,27 +73,6 @@ class NumberPanelViewTest : BaseViewTest() {
assertRenders(view, "$PATH/render.png") assertRenders(view, "$PATH/render.png")
} }
// // Flaky test
// @Test
// fun testRender_withDifferentColor() {
// view.color = PaletteUtils.getAndroidTestColor(1)
// assertRenders(view, "$PATH/render_different_color.png")
// }
// // Flaky test
// @Test
// fun testRender_Reversed() {
// prefs.isCheckmarkSequenceReversed = true
// assertRenders(view, "$PATH/render_reversed.png")
// }
// // Flaky test
// @Test
// fun testRender_withOffset() {
// view.dataOffset = 3
// assertRenders(view, "$PATH/render_offset.png")
// }
@Test @Test
fun testEdit() { fun testEdit() {
val timestamps = mutableListOf<Timestamp>() val timestamps = mutableListOf<Timestamp>()

@ -26,7 +26,7 @@ import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Frequency import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.core.models.Reminder import org.isoron.uhabits.core.models.Reminder
import org.isoron.uhabits.core.models.WeekdayList.EVERY_DAY import org.isoron.uhabits.core.models.WeekdayList.Companion.EVERY_DAY
import org.isoron.uhabits.core.ui.screens.habits.show.views.SubtitleCardState import org.isoron.uhabits.core.ui.screens.habits.show.views.SubtitleCardState
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test

@ -103,9 +103,8 @@ class IntentSchedulerTest : BaseAndroidTest() {
assertNull(ReminderReceiver.lastReceivedIntent) assertNull(ReminderReceiver.lastReceivedIntent)
setSystemTime("America/Chicago", 2020, JUNE, 2, 22, 46) setSystemTime("America/Chicago", 2020, JUNE, 2, 22, 46)
val intent = ReminderReceiver.lastReceivedIntent val intent = ReminderReceiver.lastReceivedIntent!!
assertNotNull(intent) assertThat(parseId(intent.data!!), equalTo(habit.id))
assertThat(parseId(intent?.data!!), equalTo(habit.id))
} }
@Test @Test
@ -123,7 +122,6 @@ class IntentSchedulerTest : BaseAndroidTest() {
assertNull(WidgetReceiver.lastReceivedIntent) assertNull(WidgetReceiver.lastReceivedIntent)
setSystemTime("America/Chicago", 2020, JUNE, 2, 22, 46) setSystemTime("America/Chicago", 2020, JUNE, 2, 22, 46)
val intent = WidgetReceiver.lastReceivedIntent WidgetReceiver.lastReceivedIntent!!
assertNotNull(intent)
} }
} }

@ -68,13 +68,6 @@ public class CheckmarkWidgetViewTest extends BaseViewTest
assertRenders(view, PATH + "checked.png"); assertRenders(view, PATH + "checked.png");
} }
// @Test
// public void testRender_implicitlyChecked() throws IOException
// {
// view.setCheckmarkValue(Checkmark.YES_AUTO);
// view.refresh();
// assertRenders(view, PATH + "implicitly_checked.png");
// }
@Test @Test
public void testRender_largeSize() throws IOException public void testRender_largeSize() throws IOException
@ -83,11 +76,4 @@ public class CheckmarkWidgetViewTest extends BaseViewTest
assertRenders(view, PATH + "large_size.png"); assertRenders(view, PATH + "large_size.png");
} }
// @Test
// public void testRender_unchecked() throws IOException
// {
// view.setCheckmarkValue(Checkmark.NO);
// view.refresh();
// assertRenders(view, PATH + "unchecked.png");
// }
} }

@ -114,7 +114,7 @@ public class DatePickerDialog extends DialogFragment implements
public interface OnDateSetListener { public interface OnDateSetListener {
/** /**
* @param view The view associated with this listener. * @param dialog The dialog associated with this listener.
* @param year The year that was set. * @param year The year that was set.
* @param monthOfYear The month that was set (0-11) for compatibility * @param monthOfYear The month that was set (0-11) for compatibility
* with {@link java.util.Calendar}. * with {@link java.util.Calendar}.

@ -167,7 +167,7 @@ public abstract class DayPickerView extends ListView implements OnScrollListener
* the list will not be scrolled unless forceScroll is true. This time may * the list will not be scrolled unless forceScroll is true. This time may
* optionally be highlighted as selected as well. * optionally be highlighted as selected as well.
* *
* @param time The time to move to * @param day The day to move to
* @param animate Whether to scroll to the given time or just redraw at the * @param animate Whether to scroll to the given time or just redraw at the
* new location * new location
* @param setSelected Whether to set the given time as selected * @param setSelected Whether to set the given time as selected

@ -392,7 +392,7 @@ public class RadialPickerLayout extends FrameLayout implements OnTouchListener {
* Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all
* multiples of 30), where the input will be "snapped" to the closest visible degrees. * multiples of 30), where the input will be "snapped" to the closest visible degrees.
* @param degrees The input degrees * @param degrees The input degrees
* @param forceAboveOrBelow The output may be forced to either the higher or lower step, or may * @param forceHigherOrLower The output may be forced to either the higher or lower step, or may
* be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force
* strictly lower, and 0 to snap to the closer one. * strictly lower, and 0 to snap to the closer one.
* @return output degrees, will be a multiple of 30 * @return output degrees, will be a multiple of 30

@ -720,7 +720,7 @@ public class TimePickerDialog extends AppCompatDialogFragment implements OnValue
/** /**
* Get out of keyboard mode. If there is nothing in typedTimes, revert to TimePicker's time. * Get out of keyboard mode. If there is nothing in typedTimes, revert to TimePicker's time.
* *
* @param changeDisplays If true, update the displays with the relevant time. * @param updateDisplays If true, update the displays with the relevant time.
*/ */
private void finishKbMode(boolean updateDisplays) private void finishKbMode(boolean updateDisplays)
{ {

@ -25,6 +25,8 @@ import android.util.AttributeSet
import android.view.GestureDetector import android.view.GestureDetector
import android.view.MotionEvent import android.view.MotionEvent
import android.widget.Scroller import android.widget.Scroller
import kotlin.math.abs
import kotlin.math.max
/** /**
* An AndroidView that implements scrolling. * An AndroidView that implements scrolling.
@ -71,7 +73,7 @@ class AndroidDataView(
dx: Float, dx: Float,
dy: Float, dy: Float,
): Boolean { ): Boolean {
if (Math.abs(dx) > Math.abs(dy)) { if (abs(dx) > abs(dy)) {
val parent = parent val parent = parent
parent?.requestDisallowInterceptTouchEvent(true) parent?.requestDisallowInterceptTouchEvent(true)
} }
@ -128,7 +130,7 @@ class AndroidDataView(
view?.let { v -> view?.let { v ->
var newDataOffset: Int = var newDataOffset: Int =
scroller.currX / (v.dataColumnWidth * canvas.innerDensity).toInt() scroller.currX / (v.dataColumnWidth * canvas.innerDensity).toInt()
newDataOffset = Math.max(0, newDataOffset) newDataOffset = max(0, newDataOffset)
if (newDataOffset != v.dataOffset) { if (newDataOffset != v.dataOffset) {
v.dataOffset = newDataOffset v.dataOffset = newDataOffset
postInvalidate() postInvalidate()

@ -42,7 +42,7 @@ class AndroidImage(private val bmp: Bitmap) : Image {
} }
} }
public fun Color.toInt(): Int { fun Color.toInt(): Int {
return android.graphics.Color.argb( return android.graphics.Color.argb(
(255 * this.alpha).roundToInt(), (255 * this.alpha).roundToInt(),
(255 * this.red).roundToInt(), (255 * this.red).roundToInt(),

@ -68,7 +68,7 @@ open class AndroidBugReporter @Inject constructor(@AppContext private val contex
if (log.size > maxLineCount) log.removeFirst() if (log.size > maxLineCount) log.removeFirst()
} }
for (l in log) { for (l in log) {
builder.appendln(l) builder.appendLine(l)
} }
return builder.toString() return builder.toString()
} }
@ -99,18 +99,18 @@ open class AndroidBugReporter @Inject constructor(@AppContext private val contex
private fun getDeviceInfo(): String { private fun getDeviceInfo(): String {
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
return buildString { return buildString {
appendln("App Version Name: ${BuildConfig.VERSION_NAME}") appendLine("App Version Name: ${BuildConfig.VERSION_NAME}")
appendln("App Version Code: ${BuildConfig.VERSION_CODE}") appendLine("App Version Code: ${BuildConfig.VERSION_CODE}")
appendln("OS Version: ${System.getProperty("os.version")} (${Build.VERSION.INCREMENTAL})") appendLine("OS Version: ${System.getProperty("os.version")} (${Build.VERSION.INCREMENTAL})")
appendln("OS API Level: ${Build.VERSION.SDK_INT}") appendLine("OS API Level: ${Build.VERSION.SDK_INT}")
appendln("Device: ${Build.DEVICE}") appendLine("Device: ${Build.DEVICE}")
appendln("Model (Product): ${Build.MODEL} (${Build.PRODUCT})") appendLine("Model (Product): ${Build.MODEL} (${Build.PRODUCT})")
appendln("Manufacturer: ${Build.MANUFACTURER}") appendLine("Manufacturer: ${Build.MANUFACTURER}")
appendln("Other tags: ${Build.TAGS}") appendLine("Other tags: ${Build.TAGS}")
appendln("Screen Width: ${wm.defaultDisplay.width}") appendLine("Screen Width: ${wm.defaultDisplay.width}")
appendln("Screen Height: ${wm.defaultDisplay.height}") appendLine("Screen Height: ${wm.defaultDisplay.height}")
appendln("External storage state: ${Environment.getExternalStorageState()}") appendLine("External storage state: ${Environment.getExternalStorageState()}")
appendln() appendLine()
} }
} }
} }

@ -83,7 +83,7 @@ class HabitsApplication : Application() {
notificationTray.startListening() notificationTray.startListening()
val prefs = component.preferences val prefs = component.preferences
prefs.setLastAppVersion(BuildConfig.VERSION_CODE) prefs.lastAppVersion = BuildConfig.VERSION_CODE
val taskRunner = component.taskRunner val taskRunner = component.taskRunner
taskRunner.execute { taskRunner.execute {
@ -106,11 +106,11 @@ class HabitsApplication : Application() {
lateinit var component: HabitsApplicationComponent lateinit var component: HabitsApplicationComponent
fun isTestMode(): Boolean { fun isTestMode(): Boolean {
try { return try {
Class.forName("org.isoron.uhabits.BaseAndroidTest") Class.forName("org.isoron.uhabits.BaseAndroidTest")
return true true
} catch (e: ClassNotFoundException) { } catch (e: ClassNotFoundException) {
return false false
} }
} }
} }

@ -16,24 +16,20 @@
* You should have received a copy of the GNU General Public License along * You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.isoron.uhabits.activities.common.dialogs
package org.isoron.uhabits.activities.common.dialogs; import com.android.colorpicker.ColorPickerDialog
import org.isoron.uhabits.core.ui.callbacks.OnColorPickedCallback
import org.isoron.uhabits.core.models.*; import org.isoron.uhabits.utils.toPaletteColor
import org.isoron.uhabits.core.ui.callbacks.*;
import org.isoron.uhabits.utils.*;
/** /**
* Dialog that allows the user to choose a color. * Dialog that allows the user to choose a color.
*/ */
public class ColorPickerDialog extends com.android.colorpicker.ColorPickerDialog class ColorPickerDialog : ColorPickerDialog() {
{ fun setListener(callback: OnColorPickedCallback) {
public void setListener(OnColorPickedCallback callback) super.setOnColorSelectedListener { c: Int ->
{ val pc = c.toPaletteColor(context!!)
super.setOnColorSelectedListener(c -> callback.onColorPicked(pc)
{ }
PaletteColor pc = PaletteUtilsKt.toPaletteColor(c, getContext());
callback.onColorPicked(pc);
});
} }
} }

@ -1,53 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.dialogs;
import android.content.*;
import org.isoron.uhabits.R;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.inject.*;
import org.isoron.uhabits.utils.*;
import javax.inject.*;
@ActivityScope
public class ColorPickerDialogFactory
{
private final Context context;
@Inject
public ColorPickerDialogFactory(@ActivityContext Context context)
{
this.context = context;
}
public ColorPickerDialog create(PaletteColor color)
{
ColorPickerDialog dialog = new ColorPickerDialog();
StyledResources res = new StyledResources(context);
int androidColor = PaletteUtilsKt.toThemedAndroidColor(color, context);
dialog.initialize(R.string.color_picker_default_title, res.getPalette(),
androidColor, 4, com.android.colorpicker.ColorPickerDialog.SIZE_SMALL);
return dialog;
}
}

@ -0,0 +1,45 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.dialogs
import android.content.Context
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.PaletteColor
import org.isoron.uhabits.inject.ActivityContext
import org.isoron.uhabits.inject.ActivityScope
import org.isoron.uhabits.utils.StyledResources
import org.isoron.uhabits.utils.toThemedAndroidColor
import javax.inject.Inject
@ActivityScope
class ColorPickerDialogFactory @Inject constructor(@param:ActivityContext private val context: Context) {
fun create(color: PaletteColor): ColorPickerDialog {
val dialog = ColorPickerDialog()
val res = StyledResources(context)
val androidColor = color.toThemedAndroidColor(context)
dialog.initialize(
R.string.color_picker_default_title,
res.getPalette(),
androidColor,
4,
com.android.colorpicker.ColorPickerDialog.SIZE_SMALL
)
return dialog
}
}

@ -16,39 +16,34 @@
* You should have received a copy of the GNU General Public License along * You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.isoron.uhabits.activities.common.dialogs
package org.isoron.uhabits.activities.common.dialogs; import android.content.Context
import android.content.DialogInterface
import android.content.*; import androidx.appcompat.app.AlertDialog
import android.content.res.*; import org.isoron.uhabits.R
import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback
import androidx.annotation.*; import org.isoron.uhabits.inject.ActivityContext
import androidx.appcompat.app.*;
import org.isoron.uhabits.R;
import org.isoron.uhabits.core.ui.callbacks.*;
import org.isoron.uhabits.inject.*;
/** /**
* Dialog that asks the user confirmation before executing a delete operation. * Dialog that asks the user confirmation before executing a delete operation.
*/ */
public class ConfirmDeleteDialog extends AlertDialog class ConfirmDeleteDialog(
{ @ActivityContext context: Context,
public ConfirmDeleteDialog(@ActivityContext Context context, callback: OnConfirmedCallback,
@NonNull OnConfirmedCallback callback, quantity: Int
int quantity) ) : AlertDialog(context) {
{ init {
super(context); val res = context.resources
Resources res = context.getResources(); setTitle(res.getQuantityString(R.plurals.delete_habits_title, quantity))
setTitle(res.getQuantityString(R.plurals.delete_habits_title, quantity)); setMessage(res.getQuantityString(R.plurals.delete_habits_message, quantity))
setMessage(res.getQuantityString(R.plurals.delete_habits_message, quantity)); setButton(
setButton(BUTTON_POSITIVE, BUTTON_POSITIVE,
res.getString(R.string.yes), res.getString(R.string.yes)
(dialog, which) -> callback.onConfirmed() ) { dialog: DialogInterface?, which: Int -> callback.onConfirmed() }
); setButton(
setButton(BUTTON_NEGATIVE, BUTTON_NEGATIVE,
res.getString(R.string.no), res.getString(R.string.no)
(dialog, which) -> { } ) { dialog: DialogInterface?, which: Int -> }
);
} }
} }

@ -1,50 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.dialogs;
import android.content.*;
import android.content.res.*;
import androidx.annotation.*;
import androidx.appcompat.app.*;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.ui.callbacks.*;
import org.isoron.uhabits.inject.*;
public class ConfirmSyncKeyDialog extends AlertDialog
{
public ConfirmSyncKeyDialog(@ActivityContext Context context,
@NonNull OnConfirmedCallback callback)
{
super(context);
setTitle(R.string.device_sync);
Resources res = context.getResources();
setMessage(res.getString(R.string.sync_confirm));
setButton(BUTTON_POSITIVE,
res.getString(R.string.yes),
(dialog, which) -> callback.onConfirmed()
);
setButton(BUTTON_NEGATIVE,
res.getString(R.string.no),
(dialog, which) -> { }
);
}
}

@ -0,0 +1,45 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.dialogs
import android.content.Context
import android.content.DialogInterface
import androidx.appcompat.app.AlertDialog
import org.isoron.uhabits.R
import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback
import org.isoron.uhabits.inject.ActivityContext
class ConfirmSyncKeyDialog(
@ActivityContext context: Context,
callback: OnConfirmedCallback
) : AlertDialog(context) {
init {
setTitle(R.string.device_sync)
val res = context.resources
setMessage(res.getString(R.string.sync_confirm))
setButton(
BUTTON_POSITIVE,
res.getString(R.string.yes)
) { dialog: DialogInterface?, which: Int -> callback.onConfirmed() }
setButton(
BUTTON_NEGATIVE,
res.getString(R.string.no)
) { dialog: DialogInterface?, which: Int -> }
}
}

@ -34,6 +34,7 @@ import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior
import org.isoron.uhabits.inject.ActivityContext import org.isoron.uhabits.inject.ActivityContext
import org.isoron.uhabits.utils.InterfaceUtils import org.isoron.uhabits.utils.InterfaceUtils
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.roundToLong
class NumberPickerFactory class NumberPickerFactory
@Inject constructor( @Inject constructor(
@ -52,7 +53,7 @@ class NumberPickerFactory
val picker2 = view.findViewById<NumberPicker>(R.id.picker2) val picker2 = view.findViewById<NumberPicker>(R.id.picker2)
val tvUnit = view.findViewById<TextView>(R.id.tvUnit) val tvUnit = view.findViewById<TextView>(R.id.tvUnit)
val intValue = Math.round(value * 100).toInt() val intValue = (value * 100).roundToLong().toInt()
picker.minValue = 0 picker.minValue = 0
picker.maxValue = Integer.MAX_VALUE / 100 picker.maxValue = Integer.MAX_VALUE / 100
@ -86,13 +87,12 @@ class NumberPickerFactory
} }
InterfaceUtils.setupEditorAction( InterfaceUtils.setupEditorAction(
picker, picker
TextView.OnEditorActionListener { _, actionId, _ -> ) { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) if (actionId == EditorInfo.IME_ACTION_DONE)
dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick() dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick()
false false
} }
)
return dialog return dialog
} }

@ -1,107 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.dialogs;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDialogFragment;
import org.isoron.uhabits.R;
import org.isoron.uhabits.core.models.WeekdayList;
import org.isoron.uhabits.core.utils.DateUtils;
import java.util.Calendar;
/**
* Dialog that allows the user to pick one or more days of the week.
*/
public class WeekdayPickerDialog extends AppCompatDialogFragment implements
DialogInterface.OnMultiChoiceClickListener,
DialogInterface.OnClickListener
{
private static final String KEY_SELECTED_DAYS = "selectedDays";
private boolean[] selectedDays;
private OnWeekdaysPickedListener listener;
@Override
public void onClick(DialogInterface dialog, int which, boolean isChecked)
{
selectedDays[which] = isChecked;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if(savedInstanceState != null){
selectedDays = savedInstanceState.getBooleanArray(KEY_SELECTED_DAYS);
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBooleanArray(KEY_SELECTED_DAYS, selectedDays);
}
@Override
public void onClick(DialogInterface dialog, int which)
{
if (listener != null)
listener.onWeekdaysSet(new WeekdayList(selectedDays));
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
{
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder
.setTitle(R.string.select_weekdays)
.setMultiChoiceItems(DateUtils.getLongWeekdayNames(Calendar.SATURDAY),
selectedDays,
this)
.setPositiveButton(android.R.string.yes, this)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
dismiss();
});
return builder.create();
}
public void setListener(OnWeekdaysPickedListener listener)
{
this.listener = listener;
}
public void setSelectedDays(WeekdayList days)
{
this.selectedDays = days.toArray();
}
public interface OnWeekdaysPickedListener
{
void onWeekdaysSet(WeekdayList days);
}
}

@ -0,0 +1,94 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.common.dialogs
import android.app.Dialog
import android.content.DialogInterface
import android.content.DialogInterface.OnMultiChoiceClickListener
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.WeekdayList
import org.isoron.uhabits.core.utils.DateUtils
import java.util.Calendar
/**
* Dialog that allows the user to pick one or more days of the week.
*/
class WeekdayPickerDialog :
AppCompatDialogFragment(),
OnMultiChoiceClickListener,
DialogInterface.OnClickListener {
private var selectedDays: BooleanArray? = null
private var listener: OnWeekdaysPickedListener? = null
override fun onClick(dialog: DialogInterface, which: Int, isChecked: Boolean) {
selectedDays!![which] = isChecked
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) {
selectedDays = savedInstanceState.getBooleanArray(KEY_SELECTED_DAYS)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBooleanArray(KEY_SELECTED_DAYS, selectedDays)
}
override fun onClick(dialog: DialogInterface, which: Int) {
if (listener != null) listener!!.onWeekdaysSet(WeekdayList(selectedDays))
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(
activity!!
)
builder
.setTitle(R.string.select_weekdays)
.setMultiChoiceItems(
DateUtils.getLongWeekdayNames(Calendar.SATURDAY),
selectedDays,
this
)
.setPositiveButton(android.R.string.yes, this)
.setNegativeButton(
android.R.string.cancel
) { _: DialogInterface?, _: Int -> dismiss() }
return builder.create()
}
fun setListener(listener: OnWeekdaysPickedListener?) {
this.listener = listener
}
fun setSelectedDays(days: WeekdayList) {
selectedDays = days.toArray()
}
fun interface OnWeekdaysPickedListener {
fun onWeekdaysSet(days: WeekdayList)
}
companion object {
private const val KEY_SELECTED_DAYS = "selectedDays"
}
}

@ -56,8 +56,7 @@ class TaskProgressBar(
fun update() { fun update() {
val callback = { val callback = {
val activeTaskCount = runner.activeTaskCount val newVisibility = when (runner.activeTaskCount) {
val newVisibility = when (activeTaskCount) {
0 -> GONE 0 -> GONE
else -> VISIBLE else -> VISIBLE
} }

@ -197,7 +197,8 @@ class EditHabitActivity : AppCompatActivity() {
binding.reminderDatePicker.setOnClickListener { binding.reminderDatePicker.setOnClickListener {
val dialog = WeekdayPickerDialog() val dialog = WeekdayPickerDialog()
dialog.setListener { days ->
dialog.setListener { days: WeekdayList ->
reminderDays = days reminderDays = days
if (reminderDays.isEmpty) reminderDays = WeekdayList.EVERY_DAY if (reminderDays.isEmpty) reminderDays = WeekdayList.EVERY_DAY
populateReminder() populateReminder()

@ -36,7 +36,7 @@ class HabitTypeDialog : AppCompatDialogFragment() {
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View {
val binding = SelectHabitTypeBinding.inflate(inflater, container, false) val binding = SelectHabitTypeBinding.inflate(inflater, container, false)
binding.buttonYesNo.setOnClickListener { binding.buttonYesNo.setOnClickListener {

@ -48,9 +48,9 @@ import org.isoron.uhabits.utils.dim
import org.isoron.uhabits.utils.dp import org.isoron.uhabits.utils.dp
import org.isoron.uhabits.utils.setupToolbar import org.isoron.uhabits.utils.setupToolbar
import org.isoron.uhabits.utils.sres import org.isoron.uhabits.utils.sres
import java.lang.Math.max
import java.lang.Math.min
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
const val MAX_CHECKMARK_COUNT = 60 const val MAX_CHECKMARK_COUNT = 60

@ -1,350 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.habits.list.views;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.*;
import androidx.recyclerview.widget.RecyclerView;
import org.isoron.uhabits.activities.habits.list.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.ui.screens.habits.list.*;
import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.inject.*;
import java.util.*;
import javax.inject.*;
/**
* Provides data that backs a {@link HabitCardListView}.
* <p>
* The data if fetched and cached by a {@link HabitCardListCache}. This adapter
* also holds a list of items that have been selected.
*/
@ActivityScope
public class HabitCardListAdapter
extends RecyclerView.Adapter<HabitCardViewHolder> implements
HabitCardListCache.Listener,
MidnightTimer.MidnightListener,
ListHabitsMenuBehavior.Adapter,
ListHabitsSelectionMenuBehavior.Adapter
{
@NonNull
private ModelObservable observable;
@Nullable
private HabitCardListView listView;
@NonNull
private final LinkedList<Habit> selected;
@NonNull
private final HabitCardListCache cache;
@NonNull
private Preferences preferences;
private final MidnightTimer midnightTimer;
@Inject
public HabitCardListAdapter(@NonNull HabitCardListCache cache,
@NonNull Preferences preferences,
@NonNull MidnightTimer midnightTimer)
{
this.preferences = preferences;
this.selected = new LinkedList<>();
this.observable = new ModelObservable();
this.cache = cache;
this.midnightTimer = midnightTimer;
cache.setListener(this);
cache.setCheckmarkCount(
ListHabitsRootViewKt.MAX_CHECKMARK_COUNT);
cache.setSecondaryOrder(preferences.getDefaultSecondaryOrder());
cache.setPrimaryOrder(preferences.getDefaultPrimaryOrder());
setHasStableIds(true);
}
@Override
public void atMidnight()
{
cache.refreshAllHabits();
}
public void cancelRefresh()
{
cache.cancelTasks();
}
/**
* Sets all items as not selected.
*/
@Override
public void clearSelection()
{
selected.clear();
notifyDataSetChanged();
observable.notifyListeners();
}
/**
* Returns the item that occupies a certain position on the list
*
* @param position position of the item
* @return the item at given position or null if position is invalid
*/
@Deprecated
@Nullable
public Habit getItem(int position)
{
return cache.getHabitByPosition(position);
}
@Override
public int getItemCount()
{
return cache.getHabitCount();
}
@Override
public long getItemId(int position)
{
return getItem(position).getId();
}
@NonNull
public ModelObservable getObservable()
{
return observable;
}
@Override
@NonNull
public List<Habit> getSelected()
{
return new LinkedList<>(selected);
}
/**
* Returns whether list of selected items is empty.
*
* @return true if selection is empty, false otherwise
*/
public boolean isSelectionEmpty()
{
return selected.isEmpty();
}
public boolean isSortable()
{
return cache.getPrimaryOrder() == HabitList.Order.BY_POSITION;
}
/**
* Notify the adapter that it has been attached to a ListView.
*/
public void onAttached()
{
cache.onAttached();
midnightTimer.addListener(this);
}
@Override
public void onBindViewHolder(@Nullable HabitCardViewHolder holder,
int position)
{
if (holder == null) return;
if (listView == null) return;
Habit habit = cache.getHabitByPosition(position);
double score = cache.getScore(habit.getId());
int checkmarks[] = cache.getCheckmarks(habit.getId());
boolean selected = this.selected.contains(habit);
listView.bindCardView(holder, habit, score, checkmarks, selected);
}
@Override
public void onViewAttachedToWindow(@Nullable HabitCardViewHolder holder)
{
if (listView == null) return;
listView.attachCardView(holder);
}
@Override
public void onViewDetachedFromWindow(@Nullable HabitCardViewHolder holder)
{
if (listView == null) return;
listView.detachCardView(holder);
}
@Override
public HabitCardViewHolder onCreateViewHolder(ViewGroup parent,
int viewType)
{
if (listView == null) return null;
View view = listView.createHabitCardView();
return new HabitCardViewHolder(view);
}
/**
* Notify the adapter that it has been detached from a ListView.
*/
public void onDetached()
{
cache.onDetached();
midnightTimer.removeListener(this);
}
@Override
public void onItemChanged(int position)
{
notifyItemChanged(position);
observable.notifyListeners();
}
@Override
public void onItemInserted(int position)
{
notifyItemInserted(position);
observable.notifyListeners();
}
@Override
public void onItemMoved(int fromPosition, int toPosition)
{
notifyItemMoved(fromPosition, toPosition);
observable.notifyListeners();
}
@Override
public void onItemRemoved(int position)
{
notifyItemRemoved(position);
observable.notifyListeners();
}
@Override
public void onRefreshFinished()
{
observable.notifyListeners();
}
/**
* Removes a list of habits from the adapter.
* <p>
* Note that this only has effect on the adapter cache. The database is not
* modified, and the change is lost when the cache is refreshed. This method
* is useful for making the ListView more responsive: while we wait for the
* database operation to finish, the cache can be modified to reflect the
* changes immediately.
*
* @param habits list of habits to be removed
*/
@Override
public void performRemove(List<Habit> habits)
{
for (Habit h : habits)
cache.remove(h.getId());
}
/**
* Changes the order of habits on the adapter.
* <p>
* Note that this only has effect on the adapter cache. The database is not
* modified, and the change is lost when the cache is refreshed. This method
* is useful for making the ListView more responsive: while we wait for the
* database operation to finish, the cache can be modified to reflect the
* changes immediately.
*
* @param from the habit that should be moved
* @param to the habit that currently occupies the desired position
*/
public void performReorder(int from, int to)
{
cache.reorder(from, to);
}
@Override
public void refresh()
{
cache.refreshAllHabits();
}
@Override
public void setFilter(HabitMatcher matcher)
{
cache.setFilter(matcher);
}
/**
* Sets the HabitCardListView that this adapter will provide data for.
* <p>
* This object will be used to generated new HabitCardViews, upon demand.
*
* @param listView the HabitCardListView associated with this adapter
*/
public void setListView(@Nullable HabitCardListView listView)
{
this.listView = listView;
}
@Override
public void setPrimaryOrder(HabitList.Order order)
{
cache.setPrimaryOrder(order);
preferences.setDefaultPrimaryOrder(order);
}
@Override
public void setSecondaryOrder(HabitList.Order order) {
cache.setSecondaryOrder(order);
preferences.setDefaultSecondaryOrder(order);
}
@Override
public HabitList.Order getPrimaryOrder()
{
return cache.getPrimaryOrder();
}
/**
* Selects or deselects the item at a given position.
*
* @param position position of the item to be toggled
*/
public void toggleSelection(int position)
{
Habit h = getItem(position);
if (h == null) return;
int k = selected.indexOf(h);
if (k < 0) selected.add(h);
else selected.remove(h);
notifyDataSetChanged();
}
}

@ -0,0 +1,263 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.habits.list.views
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.isoron.uhabits.activities.habits.list.MAX_CHECKMARK_COUNT
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.HabitMatcher
import org.isoron.uhabits.core.models.ModelObservable
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.ui.screens.habits.list.HabitCardListCache
import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsMenuBehavior
import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsSelectionMenuBehavior
import org.isoron.uhabits.core.utils.MidnightTimer
import org.isoron.uhabits.inject.ActivityScope
import java.util.LinkedList
import javax.inject.Inject
/**
* Provides data that backs a [HabitCardListView].
*
*
* The data if fetched and cached by a [HabitCardListCache]. This adapter
* also holds a list of items that have been selected.
*/
@ActivityScope
class HabitCardListAdapter @Inject constructor(
private val cache: HabitCardListCache,
private val preferences: Preferences,
private val midnightTimer: MidnightTimer
) : RecyclerView.Adapter<HabitCardViewHolder?>(),
HabitCardListCache.Listener,
MidnightTimer.MidnightListener,
ListHabitsMenuBehavior.Adapter,
ListHabitsSelectionMenuBehavior.Adapter {
val observable: ModelObservable = ModelObservable()
private var listView: HabitCardListView? = null
private val selected: LinkedList<Habit> = LinkedList()
override fun atMidnight() {
cache.refreshAllHabits()
}
fun cancelRefresh() {
cache.cancelTasks()
}
/**
* Sets all items as not selected.
*/
override fun clearSelection() {
selected.clear()
notifyDataSetChanged()
observable.notifyListeners()
}
/**
* Returns the item that occupies a certain position on the list
*
* @param position position of the item
* @return the item at given position or null if position is invalid
*/
@Deprecated("")
fun getItem(position: Int): Habit? {
return cache.getHabitByPosition(position)
}
override fun getItemCount(): Int {
return cache.habitCount
}
override fun getItemId(position: Int): Long {
return getItem(position)!!.id!!
}
override fun getSelected(): List<Habit> {
return LinkedList(selected)
}
/**
* Returns whether list of selected items is empty.
*
* @return true if selection is empty, false otherwise
*/
val isSelectionEmpty: Boolean
get() = selected.isEmpty()
val isSortable: Boolean
get() = cache.primaryOrder == HabitList.Order.BY_POSITION
/**
* Notify the adapter that it has been attached to a ListView.
*/
fun onAttached() {
cache.onAttached()
midnightTimer.addListener(this)
}
override fun onBindViewHolder(
holder: HabitCardViewHolder,
position: Int
) {
if (listView == null) return
val habit = cache.getHabitByPosition(position)
val score = cache.getScore(habit!!.id!!)
val checkmarks = cache.getCheckmarks(habit.id!!)
val selected = selected.contains(habit)
listView!!.bindCardView(holder, habit, score, checkmarks, selected)
}
override fun onViewAttachedToWindow(holder: HabitCardViewHolder) {
listView!!.attachCardView(holder)
}
override fun onViewDetachedFromWindow(holder: HabitCardViewHolder) {
listView!!.detachCardView(holder)
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): HabitCardViewHolder {
val view = listView!!.createHabitCardView()
return HabitCardViewHolder(view)
}
/**
* Notify the adapter that it has been detached from a ListView.
*/
fun onDetached() {
cache.onDetached()
midnightTimer.removeListener(this)
}
override fun onItemChanged(position: Int) {
notifyItemChanged(position)
observable.notifyListeners()
}
override fun onItemInserted(position: Int) {
notifyItemInserted(position)
observable.notifyListeners()
}
override fun onItemMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition, toPosition)
observable.notifyListeners()
}
override fun onItemRemoved(position: Int) {
notifyItemRemoved(position)
observable.notifyListeners()
}
override fun onRefreshFinished() {
observable.notifyListeners()
}
/**
* Removes a list of habits from the adapter.
*
*
* Note that this only has effect on the adapter cache. The database is not
* modified, and the change is lost when the cache is refreshed. This method
* is useful for making the ListView more responsive: while we wait for the
* database operation to finish, the cache can be modified to reflect the
* changes immediately.
*
* @param habits list of habits to be removed
*/
override fun performRemove(habits: List<Habit>) {
for (habit in habits) cache.remove(habit.id!!)
}
/**
* Changes the order of habits on the adapter.
*
*
* Note that this only has effect on the adapter cache. The database is not
* modified, and the change is lost when the cache is refreshed. This method
* is useful for making the ListView more responsive: while we wait for the
* database operation to finish, the cache can be modified to reflect the
* changes immediately.
*
* @param from the habit that should be moved
* @param to the habit that currently occupies the desired position
*/
fun performReorder(from: Int, to: Int) {
cache.reorder(from, to)
}
override fun refresh() {
cache.refreshAllHabits()
}
override fun setFilter(matcher: HabitMatcher) {
cache.setFilter(matcher)
}
/**
* Sets the HabitCardListView that this adapter will provide data for.
*
*
* This object will be used to generated new HabitCardViews, upon demand.
*
* @param listView the HabitCardListView associated with this adapter
*/
fun setListView(listView: HabitCardListView?) {
this.listView = listView
}
override fun setPrimaryOrder(order: HabitList.Order) {
cache.primaryOrder = order
preferences.defaultPrimaryOrder = order
}
override fun setSecondaryOrder(order: HabitList.Order) {
cache.secondaryOrder = order
preferences.defaultSecondaryOrder = order
}
override fun getPrimaryOrder(): HabitList.Order {
return cache.primaryOrder
}
/**
* Selects or deselects the item at a given position.
*
* @param position position of the item to be toggled
*/
fun toggleSelection(position: Int) {
val h = getItem(position) ?: return
val k = selected.indexOf(h)
if (k < 0) selected.add(h) else selected.remove(h)
notifyDataSetChanged()
}
init {
cache.setListener(this)
cache.setCheckmarkCount(
MAX_CHECKMARK_COUNT
)
cache.secondaryOrder = preferences.defaultSecondaryOrder
cache.primaryOrder = preferences.defaultPrimaryOrder
setHasStableIds(true)
}
}

@ -39,12 +39,10 @@ class HabitCardListController @Inject constructor(
private val selectionMenu: Lazy<ListHabitsSelectionMenu> private val selectionMenu: Lazy<ListHabitsSelectionMenu>
) : HabitCardListView.Controller, ModelObservable.Listener { ) : HabitCardListView.Controller, ModelObservable.Listener {
private val NORMAL_MODE = NormalMode()
private val SELECTION_MODE = SelectionMode()
private var activeMode: Mode private var activeMode: Mode
init { init {
this.activeMode = NORMAL_MODE this.activeMode = NormalMode()
adapter.observable.addListener(this) adapter.observable.addListener(this)
} }
@ -83,9 +81,9 @@ class HabitCardListController @Inject constructor(
activeMode.startDrag(position) activeMode.startDrag(position)
} }
protected fun toggleSelection(position: Int) { private fun toggleSelection(position: Int) {
adapter.toggleSelection(position) adapter.toggleSelection(position)
activeMode = if (adapter.isSelectionEmpty) NORMAL_MODE else SELECTION_MODE activeMode = if (adapter.isSelectionEmpty) NormalMode() else SelectionMode()
} }
private fun cancelSelection() { private fun cancelSelection() {
@ -116,8 +114,7 @@ class HabitCardListController @Inject constructor(
*/ */
internal inner class NormalMode : Mode { internal inner class NormalMode : Mode {
override fun onItemClick(position: Int) { override fun onItemClick(position: Int) {
val habit = adapter.getItem(position) val habit = adapter.getItem(position) ?: return
if (habit == null) return
behavior.onClickHabit(habit) behavior.onClickHabit(habit)
} }
@ -130,9 +127,9 @@ class HabitCardListController @Inject constructor(
startSelection(position) startSelection(position)
} }
protected fun startSelection(position: Int) { private fun startSelection(position: Int) {
toggleSelection(position) toggleSelection(position)
activeMode = SELECTION_MODE activeMode = SelectionMode()
selectionMenu.get().onSelectionStart() selectionMenu.get().onSelectionStart()
} }
} }
@ -158,8 +155,8 @@ class HabitCardListController @Inject constructor(
notifyListener() notifyListener()
} }
protected fun notifyListener() { private fun notifyListener() {
if (activeMode === SELECTION_MODE) if (activeMode === SelectionMode())
selectionMenu.get().onSelectionChange() selectionMenu.get().onSelectionChange()
else else
selectionMenu.get().onSelectionFinish() selectionMenu.get().onSelectionFinish()

@ -170,22 +170,22 @@ class HabitCardListView(
inner class TouchHelperCallback : ItemTouchHelper.Callback() { inner class TouchHelperCallback : ItemTouchHelper.Callback() {
override fun getMovementFlags( override fun getMovementFlags(
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder viewHolder: ViewHolder
): Int { ): Int {
return makeMovementFlags(UP or DOWN, START or END) return makeMovementFlags(UP or DOWN, START or END)
} }
override fun onMove( override fun onMove(
recyclerView: RecyclerView, recyclerView: RecyclerView,
from: RecyclerView.ViewHolder, from: ViewHolder,
to: RecyclerView.ViewHolder to: ViewHolder
): Boolean { ): Boolean {
controller.get().drop(from.adapterPosition, to.adapterPosition) controller.get().drop(from.adapterPosition, to.adapterPosition)
return true return true
} }
override fun onSwiped( override fun onSwiped(
viewHolder: RecyclerView.ViewHolder, viewHolder: ViewHolder,
direction: Int direction: Int
) { ) {
} }

@ -59,8 +59,8 @@ class HabitCardViewFactory
class HabitCardView( class HabitCardView(
@ActivityContext context: Context, @ActivityContext context: Context,
private val checkmarkPanelFactory: CheckmarkPanelViewFactory, checkmarkPanelFactory: CheckmarkPanelViewFactory,
private val numberPanelFactory: NumberPanelViewFactory, numberPanelFactory: NumberPanelViewFactory,
private val behavior: ListHabitsBehavior private val behavior: ListHabitsBehavior
) : FrameLayout(context), ) : FrameLayout(context),
ModelObservable.Listener { ModelObservable.Listener {
@ -174,7 +174,7 @@ class HabitCardView(
} }
clipToPadding = false clipToPadding = false
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT)
val margin = dp(3f).toInt() val margin = dp(3f).toInt()
setPadding(margin, 0, margin, margin) setPadding(margin, 0, margin, margin)
addView(innerFrame) addView(innerFrame)

@ -71,7 +71,7 @@ class NumberPanelView(
setupButtons() setupButtons()
} }
override fun createButton() = buttonFactory.create()!! override fun createButton() = buttonFactory.create()
@Synchronized @Synchronized
override fun setupButtons() { override fun setupButtons() {

@ -27,6 +27,7 @@ import org.isoron.uhabits.core.ui.screens.habits.show.views.OverviewCardState
import org.isoron.uhabits.databinding.ShowHabitOverviewBinding import org.isoron.uhabits.databinding.ShowHabitOverviewBinding
import org.isoron.uhabits.utils.StyledResources import org.isoron.uhabits.utils.StyledResources
import org.isoron.uhabits.utils.toThemedAndroidColor import org.isoron.uhabits.utils.toThemedAndroidColor
import kotlin.math.abs
class OverviewCardView(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) { class OverviewCardView(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) {
@ -36,7 +37,7 @@ class OverviewCardView(context: Context, attrs: AttributeSet) : LinearLayout(con
return String.format( return String.format(
"%s%.0f%%", "%s%.0f%%",
if (percentageDiff >= 0) "+" else "\u2212", if (percentageDiff >= 0) "+" else "\u2212",
Math.abs(percentageDiff) * 100 abs(percentageDiff) * 100
) )
} }

@ -1,236 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.settings;
import android.app.backup.*;
import android.content.*;
import android.net.*;
import android.os.*;
import android.provider.*;
import android.util.*;
import androidx.annotation.*;
import androidx.preference.*;
import org.isoron.uhabits.R;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.ui.*;
import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.intents.*;
import org.isoron.uhabits.notifications.*;
import org.isoron.uhabits.widgets.*;
import java.util.*;
import static android.media.RingtoneManager.*;
import static android.os.Build.VERSION.*;
import static org.isoron.uhabits.activities.habits.list.ListHabitsScreenKt.*;
public class SettingsFragment extends PreferenceFragmentCompat
implements SharedPreferences.OnSharedPreferenceChangeListener
{
private static int RINGTONE_REQUEST_CODE = 1;
private SharedPreferences sharedPrefs;
private RingtoneManager ringtoneManager;
@NonNull
private Preferences prefs;
@Nullable
private WidgetUpdater widgetUpdater;
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data)
{
if (requestCode == RINGTONE_REQUEST_CODE)
{
ringtoneManager.update(data);
updateRingtoneDescription();
return;
}
super.onActivityResult(requestCode, resultCode, data);
}
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
addPreferencesFromResource(R.xml.preferences);
Context appContext = getContext().getApplicationContext();
if (appContext instanceof HabitsApplication)
{
HabitsApplication app = (HabitsApplication) appContext;
prefs = app.getComponent().getPreferences();
widgetUpdater = app.getComponent().getWidgetUpdater();
}
setResultOnPreferenceClick("importData", RESULT_IMPORT_DATA);
setResultOnPreferenceClick("exportCSV", RESULT_EXPORT_CSV);
setResultOnPreferenceClick("exportDB", RESULT_EXPORT_DB);
setResultOnPreferenceClick("repairDB", RESULT_REPAIR_DB);
setResultOnPreferenceClick("bugReport", RESULT_BUG_REPORT);
}
@Override
public void onCreatePreferences(Bundle bundle, String s)
{
// NOP
}
@Override
public void onPause()
{
sharedPrefs.unregisterOnSharedPreferenceChangeListener(this);
super.onPause();
}
@Override
public boolean onPreferenceTreeClick(Preference preference)
{
String key = preference.getKey();
if (key == null) return false;
if (key.equals("reminderSound"))
{
showRingtonePicker();
return true;
}
else if (key.equals("reminderCustomize"))
{
if (SDK_INT < Build.VERSION_CODES.O) return true;
AndroidNotificationTray.Companion.createAndroidNotificationChannel(getContext());
Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS);
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName());
intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationTray.REMINDERS_CHANNEL_ID);
startActivity(intent);
return true;
}
else if (key.equals("pref_sync_enabled_dummy"))
{
if (prefs.isSyncEnabled())
{
prefs.disableSync();
}
else
{
Context context = getActivity();
context.startActivity(new IntentFactory().startSyncActivity(context));
}
}
return super.onPreferenceTreeClick(preference);
}
@Override
public void onResume()
{
super.onResume();
this.ringtoneManager = new RingtoneManager(getActivity());
sharedPrefs = getPreferenceManager().getSharedPreferences();
sharedPrefs.registerOnSharedPreferenceChangeListener(this);
if (!prefs.isDeveloper())
{
PreferenceCategory devCategory =
(PreferenceCategory) findPreference("devCategory");
devCategory.setVisible(false);
}
updateWeekdayPreference();
updateSyncPreferences();
// Temporarily disable this; we now always ask
findPreference("reminderSound").setVisible(false);
findPreference("pref_snooze_interval").setVisible(false);
}
private void updateSyncPreferences()
{
findPreference("pref_sync_display").setVisible(prefs.isSyncEnabled());
((CheckBoxPreference) findPreference("pref_sync_enabled_dummy")).setChecked(prefs.isSyncEnabled());
}
private void updateWeekdayPreference()
{
ListPreference weekdayPref = (ListPreference) findPreference("pref_first_weekday");
int currentFirstWeekday = prefs.getFirstWeekday().getDaysSinceSunday() + 1;
String[] dayNames = DateUtils.getLongWeekdayNames(Calendar.SATURDAY);
String[] dayValues = {"7", "1", "2", "3", "4", "5", "6"};
weekdayPref.setEntries(dayNames);
weekdayPref.setEntryValues(dayValues);
weekdayPref.setDefaultValue(Integer.toString(currentFirstWeekday));
weekdayPref.setSummary(dayNames[currentFirstWeekday % 7]);
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences,
String key)
{
if (key.equals("pref_widget_opacity") && widgetUpdater != null)
{
Log.d("SettingsFragment", "updating widgets");
widgetUpdater.updateWidgets();
}
BackupManager.dataChanged("org.isoron.uhabits");
updateWeekdayPreference();
updateSyncPreferences();
}
private void setResultOnPreferenceClick(String key, final int result)
{
Preference pref = findPreference(key);
pref.setOnPreferenceClickListener(preference ->
{
getActivity().setResult(result);
getActivity().finish();
return true;
});
}
private void showRingtonePicker()
{
Uri existingRingtoneUri = ringtoneManager.getURI();
Uri defaultRingtoneUri = Settings.System.DEFAULT_NOTIFICATION_URI;
Intent intent = new Intent(ACTION_RINGTONE_PICKER);
intent.putExtra(EXTRA_RINGTONE_TYPE, TYPE_NOTIFICATION);
intent.putExtra(EXTRA_RINGTONE_SHOW_DEFAULT, true);
intent.putExtra(EXTRA_RINGTONE_SHOW_SILENT, true);
intent.putExtra(EXTRA_RINGTONE_DEFAULT_URI, defaultRingtoneUri);
intent.putExtra(EXTRA_RINGTONE_EXISTING_URI, existingRingtoneUri);
startActivityForResult(intent, RINGTONE_REQUEST_CODE);
}
private void updateRingtoneDescription()
{
String ringtoneName = ringtoneManager.getName();
if (ringtoneName == null) return;
Preference ringtonePreference = findPreference("reminderSound");
ringtonePreference.setSummary(ringtoneName);
}
}

@ -0,0 +1,201 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.activities.settings
import android.app.backup.BackupManager
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Build
import android.os.Build.VERSION
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.habits.list.RESULT_BUG_REPORT
import org.isoron.uhabits.activities.habits.list.RESULT_EXPORT_CSV
import org.isoron.uhabits.activities.habits.list.RESULT_EXPORT_DB
import org.isoron.uhabits.activities.habits.list.RESULT_IMPORT_DATA
import org.isoron.uhabits.activities.habits.list.RESULT_REPAIR_DB
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.ui.NotificationTray
import org.isoron.uhabits.core.utils.DateUtils.Companion.getLongWeekdayNames
import org.isoron.uhabits.intents.IntentFactory
import org.isoron.uhabits.notifications.AndroidNotificationTray.Companion.createAndroidNotificationChannel
import org.isoron.uhabits.notifications.RingtoneManager
import org.isoron.uhabits.widgets.WidgetUpdater
import java.util.Calendar
class SettingsFragment : PreferenceFragmentCompat(), OnSharedPreferenceChangeListener {
private var sharedPrefs: SharedPreferences? = null
private var ringtoneManager: RingtoneManager? = null
private lateinit var prefs: Preferences
private var widgetUpdater: WidgetUpdater? = null
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == RINGTONE_REQUEST_CODE) {
ringtoneManager!!.update(data)
updateRingtoneDescription()
return
}
super.onActivityResult(requestCode, resultCode, data)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addPreferencesFromResource(R.xml.preferences)
val appContext = context!!.applicationContext
if (appContext is HabitsApplication) {
prefs = appContext.component.preferences
widgetUpdater = appContext.component.widgetUpdater
}
setResultOnPreferenceClick("importData", RESULT_IMPORT_DATA)
setResultOnPreferenceClick("exportCSV", RESULT_EXPORT_CSV)
setResultOnPreferenceClick("exportDB", RESULT_EXPORT_DB)
setResultOnPreferenceClick("repairDB", RESULT_REPAIR_DB)
setResultOnPreferenceClick("bugReport", RESULT_BUG_REPORT)
}
override fun onCreatePreferences(bundle: Bundle, s: String) {
// NOP
}
override fun onPause() {
sharedPrefs!!.unregisterOnSharedPreferenceChangeListener(this)
super.onPause()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
val key = preference.key ?: return false
if (key == "reminderSound") {
showRingtonePicker()
return true
} else if (key == "reminderCustomize") {
if (VERSION.SDK_INT < Build.VERSION_CODES.O) return true
createAndroidNotificationChannel(context!!)
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
intent.putExtra(Settings.EXTRA_APP_PACKAGE, context!!.packageName)
intent.putExtra(Settings.EXTRA_CHANNEL_ID, NotificationTray.REMINDERS_CHANNEL_ID)
startActivity(intent)
return true
} else if (key == "pref_sync_enabled_dummy") {
if (prefs.isSyncEnabled) {
prefs.disableSync()
} else {
val context: Context? = activity
context!!.startActivity(IntentFactory().startSyncActivity(context))
}
}
return super.onPreferenceTreeClick(preference)
}
override fun onResume() {
super.onResume()
ringtoneManager = RingtoneManager(activity!!)
sharedPrefs = preferenceManager.sharedPreferences
sharedPrefs!!.registerOnSharedPreferenceChangeListener(this)
if (!prefs.isDeveloper) {
val devCategory = findPreference("devCategory") as PreferenceCategory
devCategory.isVisible = false
}
updateWeekdayPreference()
updateSyncPreferences()
// Temporarily disable this; we now always ask
findPreference("reminderSound").isVisible = false
findPreference("pref_snooze_interval").isVisible = false
}
private fun updateSyncPreferences() {
findPreference("pref_sync_display").isVisible = prefs.isSyncEnabled
(findPreference("pref_sync_enabled_dummy") as CheckBoxPreference).isChecked =
prefs.isSyncEnabled
}
private fun updateWeekdayPreference() {
val weekdayPref = findPreference("pref_first_weekday") as ListPreference
val currentFirstWeekday = prefs.firstWeekday.daysSinceSunday + 1
val dayNames = getLongWeekdayNames(Calendar.SATURDAY)
val dayValues = arrayOf("7", "1", "2", "3", "4", "5", "6")
weekdayPref.entries = dayNames
weekdayPref.entryValues = dayValues
weekdayPref.setDefaultValue(currentFirstWeekday.toString())
weekdayPref.summary = dayNames[currentFirstWeekday % 7]
}
override fun onSharedPreferenceChanged(
sharedPreferences: SharedPreferences,
key: String
) {
if (key == "pref_widget_opacity" && widgetUpdater != null) {
Log.d("SettingsFragment", "updating widgets")
widgetUpdater!!.updateWidgets()
}
BackupManager.dataChanged("org.isoron.uhabits")
updateWeekdayPreference()
updateSyncPreferences()
}
private fun setResultOnPreferenceClick(key: String, result: Int) {
val pref = findPreference(key)
pref.onPreferenceClickListener =
Preference.OnPreferenceClickListener {
activity!!.setResult(result)
activity!!.finish()
true
}
}
private fun showRingtonePicker() {
val existingRingtoneUri = ringtoneManager!!.getURI()
val defaultRingtoneUri = Settings.System.DEFAULT_NOTIFICATION_URI
val intent = Intent(android.media.RingtoneManager.ACTION_RINGTONE_PICKER)
intent.putExtra(
android.media.RingtoneManager.EXTRA_RINGTONE_TYPE,
android.media.RingtoneManager.TYPE_NOTIFICATION
)
intent.putExtra(android.media.RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true)
intent.putExtra(android.media.RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true)
intent.putExtra(
android.media.RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI,
defaultRingtoneUri
)
intent.putExtra(
android.media.RingtoneManager.EXTRA_RINGTONE_EXISTING_URI,
existingRingtoneUri
)
startActivityForResult(intent, RINGTONE_REQUEST_CODE)
}
private fun updateRingtoneDescription() {
val ringtoneName = ringtoneManager!!.getName() ?: return
val ringtonePreference = findPreference("reminderSound")
ringtonePreference.summary = ringtoneName
}
companion object {
private const val RINGTONE_REQUEST_CODE = 1
}
}

@ -68,7 +68,7 @@ class SyncActivity : AppCompatActivity(), SyncBehavior.Screen {
title = resources.getString(R.string.device_sync), title = resources.getString(R.string.device_sync),
) )
binding.syncLink.setOnClickListener { copyToClipboard() } binding.syncLink.setOnClickListener { copyToClipboard() }
binding.instructions.setText(Html.fromHtml(resources.getString(R.string.sync_instructions))) binding.instructions.text = Html.fromHtml(resources.getString(R.string.sync_instructions))
setContentView(binding.root) setContentView(binding.root)
} }

@ -27,22 +27,22 @@ class AndroidCursor(private val cursor: android.database.Cursor) : Cursor {
override fun moveToNext() = cursor.moveToNext() override fun moveToNext() = cursor.moveToNext()
override fun getInt(index: Int): Int? { override fun getInt(index: Int): Int? {
if (cursor.isNull(index)) return null return if (cursor.isNull(index)) null
else return cursor.getInt(index) else cursor.getInt(index)
} }
override fun getLong(index: Int): Long? { override fun getLong(index: Int): Long? {
if (cursor.isNull(index)) return null return if (cursor.isNull(index)) null
else return cursor.getLong(index) else cursor.getLong(index)
} }
override fun getDouble(index: Int): Double? { override fun getDouble(index: Int): Double? {
if (cursor.isNull(index)) return null return if (cursor.isNull(index)) null
else return cursor.getDouble(index) else cursor.getDouble(index)
} }
override fun getString(index: Int): String? { override fun getString(index: Int): String? {
if (cursor.isNull(index)) return null return if (cursor.isNull(index)) null
else return cursor.getString(index) else cursor.getString(index)
} }
} }

@ -51,7 +51,7 @@ class AndroidDatabase(
return db.update(tableName, contValues, where, params) return db.update(tableName, contValues, where, params)
} }
override fun insert(tableName: String, values: Map<String, Any?>): Long? { override fun insert(tableName: String, values: Map<String, Any?>): Long {
val contValues = mapToContentValues(values) val contValues = mapToContentValues(values)
return db.insert(tableName, null, contValues) return db.insert(tableName, null, contValues)
} }

@ -16,28 +16,14 @@
* You should have received a copy of the GNU General Public License along * You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.isoron.uhabits.inject
package org.isoron.uhabits.inject; import android.content.Context
import dagger.Module
import android.content.Context; import dagger.Provides
import dagger.Module;
import dagger.Provides;
@Module @Module
public class AppContextModule class ActivityContextModule(
{ @get:Provides
private final Context context; @get:ActivityContext val context: Context
)
public AppContextModule(@AppContext Context context)
{
this.context = context;
}
@Provides
@AppContext
Context getContext()
{
return context;
}
}

@ -16,29 +16,15 @@
* You should have received a copy of the GNU General Public License along * You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.isoron.uhabits.inject
package org.isoron.uhabits.inject; import android.content.Context
import dagger.Module
import dagger.Provides
import android.content.Context;
import dagger.Module;
import dagger.Provides;
@Module @Module
public class ActivityContextModule class AppContextModule(
{ @get:Provides
private Context context; @get:AppContext
@param:AppContext val context: Context
public ActivityContextModule(Context context) )
{
this.context = context;
}
@Provides
@ActivityContext
public Context getContext()
{
return context;
}
}

@ -32,13 +32,8 @@ import org.isoron.uhabits.core.ui.screens.habits.list.ListHabitsBehavior
@ActivityScope @ActivityScope
@Component( @Component(
modules = arrayOf( modules = [ActivityContextModule::class, HabitsActivityModule::class, ListHabitsModule::class, HabitModule::class],
ActivityContextModule::class, dependencies = [HabitsApplicationComponent::class]
HabitsActivityModule::class,
ListHabitsModule::class,
HabitModule::class
),
dependencies = arrayOf(HabitsApplicationComponent::class)
) )
interface HabitsActivityComponent { interface HabitsActivityComponent {
val colorPickerDialogFactory: ColorPickerDialogFactory val colorPickerDialogFactory: ColorPickerDialogFactory

@ -1,88 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.inject;
import android.content.*;
import org.isoron.uhabits.core.*;
import org.isoron.uhabits.core.commands.*;
import org.isoron.uhabits.core.io.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.reminders.*;
import org.isoron.uhabits.core.sync.*;
import org.isoron.uhabits.core.tasks.*;
import org.isoron.uhabits.core.ui.*;
import org.isoron.uhabits.core.ui.screens.habits.list.*;
import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.intents.*;
import org.isoron.uhabits.receivers.*;
import org.isoron.uhabits.tasks.*;
import org.isoron.uhabits.widgets.*;
import dagger.*;
@AppScope
@Component(modules = {
AppContextModule.class,
HabitsModule.class,
AndroidTaskRunner.class,
})
public interface HabitsApplicationComponent
{
CommandRunner getCommandRunner();
@AppContext
Context getContext();
GenericImporter getGenericImporter();
HabitCardListCache getHabitCardListCache();
HabitList getHabitList();
IntentFactory getIntentFactory();
IntentParser getIntentParser();
Logging getLogging();
MidnightTimer getMidnightTimer();
ModelFactory getModelFactory();
NotificationTray getNotificationTray();
PendingIntentFactory getPendingIntentFactory();
Preferences getPreferences();
ReminderScheduler getReminderScheduler();
ReminderController getReminderController();
TaskRunner getTaskRunner();
WidgetPreferences getWidgetPreferences();
WidgetUpdater getWidgetUpdater();
SyncManager getSyncManager();
}

@ -0,0 +1,68 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.inject
import android.content.Context
import dagger.Component
import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.io.GenericImporter
import org.isoron.uhabits.core.io.Logging
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.preferences.WidgetPreferences
import org.isoron.uhabits.core.reminders.ReminderScheduler
import org.isoron.uhabits.core.sync.SyncManager
import org.isoron.uhabits.core.tasks.TaskRunner
import org.isoron.uhabits.core.ui.NotificationTray
import org.isoron.uhabits.core.ui.screens.habits.list.HabitCardListCache
import org.isoron.uhabits.core.utils.MidnightTimer
import org.isoron.uhabits.intents.IntentFactory
import org.isoron.uhabits.intents.IntentParser
import org.isoron.uhabits.intents.PendingIntentFactory
import org.isoron.uhabits.receivers.ReminderController
import org.isoron.uhabits.tasks.AndroidTaskRunner
import org.isoron.uhabits.widgets.WidgetUpdater
@AppScope
@Component(modules = [AppContextModule::class, HabitsModule::class, AndroidTaskRunner::class])
interface HabitsApplicationComponent {
val commandRunner: CommandRunner
@get:AppContext
val context: Context
val genericImporter: GenericImporter
val habitCardListCache: HabitCardListCache
val habitList: HabitList
val intentFactory: IntentFactory
val intentParser: IntentParser
val logging: Logging
val midnightTimer: MidnightTimer
val modelFactory: ModelFactory
val notificationTray: NotificationTray
val pendingIntentFactory: PendingIntentFactory
val preferences: Preferences
val reminderScheduler: ReminderScheduler
val reminderController: ReminderController
val taskRunner: TaskRunner
val widgetPreferences: WidgetPreferences
val widgetUpdater: WidgetUpdater
val syncManager: SyncManager
}

@ -44,9 +44,8 @@ class IntentParser
} }
private fun parseHabit(uri: Uri): Habit { private fun parseHabit(uri: Uri): Habit {
val habit = habits.getById(parseId(uri)) return habits.getById(parseId(uri))
?: throw IllegalArgumentException("habit not found") ?: throw IllegalArgumentException("habit not found")
return habit
} }
private fun parseTimestamp(intent: Intent): Timestamp { private fun parseTimestamp(intent: Intent): Timestamp {

@ -36,6 +36,7 @@ import org.isoron.uhabits.core.utils.DateFormats
import org.isoron.uhabits.inject.AppContext import org.isoron.uhabits.inject.AppContext
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min
@AppScope @AppScope
class IntentScheduler class IntentScheduler
@ -84,7 +85,7 @@ class IntentScheduler
} }
private fun logReminderScheduled(habit: Habit, reminderTime: Long) { private fun logReminderScheduled(habit: Habit, reminderTime: Long) {
val min = Math.min(5, habit.name.length) val min = min(5, habit.name.length)
val name = habit.name.substring(0, min) val name = habit.name.substring(0, min)
val df = DateFormats.getBackupDateFormat() val df = DateFormats.getBackupDateFormat()
val time = df.format(Date(reminderTime)) val time = df.format(Date(reminderTime))

@ -41,7 +41,7 @@ class PendingIntentFactory
) { ) {
fun addCheckmark(habit: Habit, timestamp: Timestamp?): PendingIntent = fun addCheckmark(habit: Habit, timestamp: Timestamp?): PendingIntent =
PendingIntent.getBroadcast( getBroadcast(
context, context,
1, 1,
Intent(context, WidgetReceiver::class.java).apply { Intent(context, WidgetReceiver::class.java).apply {
@ -53,7 +53,7 @@ class PendingIntentFactory
) )
fun dismissNotification(habit: Habit): PendingIntent = fun dismissNotification(habit: Habit): PendingIntent =
PendingIntent.getBroadcast( getBroadcast(
context, context,
0, 0,
Intent(context, ReminderReceiver::class.java).apply { Intent(context, ReminderReceiver::class.java).apply {
@ -64,7 +64,7 @@ class PendingIntentFactory
) )
fun removeRepetition(habit: Habit): PendingIntent = fun removeRepetition(habit: Habit): PendingIntent =
PendingIntent.getBroadcast( getBroadcast(
context, context,
3, 3,
Intent(context, WidgetReceiver::class.java).apply { Intent(context, WidgetReceiver::class.java).apply {
@ -90,7 +90,7 @@ class PendingIntentFactory
reminderTime: Long?, reminderTime: Long?,
timestamp: Long timestamp: Long
): PendingIntent = ): PendingIntent =
PendingIntent.getBroadcast( getBroadcast(
context, context,
(habit.id!! % Integer.MAX_VALUE).toInt() + 1, (habit.id!! % Integer.MAX_VALUE).toInt() + 1,
Intent(context, ReminderReceiver::class.java).apply { Intent(context, ReminderReceiver::class.java).apply {
@ -103,7 +103,7 @@ class PendingIntentFactory
) )
fun snoozeNotification(habit: Habit): PendingIntent = fun snoozeNotification(habit: Habit): PendingIntent =
PendingIntent.getBroadcast( getBroadcast(
context, context,
0, 0,
Intent(context, ReminderReceiver::class.java).apply { Intent(context, ReminderReceiver::class.java).apply {
@ -114,7 +114,7 @@ class PendingIntentFactory
) )
fun toggleCheckmark(habit: Habit, timestamp: Long?): PendingIntent = fun toggleCheckmark(habit: Habit, timestamp: Long?): PendingIntent =
PendingIntent.getBroadcast( getBroadcast(
context, context,
2, 2,
Intent(context, WidgetReceiver::class.java).apply { Intent(context, WidgetReceiver::class.java).apply {
@ -145,7 +145,7 @@ class PendingIntentFactory
) )
fun updateWidgets(): PendingIntent = fun updateWidgets(): PendingIntent =
PendingIntent.getBroadcast( getBroadcast(
context, context,
0, 0,
Intent(context, WidgetReceiver::class.java).apply { Intent(context, WidgetReceiver::class.java).apply {

@ -40,17 +40,17 @@ class RingtoneManager
PreferenceManager.getDefaultSharedPreferences(context) PreferenceManager.getDefaultSharedPreferences(context)
fun getName(): String? { fun getName(): String? {
try { return try {
var ringtoneName = context.resources.getString(R.string.none) var ringtoneName = context.resources.getString(R.string.none)
val ringtoneUri = getURI() val ringtoneUri = getURI()
if (ringtoneUri != null) { if (ringtoneUri != null) {
val ringtone = getRingtone(context, ringtoneUri) val ringtone = getRingtone(context, ringtoneUri)
if (ringtone != null) ringtoneName = ringtone.getTitle(context) if (ringtone != null) ringtoneName = ringtone.getTitle(context)
} }
return ringtoneName ringtoneName
} catch (e: RuntimeException) { } catch (e: RuntimeException) {
e.printStackTrace() e.printStackTrace()
return null null
} }
} }

@ -80,8 +80,7 @@ class RemoteSyncServer(
try { try {
val url = "${preferences.syncBaseURL}/db/$key" val url = "${preferences.syncBaseURL}/db/$key"
Log.i("RemoteSyncServer", "GET $url") Log.i("RemoteSyncServer", "GET $url")
val data: SyncData = httpClient.get(url) return@IO httpClient.get<SyncData>(url)
return@IO data
} catch (e: ServerResponseException) { } catch (e: ServerResponseException) {
throw ServiceUnavailable() throw ServiceUnavailable()
} catch (e: ClientRequestException) { } catch (e: ClientRequestException) {

@ -1,93 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils;
import android.content.*;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.util.*;
import org.jetbrains.annotations.*;
public class AttributeSetUtils
{
public static final String ISORON_NAMESPACE = "http://isoron.org/android";
@Nullable
public static String getAttribute(@NonNull Context context,
@NonNull AttributeSet attrs,
@NonNull String name,
@Nullable 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 boolean getBooleanAttribute(@NonNull Context context,
@NonNull AttributeSet attrs,
@NonNull String name,
boolean defaultValue)
{
String boolText = getAttribute(context, attrs, name, null);
if (boolText != null) return Boolean.parseBoolean(boolText);
else return defaultValue;
}
@Contract("_,_,_,!null -> !null")
public static Integer getColorAttribute(@NonNull Context context,
@NonNull AttributeSet attrs,
@NonNull String name,
@Nullable Integer defaultValue)
{
int resId = attrs.getAttributeResourceValue(ISORON_NAMESPACE, name, 0);
if (resId != 0) return context.getResources().getColor(resId);
else return defaultValue;
}
public static float getFloatAttribute(@NonNull Context context,
@NonNull AttributeSet attrs,
@NonNull String name,
float defaultValue)
{
try
{
String number = getAttribute(context, attrs, name, null);
if (number != null) return Float.parseFloat(number);
else return defaultValue;
} catch(NumberFormatException e) {
return defaultValue;
}
}
public static int getIntAttribute(@NonNull Context context,
@NonNull AttributeSet attrs,
@NonNull String name,
int defaultValue)
{
String number = getAttribute(context, attrs, name, null);
if (number != null) return Integer.parseInt(number);
else return defaultValue;
}
}

@ -0,0 +1,87 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
import android.content.Context
import android.util.AttributeSet
import org.jetbrains.annotations.Contract
object AttributeSetUtils {
const val ISORON_NAMESPACE = "http://isoron.org/android"
@JvmStatic
fun getAttribute(
context: Context,
attrs: AttributeSet,
name: String,
defaultValue: String?
): String? {
val resId = attrs.getAttributeResourceValue(ISORON_NAMESPACE, name, 0)
if (resId != 0) return context.resources.getString(resId)
val value = attrs.getAttributeValue(ISORON_NAMESPACE, name)
return value ?: defaultValue
}
@JvmStatic
fun getBooleanAttribute(
context: Context,
attrs: AttributeSet,
name: String,
defaultValue: Boolean
): Boolean {
val boolText = getAttribute(context, attrs, name, null)
return if (boolText != null) java.lang.Boolean.parseBoolean(boolText) else defaultValue
}
@JvmStatic
@Contract("_,_,_,!null -> !null")
fun getColorAttribute(
context: Context,
attrs: AttributeSet,
name: String,
defaultValue: Int?
): Int? {
val resId = attrs.getAttributeResourceValue(ISORON_NAMESPACE, name, 0)
return if (resId != 0) context.resources.getColor(resId) else defaultValue
}
@JvmStatic
fun getFloatAttribute(
context: Context,
attrs: AttributeSet,
name: String,
defaultValue: Float
): Float {
return try {
val number = getAttribute(context, attrs, name, null)
number?.toFloat() ?: defaultValue
} catch (e: NumberFormatException) {
defaultValue
}
}
fun getIntAttribute(
context: Context,
attrs: AttributeSet,
name: String,
defaultValue: Int
): Int {
val number = getAttribute(context, attrs, name, null)
return number?.toInt() ?: defaultValue
}
}

@ -1,92 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils;
import android.content.*;
import android.database.sqlite.*;
import android.util.*;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.utils.*;
import java.io.*;
import java.text.*;
import static org.isoron.uhabits.core.ConstantsKt.*;
public abstract class DatabaseUtils
{
@Nullable
private static HabitsDatabaseOpener opener = null;
@NonNull
public static File getDatabaseFile(Context context)
{
String databaseFilename = getDatabaseFilename();
String root = context.getFilesDir().getPath();
String format = "%s/../databases/%s";
String filename = String.format(format, root, databaseFilename);
return new File(filename);
}
@NonNull
public static String getDatabaseFilename()
{
String databaseFilename = DATABASE_FILENAME;
if (HabitsApplication.Companion.isTestMode()) databaseFilename = "test.db";
return databaseFilename;
}
@SuppressWarnings("unchecked")
public static void initializeDatabase(Context context)
{
opener = new HabitsDatabaseOpener(context, getDatabaseFilename(),
DATABASE_VERSION);
}
@SuppressWarnings("ResultOfMethodCallIgnored")
public static String saveDatabaseCopy(Context context, File dir)
throws IOException
{
SimpleDateFormat dateFormat = DateFormats.getBackupDateFormat();
String date = dateFormat.format(DateUtils.getLocalTime());
String format = "%s/Loop Habits Backup %s.db";
String filename = String.format(format, dir.getAbsolutePath(), date);
Log.i("DatabaseUtils", "Writing: " + filename);
File db = getDatabaseFile(context);
File dbCopy = new File(filename);
FileUtilsKt.copyTo(db, dbCopy);
return dbCopy.getAbsolutePath();
}
@NonNull
public static SQLiteDatabase openDatabase()
{
if (opener == null) throw new IllegalStateException();
return opener.getWritableDatabase();
}
}

@ -0,0 +1,75 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.util.Log
import org.isoron.uhabits.HabitsApplication.Companion.isTestMode
import org.isoron.uhabits.HabitsDatabaseOpener
import org.isoron.uhabits.core.DATABASE_FILENAME
import org.isoron.uhabits.core.DATABASE_VERSION
import org.isoron.uhabits.core.utils.DateFormats.Companion.getBackupDateFormat
import org.isoron.uhabits.core.utils.DateUtils.Companion.getLocalTime
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
object DatabaseUtils {
private var opener: HabitsDatabaseOpener? = null
@JvmStatic
fun getDatabaseFile(context: Context): File {
val databaseFilename = databaseFilename
val root = context.filesDir.path
return File("$root/../databases/$databaseFilename")
}
private val databaseFilename: String
get() {
var databaseFilename: String = DATABASE_FILENAME
if (isTestMode()) databaseFilename = "test.db"
return databaseFilename
}
fun initializeDatabase(context: Context?) {
opener = HabitsDatabaseOpener(
context!!,
databaseFilename,
DATABASE_VERSION
)
}
@JvmStatic
@Throws(IOException::class)
fun saveDatabaseCopy(context: Context, dir: File): String {
val dateFormat: SimpleDateFormat = getBackupDateFormat()
val date = dateFormat.format(getLocalTime())
val filename = "${dir.absolutePath}/Loop Habits Backup $date.db"
Log.i("DatabaseUtils", "Writing: $filename")
val db = getDatabaseFile(context)
val dbCopy = File(filename)
db.copyTo(dbCopy)
return dbCopy.absolutePath
}
fun openDatabase(): SQLiteDatabase {
checkNotNull(opener)
return opener!!.writableDatabase
}
}

@ -16,30 +16,24 @@
* You should have received a copy of the GNU General Public License along * You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>. * with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package org.isoron.uhabits.utils
package org.isoron.uhabits.utils; import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.os.Build
import android.view.WindowManager
import android.app.*; object SystemUtils {
import android.content.*; val isAndroidOOrLater: Boolean
import android.os.*; get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
import android.view.*;
fun unlockScreen(activity: Activity) {
public class SystemUtils if (isAndroidOOrLater) {
{ val km = activity.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
public static boolean isAndroidOOrLater() km.requestDismissKeyguard(activity, null)
{
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
}
public static void unlockScreen(Activity activity)
{
if (isAndroidOOrLater()) {
KeyguardManager km =
(KeyguardManager) activity.getSystemService(Context.KEYGUARD_SERVICE);
km.requestDismissKeyguard(activity, null);
} else { } else {
activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD); activity.window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD)
} }
} }
} }

@ -1,214 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets;
import android.app.*;
import android.content.*;
import android.graphics.*;
import android.view.*;
import android.widget.*;
import androidx.annotation.NonNull;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.commands.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.intents.*;
import static android.view.View.MeasureSpec.makeMeasureSpec;
public abstract class BaseWidget
{
private final int id;
@NonNull
protected final WidgetPreferences widgetPrefs;
@NonNull
protected final Preferences prefs;
@NonNull
protected final PendingIntentFactory pendingIntentFactory;
@NonNull
private final Context context;
@NonNull
protected final CommandRunner commandRunner;
@NonNull
private WidgetDimensions dimensions;
public BaseWidget(@NonNull Context context, int id)
{
this.id = id;
this.context = context;
HabitsApplication app =
(HabitsApplication) context.getApplicationContext();
widgetPrefs = app.getComponent().getWidgetPreferences();
prefs = app.getComponent().getPreferences();
commandRunner = app.getComponent().getCommandRunner();
pendingIntentFactory = app.getComponent().getPendingIntentFactory();
dimensions = new WidgetDimensions(getDefaultWidth(), getDefaultHeight(),
getDefaultWidth(), getDefaultHeight());
}
public void delete()
{
widgetPrefs.removeWidget(id);
}
@NonNull
public Context getContext()
{
return context;
}
public int getId()
{
return id;
}
@NonNull
public RemoteViews getLandscapeRemoteViews()
{
return getRemoteViews(dimensions.getLandscapeWidth(),
dimensions.getLandscapeHeight());
}
public abstract PendingIntent getOnClickPendingIntent(Context context);
@NonNull
public RemoteViews getPortraitRemoteViews()
{
return getRemoteViews(dimensions.getPortraitWidth(),
dimensions.getPortraitHeight());
}
public abstract void refreshData(View widgetView);
public void setDimensions(@NonNull WidgetDimensions dimensions)
{
this.dimensions = dimensions;
}
protected abstract View buildView();
protected abstract int getDefaultHeight();
protected abstract int getDefaultWidth();
private void adjustRemoteViewsPadding(RemoteViews remoteViews,
View view,
int width,
int height)
{
int imageWidth = view.getMeasuredWidth();
int imageHeight = view.getMeasuredHeight();
int p[] = calculatePadding(width, height, imageWidth, imageHeight);
remoteViews.setViewPadding(R.id.buttonOverlay, p[0], p[1], p[2], p[3]);
}
private void buildRemoteViews(View view,
RemoteViews remoteViews,
int width,
int height)
{
Bitmap bitmap = getBitmapFromView(view);
remoteViews.setImageViewBitmap(R.id.imageView, bitmap);
adjustRemoteViewsPadding(remoteViews, view, width, height);
PendingIntent onClickIntent = getOnClickPendingIntent(context);
if (onClickIntent != null)
remoteViews.setOnClickPendingIntent(R.id.button, onClickIntent);
}
private int[] calculatePadding(int entireWidth,
int entireHeight,
int imageWidth,
int imageHeight)
{
int w = (int) (((float) entireWidth - imageWidth) / 2);
int h = (int) (((float) entireHeight - imageHeight) / 2);
return new int[]{w, h, w, h};
}
@NonNull
private Bitmap getBitmapFromView(View view)
{
view.invalidate();
int width = view.getMeasuredWidth();
int height = view.getMeasuredHeight();
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
view.draw(canvas);
return bitmap;
}
@NonNull
protected RemoteViews getRemoteViews(int width, int height)
{
View view = buildView();
measureView(view, width, height);
refreshData(view);
if (view.isLayoutRequested()) measureView(view, width, height);
RemoteViews remoteViews =
new RemoteViews(context.getPackageName(), R.layout.widget_wrapper);
buildRemoteViews(view, remoteViews, width, height);
return remoteViews;
}
private void measureView(View view, int width, int height)
{
LayoutInflater inflater = LayoutInflater.from(context);
View entireView = inflater.inflate(R.layout.widget_wrapper, null);
int specWidth = makeMeasureSpec(width, View.MeasureSpec.EXACTLY);
int specHeight = makeMeasureSpec(height, View.MeasureSpec.EXACTLY);
entireView.measure(specWidth, specHeight);
entireView.layout(0, 0, entireView.getMeasuredWidth(),
entireView.getMeasuredHeight());
View imageView = entireView.findViewById(R.id.imageView);
width = imageView.getMeasuredWidth();
height = imageView.getMeasuredHeight();
specWidth = makeMeasureSpec(width, View.MeasureSpec.EXACTLY);
specHeight = makeMeasureSpec(height, View.MeasureSpec.EXACTLY);
view.measure(specWidth, specHeight);
view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight());
}
protected int getPreferedBackgroundAlpha() {
return prefs.getWidgetOpacity();
}
}

@ -0,0 +1,162 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets
import android.app.PendingIntent
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.view.LayoutInflater
import android.view.View
import android.view.View.MeasureSpec
import android.widget.RemoteViews
import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.preferences.WidgetPreferences
import org.isoron.uhabits.intents.PendingIntentFactory
abstract class BaseWidget(val context: Context, val id: Int) {
protected val widgetPrefs: WidgetPreferences
protected val prefs: Preferences
protected val pendingIntentFactory: PendingIntentFactory
protected val commandRunner: CommandRunner
private var dimensions: WidgetDimensions
fun delete() {
widgetPrefs.removeWidget(id)
}
val landscapeRemoteViews: RemoteViews
get() = getRemoteViews(
dimensions.landscapeWidth,
dimensions.landscapeHeight
)
abstract fun getOnClickPendingIntent(context: Context): PendingIntent?
val portraitRemoteViews: RemoteViews
get() = getRemoteViews(
dimensions.portraitWidth,
dimensions.portraitHeight
)
abstract fun refreshData(widgetView: View)
fun setDimensions(dimensions: WidgetDimensions) {
this.dimensions = dimensions
}
protected abstract fun buildView(): View?
protected abstract val defaultHeight: Int
protected abstract val defaultWidth: Int
private fun adjustRemoteViewsPadding(
remoteViews: RemoteViews,
view: View,
width: Int,
height: Int
) {
val imageWidth = view.measuredWidth
val imageHeight = view.measuredHeight
val p = calculatePadding(width, height, imageWidth, imageHeight)
remoteViews.setViewPadding(R.id.buttonOverlay, p[0], p[1], p[2], p[3])
}
private fun buildRemoteViews(
view: View,
remoteViews: RemoteViews,
width: Int,
height: Int
) {
val bitmap = getBitmapFromView(view)
remoteViews.setImageViewBitmap(R.id.imageView, bitmap)
adjustRemoteViewsPadding(remoteViews, view, width, height)
val onClickIntent = getOnClickPendingIntent(context)
if (onClickIntent != null) remoteViews.setOnClickPendingIntent(R.id.button, onClickIntent)
}
private fun calculatePadding(
entireWidth: Int,
entireHeight: Int,
imageWidth: Int,
imageHeight: Int
): IntArray {
val w = ((entireWidth.toFloat() - imageWidth) / 2).toInt()
val h = ((entireHeight.toFloat() - imageHeight) / 2).toInt()
return intArrayOf(w, h, w, h)
}
private fun getBitmapFromView(view: View): Bitmap {
view.invalidate()
val width = view.measuredWidth
val height = view.measuredHeight
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
view.draw(canvas)
return bitmap
}
protected open fun getRemoteViews(width: Int, height: Int): RemoteViews {
val view = buildView()!!
measureView(view, width, height)
refreshData(view)
if (view.isLayoutRequested) measureView(view, width, height)
val remoteViews = RemoteViews(context.packageName, R.layout.widget_wrapper)
buildRemoteViews(view, remoteViews, width, height)
return remoteViews
}
private fun measureView(view: View, width: Int, height: Int) {
var width = width
var height = height
val inflater = LayoutInflater.from(context)
val entireView = inflater.inflate(R.layout.widget_wrapper, null)
var specWidth = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
var specHeight = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
entireView.measure(specWidth, specHeight)
entireView.layout(
0,
0,
entireView.measuredWidth,
entireView.measuredHeight
)
val imageView = entireView.findViewById<View>(R.id.imageView)
width = imageView.measuredWidth
height = imageView.measuredHeight
specWidth = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
specHeight = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
view.measure(specWidth, specHeight)
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
}
protected val preferedBackgroundAlpha: Int
protected get() = prefs.widgetOpacity
init {
val app = context.applicationContext as HabitsApplication
widgetPrefs = app.component.widgetPreferences
prefs = app.component.preferences
commandRunner = app.component.commandRunner
pendingIntentFactory = app.component.pendingIntentFactory
dimensions = WidgetDimensions(
defaultWidth,
defaultHeight,
defaultWidth,
defaultHeight
)
}
}

@ -1,206 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets;
import android.appwidget.*;
import android.content.*;
import android.os.*;
import android.widget.*;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import java.util.*;
import static android.appwidget.AppWidgetManager.*;
import static org.isoron.uhabits.utils.InterfaceUtils.dpToPixels;
public abstract class BaseWidgetProvider extends AppWidgetProvider
{
private HabitList habits;
private Preferences preferences;
private WidgetPreferences widgetPrefs;
public static void updateAppWidget(@NonNull AppWidgetManager manager,
@NonNull BaseWidget widget)
{
RemoteViews landscape = widget.getLandscapeRemoteViews();
RemoteViews portrait = widget.getPortraitRemoteViews();
RemoteViews views = new RemoteViews(landscape, portrait);
manager.updateAppWidget(widget.getId(), views);
}
@NonNull
public WidgetDimensions getDimensionsFromOptions(@NonNull Context ctx,
@NonNull Bundle options)
{
int maxWidth =
(int) dpToPixels(ctx, options.getInt(OPTION_APPWIDGET_MAX_WIDTH));
int maxHeight =
(int) dpToPixels(ctx, options.getInt(OPTION_APPWIDGET_MAX_HEIGHT));
int minWidth =
(int) dpToPixels(ctx, options.getInt(OPTION_APPWIDGET_MIN_WIDTH));
int minHeight =
(int) dpToPixels(ctx, options.getInt(OPTION_APPWIDGET_MIN_HEIGHT));
return new WidgetDimensions(minWidth, maxHeight, maxWidth, minHeight);
}
@Override
public void onAppWidgetOptionsChanged(@Nullable Context context,
@Nullable AppWidgetManager manager,
int widgetId,
@Nullable Bundle options)
{
try
{
if (context == null) throw new RuntimeException("context is null");
if (manager == null) throw new RuntimeException("manager is null");
if (options == null) throw new RuntimeException("options is null");
updateDependencies(context);
context.setTheme(R.style.WidgetTheme);
BaseWidget widget = getWidgetFromId(context, widgetId);
WidgetDimensions dims = getDimensionsFromOptions(context, options);
widget.setDimensions(dims);
updateAppWidget(manager, widget);
}
catch (RuntimeException e)
{
drawErrorWidget(context, manager, widgetId, e);
e.printStackTrace();
}
}
@Override
public void onDeleted(@Nullable Context context, @Nullable int[] ids)
{
if (context == null) throw new RuntimeException("context is null");
if (ids == null) throw new RuntimeException("ids is null");
updateDependencies(context);
for (int id : ids)
{
try
{
BaseWidget widget = getWidgetFromId(context, id);
widget.delete();
}
catch (HabitNotFoundException e)
{
e.printStackTrace();
}
}
}
@Override
public void onUpdate(@Nullable Context context,
@Nullable AppWidgetManager manager,
@Nullable int[] widgetIds)
{
if (context == null) throw new RuntimeException("context is null");
if (manager == null) throw new RuntimeException("manager is null");
if (widgetIds == null) throw new RuntimeException("widgetIds is null");
updateDependencies(context);
context.setTheme(R.style.WidgetTheme);
new Thread(() ->
{
Looper.prepare();
for (int id : widgetIds)
update(context, manager, id);
}).start();
}
protected List<Habit> getHabitsFromWidgetId(int widgetId)
{
long selectedIds[] = widgetPrefs.getHabitIdsFromWidgetId(widgetId);
ArrayList<Habit> selectedHabits = new ArrayList<>(selectedIds.length);
for (long id : selectedIds)
{
Habit h = habits.getById(id);
if (h == null) throw new HabitNotFoundException();
selectedHabits.add(h);
}
return selectedHabits;
}
@NonNull
protected abstract BaseWidget getWidgetFromId(@NonNull Context context,
int id);
private void drawErrorWidget(Context context,
AppWidgetManager manager,
int widgetId,
RuntimeException e)
{
RemoteViews errorView =
new RemoteViews(context.getPackageName(), R.layout.widget_error);
if (e instanceof HabitNotFoundException)
{
errorView.setCharSequence(R.id.label, "setText",
context.getString(R.string.habit_not_found));
}
manager.updateAppWidget(widgetId, errorView);
}
private void update(@NonNull Context context,
@NonNull AppWidgetManager manager,
int widgetId)
{
try
{
BaseWidget widget = getWidgetFromId(context, widgetId);
Bundle options = manager.getAppWidgetOptions(widgetId);
widget.setDimensions(getDimensionsFromOptions(context, options));
updateAppWidget(manager, widget);
}
catch (RuntimeException e)
{
drawErrorWidget(context, manager, widgetId, e);
e.printStackTrace();
}
}
private void updateDependencies(Context context)
{
HabitsApplication app =
(HabitsApplication) context.getApplicationContext();
habits = app.getComponent().getHabitList();
preferences = app.getComponent().getPreferences();
widgetPrefs = app.getComponent().getWidgetPreferences();
}
public Preferences getPreferences()
{
return preferences;
}
}

@ -0,0 +1,177 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.os.Bundle
import android.os.Looper
import android.widget.RemoteViews
import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.HabitNotFoundException
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.preferences.WidgetPreferences
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
import java.util.ArrayList
abstract class BaseWidgetProvider : AppWidgetProvider() {
private lateinit var habits: HabitList
lateinit var preferences: Preferences
private set
private lateinit var widgetPrefs: WidgetPreferences
fun getDimensionsFromOptions(
ctx: Context,
options: Bundle
): WidgetDimensions {
val maxWidth = dpToPixels(
ctx,
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH).toFloat()
).toInt()
val maxHeight = dpToPixels(
ctx,
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT).toFloat()
).toInt()
val minWidth = dpToPixels(
ctx,
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH).toFloat()
).toInt()
val minHeight = dpToPixels(
ctx,
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT).toFloat()
).toInt()
return WidgetDimensions(minWidth, maxHeight, maxWidth, minHeight)
}
override fun onAppWidgetOptionsChanged(
context: Context,
manager: AppWidgetManager,
widgetId: Int,
options: Bundle
) {
try {
updateDependencies(context)
context.setTheme(R.style.WidgetTheme)
val widget = getWidgetFromId(context, widgetId)
val dims = getDimensionsFromOptions(context, options)
widget.setDimensions(dims)
updateAppWidget(manager, widget)
} catch (e: RuntimeException) {
drawErrorWidget(context, manager, widgetId, e)
e.printStackTrace()
}
}
override fun onDeleted(context: Context?, ids: IntArray?) {
if (context == null) throw RuntimeException("context is null")
if (ids == null) throw RuntimeException("ids is null")
updateDependencies(context)
for (id in ids) {
try {
val widget = getWidgetFromId(context, id)
widget.delete()
} catch (e: HabitNotFoundException) {
e.printStackTrace()
}
}
}
override fun onUpdate(
context: Context,
manager: AppWidgetManager,
widgetIds: IntArray
) {
updateDependencies(context)
context.setTheme(R.style.WidgetTheme)
Thread {
Looper.prepare()
for (id in widgetIds) update(context, manager, id)
}.start()
}
protected fun getHabitsFromWidgetId(widgetId: Int): List<Habit> {
val selectedIds = widgetPrefs.getHabitIdsFromWidgetId(widgetId)
val selectedHabits = ArrayList<Habit>(selectedIds.size)
for (id in selectedIds) {
val h = habits.getById(id) ?: throw HabitNotFoundException()
selectedHabits.add(h)
}
return selectedHabits
}
protected abstract fun getWidgetFromId(
context: Context,
id: Int
): BaseWidget
private fun drawErrorWidget(
context: Context,
manager: AppWidgetManager,
widgetId: Int,
e: RuntimeException
) {
val errorView = RemoteViews(context.packageName, R.layout.widget_error)
if (e is HabitNotFoundException) {
errorView.setCharSequence(
R.id.label,
"setText",
context.getString(R.string.habit_not_found)
)
}
manager.updateAppWidget(widgetId, errorView)
}
private fun update(
context: Context,
manager: AppWidgetManager,
widgetId: Int
) {
try {
val widget = getWidgetFromId(context, widgetId)
val options = manager.getAppWidgetOptions(widgetId)
widget.setDimensions(getDimensionsFromOptions(context, options))
updateAppWidget(manager, widget)
} catch (e: RuntimeException) {
drawErrorWidget(context, manager, widgetId, e)
e.printStackTrace()
}
}
private fun updateDependencies(context: Context) {
val app = context.applicationContext as HabitsApplication
habits = app.component.habitList
preferences = app.component.preferences
widgetPrefs = app.component.widgetPreferences
}
companion object {
fun updateAppWidget(
manager: AppWidgetManager,
widget: BaseWidget
) {
val landscape = widget.landscapeRemoteViews
val portrait = widget.portraitRemoteViews
val views = RemoteViews(landscape, portrait)
manager.updateAppWidget(widget.id, views)
}
}
}

@ -21,7 +21,9 @@ package org.isoron.uhabits.widgets
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build
import android.view.View import android.view.View
import androidx.annotation.RequiresApi
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.utils.DateUtils import org.isoron.uhabits.core.utils.DateUtils
@ -31,10 +33,13 @@ import org.isoron.uhabits.widgets.views.CheckmarkWidgetView
open class CheckmarkWidget( open class CheckmarkWidget(
context: Context, context: Context,
widgetId: Int, widgetId: Int,
protected val habit: Habit protected val habit: Habit,
) : BaseWidget(context, widgetId) { ) : BaseWidget(context, widgetId) {
override fun getOnClickPendingIntent(context: Context): PendingIntent { override val defaultHeight: Int = 125
override val defaultWidth: Int = 125
override fun getOnClickPendingIntent(context: Context): PendingIntent? {
return if (habit.isNumerical) { return if (habit.isNumerical) {
pendingIntentFactory.setNumericalValue(context, habit, 10, null) pendingIntentFactory.setNumericalValue(context, habit, 10, null)
} else { } else {
@ -42,20 +47,21 @@ open class CheckmarkWidget(
} }
} }
override fun refreshData(v: View) { @RequiresApi(Build.VERSION_CODES.O)
(v as CheckmarkWidgetView).apply { override fun refreshData(widgetView: View) {
(widgetView as CheckmarkWidgetView).apply {
val today = DateUtils.getTodayWithOffset() val today = DateUtils.getTodayWithOffset()
setBackgroundAlpha(preferedBackgroundAlpha) setBackgroundAlpha(preferedBackgroundAlpha)
setActiveColor(habit.color.toThemedAndroidColor(context)) activeColor = habit.color.toThemedAndroidColor(context)
setName(habit.name) name = habit.name
setEntryValue(habit.computedEntries.get(today).value) entryValue = habit.computedEntries.get(today).value
if (habit.isNumerical) { if (habit.isNumerical) {
setNumerical(true) isNumerical = true
setEntryState(getNumericalEntryState()) entryState = getNumericalEntryState()
} else { } else {
setEntryState(habit.computedEntries.get(today).value) entryState = habit.computedEntries.get(today).value
} }
setPercentage(habit.scores.get(today).value.toFloat()) percentage = habit.scores[today].value.toFloat()
refresh() refresh()
} }
} }
@ -64,9 +70,6 @@ open class CheckmarkWidget(
return CheckmarkWidgetView(context) return CheckmarkWidgetView(context)
} }
override fun getDefaultHeight() = 125
override fun getDefaultWidth() = 125
private fun getNumericalEntryState(): Int { private fun getNumericalEntryState(): Int {
return if (habit.isCompletedToday()) { return if (habit.isCompletedToday()) {
Entry.YES_MANUAL Entry.YES_MANUAL

@ -23,7 +23,7 @@ import android.content.Context
class CheckmarkWidgetProvider : BaseWidgetProvider() { class CheckmarkWidgetProvider : BaseWidgetProvider() {
override fun getWidgetFromId(context: Context, id: Int): BaseWidget { override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
val habits = getHabitsFromWidgetId(id) val habits = getHabitsFromWidgetId(id)
if (habits.size == 1) return CheckmarkWidget(context, id, habits[0]) return if (habits.size == 1) CheckmarkWidget(context, id, habits[0])
else return StackWidget(context, id, StackWidgetType.CHECKMARK, habits) else StackWidget(context, id, StackWidgetType.CHECKMARK, habits)
} }
} }

@ -19,18 +19,19 @@
package org.isoron.uhabits.widgets package org.isoron.uhabits.widgets
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import org.isoron.uhabits.widgets.views.EmptyWidgetView import org.isoron.uhabits.widgets.views.EmptyWidgetView
class EmptyWidget( class EmptyWidget(
context: Context, context: Context,
widgetId: Int widgetId: Int,
) : BaseWidget(context, widgetId) { ) : BaseWidget(context, widgetId) {
override val defaultHeight: Int = 200
override val defaultWidth: Int = 200
override fun getOnClickPendingIntent(context: Context) = null override fun getOnClickPendingIntent(context: Context): PendingIntent? = null
override fun refreshData(v: View) {} override fun refreshData(v: View) {}
override fun buildView() = EmptyWidgetView(context) override fun buildView() = EmptyWidgetView(context)
override fun getDefaultHeight() = 200
override fun getDefaultWidth() = 200
} }

@ -19,6 +19,7 @@
package org.isoron.uhabits.widgets package org.isoron.uhabits.widgets
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import org.isoron.uhabits.activities.common.views.FrequencyChart import org.isoron.uhabits.activities.common.views.FrequencyChart
@ -30,10 +31,12 @@ class FrequencyWidget(
context: Context, context: Context,
widgetId: Int, widgetId: Int,
private val habit: Habit, private val habit: Habit,
private val firstWeekday: Int private val firstWeekday: Int,
) : BaseWidget(context, widgetId) { ) : BaseWidget(context, widgetId) {
override val defaultHeight: Int = 200
override val defaultWidth: Int = 200
override fun getOnClickPendingIntent(context: Context) = override fun getOnClickPendingIntent(context: Context): PendingIntent =
pendingIntentFactory.showHabit(habit) pendingIntentFactory.showHabit(habit)
override fun refreshData(v: View) { override fun refreshData(v: View) {
@ -50,7 +53,4 @@ class FrequencyWidget(
override fun buildView() = override fun buildView() =
GraphWidgetView(context, FrequencyChart(context)) GraphWidgetView(context, FrequencyChart(context))
override fun getDefaultHeight() = 200
override fun getDefaultWidth() = 200
} }

@ -24,12 +24,12 @@ import android.content.Context
class FrequencyWidgetProvider : BaseWidgetProvider() { class FrequencyWidgetProvider : BaseWidgetProvider() {
override fun getWidgetFromId(context: Context, id: Int): BaseWidget { override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
val habits = getHabitsFromWidgetId(id) val habits = getHabitsFromWidgetId(id)
if (habits.size == 1) return FrequencyWidget( return if (habits.size == 1) FrequencyWidget(
context, context,
id, id,
habits[0], habits[0],
preferences.firstWeekdayInt preferences.firstWeekdayInt
) )
else return StackWidget(context, id, StackWidgetType.FREQUENCY, habits) else StackWidget(context, id, StackWidgetType.FREQUENCY, habits)
} }
} }

@ -35,9 +35,12 @@ import java.util.Locale
class HistoryWidget( class HistoryWidget(
context: Context, context: Context,
id: Int, id: Int,
private val habit: Habit private val habit: Habit,
) : BaseWidget(context, id) { ) : BaseWidget(context, id) {
override val defaultHeight: Int = 250
override val defaultWidth: Int = 250
override fun getOnClickPendingIntent(context: Context): PendingIntent { override fun getOnClickPendingIntent(context: Context): PendingIntent {
return pendingIntentFactory.showHabit(habit) return pendingIntentFactory.showHabit(habit)
} }
@ -72,7 +75,4 @@ class HistoryWidget(
).apply { ).apply {
setTitle(habit.name) setTitle(habit.name)
} }
override fun getDefaultHeight() = 250
override fun getDefaultWidth() = 250
} }

@ -23,11 +23,11 @@ import android.content.Context
class HistoryWidgetProvider : BaseWidgetProvider() { class HistoryWidgetProvider : BaseWidgetProvider() {
override fun getWidgetFromId(context: Context, id: Int): BaseWidget { override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
val habits = getHabitsFromWidgetId(id) val habits = getHabitsFromWidgetId(id)
if (habits.size == 1) return HistoryWidget( return if (habits.size == 1) HistoryWidget(
context, context,
id, id,
habits[0] habits[0]
) )
else return StackWidget(context, id, StackWidgetType.HISTORY, habits) else StackWidget(context, id, StackWidgetType.HISTORY, habits)
} }
} }

@ -19,6 +19,7 @@
package org.isoron.uhabits.widgets package org.isoron.uhabits.widgets
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import org.isoron.uhabits.activities.common.views.ScoreChart import org.isoron.uhabits.activities.common.views.ScoreChart
@ -30,10 +31,12 @@ import org.isoron.uhabits.widgets.views.GraphWidgetView
class ScoreWidget( class ScoreWidget(
context: Context, context: Context,
id: Int, id: Int,
private val habit: Habit private val habit: Habit,
) : BaseWidget(context, id) { ) : BaseWidget(context, id) {
override val defaultHeight: Int = 300
override val defaultWidth: Int = 300
override fun getOnClickPendingIntent(context: Context) = override fun getOnClickPendingIntent(context: Context): PendingIntent =
pendingIntentFactory.showHabit(habit) pendingIntentFactory.showHabit(habit)
override fun refreshData(view: View) { override fun refreshData(view: View) {
@ -57,7 +60,4 @@ class ScoreWidget(
GraphWidgetView(context, ScoreChart(context)).apply { GraphWidgetView(context, ScoreChart(context)).apply {
setTitle(habit.name) setTitle(habit.name)
} }
override fun getDefaultHeight() = 300
override fun getDefaultWidth() = 300
} }

@ -23,7 +23,7 @@ import android.content.Context
class ScoreWidgetProvider : BaseWidgetProvider() { class ScoreWidgetProvider : BaseWidgetProvider() {
override fun getWidgetFromId(context: Context, id: Int): BaseWidget { override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
val habits = getHabitsFromWidgetId(id) val habits = getHabitsFromWidgetId(id)
if (habits.size == 1) return ScoreWidget(context, id, habits[0]) return if (habits.size == 1) ScoreWidget(context, id, habits[0])
else return StackWidget(context, id, StackWidgetType.SCORE, habits) else StackWidget(context, id, StackWidgetType.SCORE, habits)
} }
} }

@ -19,6 +19,7 @@
package org.isoron.uhabits.widgets package org.isoron.uhabits.widgets
import android.app.PendingIntent
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -32,15 +33,22 @@ class StackWidget(
context: Context, context: Context,
widgetId: Int, widgetId: Int,
private val widgetType: StackWidgetType, private val widgetType: StackWidgetType,
private val habits: List<Habit> private val habits: List<Habit>,
) : BaseWidget(context, widgetId) { ) : BaseWidget(context, widgetId) {
override val defaultHeight: Int = 0
override val defaultWidth: Int = 0
override fun getOnClickPendingIntent(context: Context) = null override fun getOnClickPendingIntent(context: Context): PendingIntent? = null
override fun refreshData(v: View) { override fun refreshData(v: View) {
// unused // unused
} }
override fun buildView(): View? {
// unused
return null
}
override fun getRemoteViews(width: Int, height: Int): RemoteViews { override fun getRemoteViews(width: Int, height: Int): RemoteViews {
val manager = AppWidgetManager.getInstance(context) val manager = AppWidgetManager.getInstance(context)
val remoteViews = RemoteViews(context.packageName, StackWidgetType.getStackWidgetLayoutId(widgetType)) val remoteViews = RemoteViews(context.packageName, StackWidgetType.getStackWidgetLayoutId(widgetType))
@ -59,8 +67,4 @@ class StackWidget(
) )
return remoteViews return remoteViews
} }
override fun buildView() = null // unused
override fun getDefaultHeight() = 0 // unused
override fun getDefaultWidth() = 0 // unused
} }

@ -1,186 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets;
import android.appwidget.*;
import android.content.*;
import android.os.*;
import android.util.Log;
import android.widget.*;
import androidx.annotation.NonNull;
import org.isoron.uhabits.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.core.utils.*;
import java.util.*;
import static android.appwidget.AppWidgetManager.*;
import static org.isoron.uhabits.utils.InterfaceUtils.dpToPixels;
import static org.isoron.uhabits.widgets.StackWidgetService.*;
public class StackWidgetService extends RemoteViewsService
{
public static final String WIDGET_TYPE = "WIDGET_TYPE";
public static final String HABIT_IDS = "HABIT_IDS";
@Override
public RemoteViewsFactory onGetViewFactory(Intent intent)
{
return new StackRemoteViewsFactory(this.getApplicationContext(), intent);
}
}
class StackRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory
{
private Context context;
private int widgetId;
private long[] habitIds;
private StackWidgetType widgetType;
private ArrayList<RemoteViews> remoteViews = new ArrayList<>();
public StackRemoteViewsFactory(Context context, Intent intent)
{
this.context = context;
widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
int widgetTypeValue = intent.getIntExtra(WIDGET_TYPE, -1);
String habitIdsStr = intent.getStringExtra(HABIT_IDS);
if (widgetTypeValue < 0) throw new RuntimeException("invalid widget type");
if (habitIdsStr == null) throw new RuntimeException("habitIdsStr is null");
widgetType = StackWidgetType.getWidgetTypeFromValue(widgetTypeValue);
habitIds = StringUtils.splitLongs(habitIdsStr);
}
public void onCreate()
{
}
public void onDestroy()
{
}
public int getCount()
{
return habitIds.length;
}
@NonNull
public WidgetDimensions getDimensionsFromOptions(@NonNull Context ctx,
@NonNull Bundle options)
{
int maxWidth = (int) dpToPixels(ctx, options.getInt(OPTION_APPWIDGET_MAX_WIDTH));
int maxHeight = (int) dpToPixels(ctx, options.getInt(OPTION_APPWIDGET_MAX_HEIGHT));
int minWidth = (int) dpToPixels(ctx, options.getInt(OPTION_APPWIDGET_MIN_WIDTH));
int minHeight = (int) dpToPixels(ctx, options.getInt(OPTION_APPWIDGET_MIN_HEIGHT));
return new WidgetDimensions(minWidth, maxHeight, maxWidth, minHeight);
}
public RemoteViews getViewAt(int position)
{
Log.i("StackRemoteViewsFactory", "getViewAt " + position);
if (position < 0 || position > remoteViews.size()) return null;
return remoteViews.get(position);
}
@NonNull
private BaseWidget constructWidget(@NonNull Habit habit,
@NonNull Preferences prefs)
{
switch (widgetType)
{
case CHECKMARK:
return new CheckmarkWidget(context, widgetId, habit);
case FREQUENCY:
return new FrequencyWidget(context, widgetId, habit, prefs.getFirstWeekdayInt());
case SCORE:
return new ScoreWidget(context, widgetId, habit);
case HISTORY:
return new HistoryWidget(context, widgetId, habit);
case STREAKS:
return new StreakWidget(context, widgetId, habit);
}
throw new IllegalStateException();
}
public RemoteViews getLoadingView()
{
Bundle options = AppWidgetManager.getInstance(context).getAppWidgetOptions(widgetId);
EmptyWidget widget = new EmptyWidget(context, widgetId);
widget.setDimensions(getDimensionsFromOptions(context, options));
RemoteViews landscapeViews = widget.getLandscapeRemoteViews();
RemoteViews portraitViews = widget.getPortraitRemoteViews();
return new RemoteViews(landscapeViews, portraitViews);
}
public int getViewTypeCount()
{
return 1;
}
public long getItemId(int position)
{
return habitIds[position];
}
public boolean hasStableIds()
{
return true;
}
public void onDataSetChanged()
{
Log.i("StackRemoteViewsFactory", "onDataSetChanged started");
HabitsApplication app = (HabitsApplication) context.getApplicationContext();
Preferences prefs = app.getComponent().getPreferences();
HabitList habitList = app.getComponent().getHabitList();
Bundle options = AppWidgetManager.getInstance(context).getAppWidgetOptions(widgetId);
ArrayList<RemoteViews> newRemoteViews = new ArrayList<>();
if (Looper.myLooper() == null) Looper.prepare();
for (long id : habitIds)
{
Habit h = habitList.getById(id);
if (h == null) throw new HabitNotFoundException();
BaseWidget widget = constructWidget(h, prefs);
widget.setDimensions(getDimensionsFromOptions(context, options));
RemoteViews landscapeViews = widget.getLandscapeRemoteViews();
RemoteViews portraitViews = widget.getPortraitRemoteViews();
newRemoteViews.add(new RemoteViews(landscapeViews, portraitViews));
Log.i("StackRemoteViewsFactory", "onDataSetChanged constructed widget " + id);
}
remoteViews = newRemoteViews;
Log.i("StackRemoteViewsFactory", "onDataSetChanged ended");
}
}

@ -0,0 +1,161 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Looper
import android.util.Log
import android.widget.RemoteViews
import android.widget.RemoteViewsService
import android.widget.RemoteViewsService.RemoteViewsFactory
import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.core.models.Habit
import org.isoron.uhabits.core.models.HabitNotFoundException
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.core.utils.StringUtils.Companion.splitLongs
import org.isoron.uhabits.utils.InterfaceUtils.dpToPixels
import java.util.ArrayList
class StackWidgetService : RemoteViewsService() {
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
return StackRemoteViewsFactory(this.applicationContext, intent)
}
companion object {
const val WIDGET_TYPE = "WIDGET_TYPE"
const val HABIT_IDS = "HABIT_IDS"
}
}
internal class StackRemoteViewsFactory(private val context: Context, intent: Intent) :
RemoteViewsFactory {
private val widgetId: Int = intent.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
)
private val habitIds: LongArray
private val widgetType: StackWidgetType?
private var remoteViews = ArrayList<RemoteViews>()
override fun onCreate() {}
override fun onDestroy() {}
override fun getCount(): Int {
return habitIds.size
}
fun getDimensionsFromOptions(
ctx: Context,
options: Bundle
): WidgetDimensions {
val maxWidth = dpToPixels(
ctx,
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH).toFloat()
).toInt()
val maxHeight = dpToPixels(
ctx,
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT).toFloat()
).toInt()
val minWidth = dpToPixels(
ctx,
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH).toFloat()
).toInt()
val minHeight = dpToPixels(
ctx,
options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT).toFloat()
).toInt()
return WidgetDimensions(minWidth, maxHeight, maxWidth, minHeight)
}
override fun getViewAt(position: Int): RemoteViews? {
Log.i("StackRemoteViewsFactory", "getViewAt $position")
return if (position < 0 || position > remoteViews.size) null else remoteViews[position]
}
private fun constructWidget(
habit: Habit,
prefs: Preferences
): BaseWidget {
when (widgetType) {
StackWidgetType.CHECKMARK -> return CheckmarkWidget(context, widgetId, habit)
StackWidgetType.FREQUENCY -> return FrequencyWidget(
context,
widgetId,
habit,
prefs.firstWeekdayInt
)
StackWidgetType.SCORE -> return ScoreWidget(context, widgetId, habit)
StackWidgetType.HISTORY -> return HistoryWidget(context, widgetId, habit)
StackWidgetType.STREAKS -> return StreakWidget(context, widgetId, habit)
}
throw IllegalStateException()
}
override fun getLoadingView(): RemoteViews {
val options = AppWidgetManager.getInstance(context).getAppWidgetOptions(widgetId)
val widget = EmptyWidget(context, widgetId)
widget.setDimensions(getDimensionsFromOptions(context, options))
val landscapeViews = widget.landscapeRemoteViews
val portraitViews = widget.portraitRemoteViews
return RemoteViews(landscapeViews, portraitViews)
}
override fun getViewTypeCount(): Int {
return 1
}
override fun getItemId(position: Int): Long {
return habitIds[position]
}
override fun hasStableIds(): Boolean {
return true
}
override fun onDataSetChanged() {
Log.i("StackRemoteViewsFactory", "onDataSetChanged started")
val app = context.applicationContext as HabitsApplication
val prefs = app.component.preferences
val habitList = app.component.habitList
val options = AppWidgetManager.getInstance(context).getAppWidgetOptions(widgetId)
val newRemoteViews = ArrayList<RemoteViews>()
if (Looper.myLooper() == null) Looper.prepare()
for (id in habitIds) {
val h = habitList.getById(id) ?: throw HabitNotFoundException()
val widget = constructWidget(h, prefs)
widget.setDimensions(getDimensionsFromOptions(context, options))
val landscapeViews = widget.landscapeRemoteViews
val portraitViews = widget.portraitRemoteViews
newRemoteViews.add(RemoteViews(landscapeViews, portraitViews))
Log.i("StackRemoteViewsFactory", "onDataSetChanged constructed widget $id")
}
remoteViews = newRemoteViews
Log.i("StackRemoteViewsFactory", "onDataSetChanged ended")
}
init {
val widgetTypeValue = intent.getIntExtra(StackWidgetService.WIDGET_TYPE, -1)
val habitIdsStr = intent.getStringExtra(StackWidgetService.HABIT_IDS)
if (widgetTypeValue < 0) throw RuntimeException("invalid widget type")
if (habitIdsStr == null) throw RuntimeException("habitIdsStr is null")
widgetType = StackWidgetType.getWidgetTypeFromValue(widgetTypeValue)
habitIds = splitLongs(habitIdsStr)
}
}

@ -1,117 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets;
import org.isoron.uhabits.R;
/**
* Created by victoryu on 11/3/17.
*/
public enum StackWidgetType {
CHECKMARK(0),
FREQUENCY(1),
SCORE(2), // habit strength widget
HISTORY(3),
STREAKS(4),
TARGET(5);
private int value;
StackWidgetType(int value) {
this.value = value;
}
public int getValue() {
return value;
}
public static StackWidgetType getWidgetTypeFromValue(int value) {
if (CHECKMARK.getValue() == value) {
return CHECKMARK;
} else if (FREQUENCY.getValue() == value) {
return FREQUENCY;
} else if (SCORE.getValue() == value) {
return SCORE;
} else if (HISTORY.getValue() == value) {
return HISTORY;
} else if (STREAKS.getValue() == value) {
return STREAKS;
} else if (TARGET.getValue() == value) {
return TARGET;
}
return null;
}
public static int getStackWidgetLayoutId(StackWidgetType type) {
switch (type) {
case CHECKMARK:
return R.layout.checkmark_stackview_widget;
case FREQUENCY:
return R.layout.frequency_stackview_widget;
case SCORE:
return R.layout.score_stackview_widget;
case HISTORY:
return R.layout.history_stackview_widget;
case STREAKS:
return R.layout.streak_stackview_widget;
case TARGET:
return R.layout.target_stackview_widget;
}
return 0;
}
public static int getStackWidgetAdapterViewId(StackWidgetType type) {
switch (type) {
case CHECKMARK:
return R.id.checkmarkStackWidgetView;
case FREQUENCY:
return R.id.frequencyStackWidgetView;
case SCORE:
return R.id.scoreStackWidgetView;
case HISTORY:
return R.id.historyStackWidgetView;
case STREAKS:
return R.id.streakStackWidgetView;
case TARGET:
return R.id.targetStackWidgetView;
}
return 0;
}
public static int getStackWidgetEmptyViewId(StackWidgetType type) {
switch (type) {
case CHECKMARK:
return R.id.checkmarkStackWidgetEmptyView;
case FREQUENCY:
return R.id.frequencyStackWidgetEmptyView;
case SCORE:
return R.id.scoreStackWidgetEmptyView;
case HISTORY:
return R.id.historyStackWidgetEmptyView;
case STREAKS:
return R.id.streakStackWidgetEmptyView;
case TARGET:
return R.id.targetStackWidgetEmptyView;
}
return 0;
}
}

@ -0,0 +1,79 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets
import org.isoron.uhabits.R
/**
* Created by victoryu on 11/3/17.
*/
enum class StackWidgetType(val value: Int) {
CHECKMARK(0), FREQUENCY(1), SCORE(2), // habit strength widget
HISTORY(3), STREAKS(4), TARGET(5);
companion object {
fun getWidgetTypeFromValue(value: Int): StackWidgetType? {
return when {
CHECKMARK.value == value -> CHECKMARK
FREQUENCY.value == value -> FREQUENCY
SCORE.value == value -> SCORE
HISTORY.value == value -> HISTORY
STREAKS.value == value -> STREAKS
TARGET.value == value -> TARGET
else -> null
}
}
fun getStackWidgetLayoutId(type: StackWidgetType?): Int {
when (type) {
CHECKMARK -> return R.layout.checkmark_stackview_widget
FREQUENCY -> return R.layout.frequency_stackview_widget
SCORE -> return R.layout.score_stackview_widget
HISTORY -> return R.layout.history_stackview_widget
STREAKS -> return R.layout.streak_stackview_widget
TARGET -> return R.layout.target_stackview_widget
}
return 0
}
fun getStackWidgetAdapterViewId(type: StackWidgetType?): Int {
when (type) {
CHECKMARK -> return R.id.checkmarkStackWidgetView
FREQUENCY -> return R.id.frequencyStackWidgetView
SCORE -> return R.id.scoreStackWidgetView
HISTORY -> return R.id.historyStackWidgetView
STREAKS -> return R.id.streakStackWidgetView
TARGET -> return R.id.targetStackWidgetView
}
return 0
}
fun getStackWidgetEmptyViewId(type: StackWidgetType?): Int {
when (type) {
CHECKMARK -> return R.id.checkmarkStackWidgetEmptyView
FREQUENCY -> return R.id.frequencyStackWidgetEmptyView
SCORE -> return R.id.scoreStackWidgetEmptyView
HISTORY -> return R.id.historyStackWidgetEmptyView
STREAKS -> return R.id.streakStackWidgetEmptyView
TARGET -> return R.id.targetStackWidgetEmptyView
}
return 0
}
}
}

@ -19,6 +19,7 @@
package org.isoron.uhabits.widgets package org.isoron.uhabits.widgets
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
@ -31,10 +32,12 @@ import org.isoron.uhabits.widgets.views.GraphWidgetView
class StreakWidget( class StreakWidget(
context: Context, context: Context,
id: Int, id: Int,
private val habit: Habit private val habit: Habit,
) : BaseWidget(context, id) { ) : BaseWidget(context, id) {
override val defaultHeight: Int = 200
override val defaultWidth: Int = 200
override fun getOnClickPendingIntent(context: Context) = override fun getOnClickPendingIntent(context: Context): PendingIntent =
pendingIntentFactory.showHabit(habit) pendingIntentFactory.showHabit(habit)
override fun refreshData(view: View) { override fun refreshData(view: View) {
@ -53,7 +56,4 @@ class StreakWidget(
layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
} }
} }
override fun getDefaultHeight() = 200
override fun getDefaultWidth() = 200
} }

@ -23,7 +23,7 @@ import android.content.Context
class StreakWidgetProvider : BaseWidgetProvider() { class StreakWidgetProvider : BaseWidgetProvider() {
override fun getWidgetFromId(context: Context, id: Int): BaseWidget { override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
val habits = getHabitsFromWidgetId(id) val habits = getHabitsFromWidgetId(id)
if (habits.size == 1) return StreakWidget(context, id, habits[0]) return if (habits.size == 1) StreakWidget(context, id, habits[0])
else return StackWidget(context, id, StackWidgetType.STREAKS, habits) else StackWidget(context, id, StackWidgetType.STREAKS, habits)
} }
} }

@ -19,6 +19,7 @@
package org.isoron.uhabits.widgets package org.isoron.uhabits.widgets
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
@ -34,10 +35,12 @@ import org.isoron.uhabits.widgets.views.GraphWidgetView
class TargetWidget( class TargetWidget(
context: Context, context: Context,
id: Int, id: Int,
private val habit: Habit private val habit: Habit,
) : BaseWidget(context, id) { ) : BaseWidget(context, id) {
override val defaultHeight: Int = 200
override val defaultWidth: Int = 200
override fun getOnClickPendingIntent(context: Context) = override fun getOnClickPendingIntent(context: Context): PendingIntent =
pendingIntentFactory.showHabit(habit) pendingIntentFactory.showHabit(habit)
override fun refreshData(view: View) = runBlocking { override fun refreshData(view: View) = runBlocking {
@ -58,7 +61,4 @@ class TargetWidget(
layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
} }
} }
override fun getDefaultHeight() = 200
override fun getDefaultWidth() = 200
} }

@ -23,7 +23,7 @@ import android.content.Context
class TargetWidgetProvider : BaseWidgetProvider() { class TargetWidgetProvider : BaseWidgetProvider() {
override fun getWidgetFromId(context: Context, id: Int): BaseWidget { override fun getWidgetFromId(context: Context, id: Int): BaseWidget {
val habits = getHabitsFromWidgetId(id) val habits = getHabitsFromWidgetId(id)
if (habits.size == 1) return TargetWidget(context, id, habits[0]) return if (habits.size == 1) TargetWidget(context, id, habits[0])
else return StackWidget(context, id, StackWidgetType.TARGET, habits) else StackWidget(context, id, StackWidgetType.TARGET, habits)
} }
} }

@ -1,225 +0,0 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets.views;
import android.content.*;
import android.util.*;
import android.widget.*;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.isoron.uhabits.*;
import org.isoron.uhabits.activities.habits.list.views.*;
import org.isoron.uhabits.core.models.*;
import org.isoron.uhabits.activities.common.views.*;
import org.isoron.uhabits.core.preferences.*;
import org.isoron.uhabits.inject.*;
import org.isoron.uhabits.utils.*;
import static org.isoron.uhabits.utils.InterfaceUtils.getDimension;
public class CheckmarkWidgetView extends HabitWidgetView {
protected int activeColor;
protected float percentage;
@Nullable
protected String name;
protected RingView ring;
protected TextView label;
protected int entryValue;
protected int entryState;
protected boolean isNumerical;
private Preferences preferences;
public CheckmarkWidgetView(Context context)
{
super(context);
init();
}
public CheckmarkWidgetView(Context context, AttributeSet attrs)
{
super(context, attrs);
init();
}
public void refresh()
{
if (backgroundPaint == null || frame == null || ring == null) return;
StyledResources res = new StyledResources(getContext());
int bgColor;
int fgColor;
switch (entryState) {
case Entry.YES_MANUAL:
case Entry.SKIP:
bgColor = activeColor;
fgColor = res.getColor(R.attr.highContrastReverseTextColor);
setShadowAlpha(0x4f);
backgroundPaint.setColor(bgColor);
frame.setBackgroundDrawable(background);
break;
case Entry.YES_AUTO:
case Entry.NO:
case Entry.UNKNOWN:
default:
bgColor = res.getColor(R.attr.cardBgColor);
fgColor = res.getColor(R.attr.mediumContrastTextColor);
setShadowAlpha(0x00);
break;
}
ring.setPercentage(percentage);
ring.setColor(fgColor);
ring.setBackgroundColor(bgColor);
ring.setText(getText());
label.setText(name);
label.setTextColor(fgColor);
requestLayout();
postInvalidate();
}
public void setEntryState(int entryState)
{
this.entryState = entryState;
}
protected String getText()
{
if (isNumerical) return NumberButtonViewKt.toShortString(entryValue / 1000.0);
switch (entryState) {
case Entry.YES_MANUAL:
case Entry.YES_AUTO:
return getResources().getString(R.string.fa_check);
case Entry.SKIP:
return getResources().getString(R.string.fa_skipped);
case Entry.UNKNOWN:
{
if (preferences.areQuestionMarksEnabled())
return getResources().getString(R.string.fa_question);
else
getResources().getString(R.string.fa_times);
}
case Entry.NO:
default:
return getResources().getString(R.string.fa_times);
}
}
public void setActiveColor(int activeColor)
{
this.activeColor = activeColor;
}
public void setEntryValue(int entryValue)
{
this.entryValue = entryValue;
}
public void setName(@NonNull String name)
{
this.name = name;
}
public void setPercentage(float percentage)
{
this.percentage = percentage;
}
public void setNumerical(boolean isNumerical)
{
this.isNumerical = isNumerical;
}
@Override
@NonNull
protected Integer getInnerLayoutId()
{
return R.layout.widget_checkmark;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
float w = width;
float h = width * 1.25f;
float scale = Math.min(width / w, height / h);
w *= scale;
h *= scale;
if (h < getDimension(getContext(), R.dimen.checkmarkWidget_heightBreakpoint))
ring.setVisibility(GONE);
else
ring.setVisibility(VISIBLE);
widthMeasureSpec =
MeasureSpec.makeMeasureSpec((int) w, MeasureSpec.EXACTLY);
heightMeasureSpec =
MeasureSpec.makeMeasureSpec((int) h, MeasureSpec.EXACTLY);
float textSize = 0.15f * h;
float maxTextSize = getDimension(getContext(), R.dimen.smallerTextSize);
textSize = Math.min(textSize, maxTextSize);
label.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize);
ring.setTextSize(textSize);
ring.setThickness(0.15f * textSize);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
private void init()
{
HabitsApplicationComponent appComponent;
appComponent = ((HabitsApplication) getContext().getApplicationContext()).getComponent();
preferences = appComponent.getPreferences();
ring = (RingView) findViewById(R.id.scoreRing);
label = (TextView) findViewById(R.id.label);
if (ring != null) ring.setIsTransparencyEnabled(true);
if (isInEditMode())
{
percentage = 0.75f;
name = "Wake up early";
activeColor = PaletteUtils.getAndroidTestColor(6);
entryValue = Entry.YES_MANUAL;
refresh();
}
}
}

@ -0,0 +1,156 @@
/*
* Copyright (C) 2016-2021 Álinson Santos Xavier <git@axavier.org>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.widgets.views
import android.content.Context
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.widget.TextView
import org.isoron.uhabits.HabitsApplication
import org.isoron.uhabits.R
import org.isoron.uhabits.activities.common.views.RingView
import org.isoron.uhabits.activities.habits.list.views.toShortString
import org.isoron.uhabits.core.models.Entry.Companion.NO
import org.isoron.uhabits.core.models.Entry.Companion.SKIP
import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN
import org.isoron.uhabits.core.models.Entry.Companion.YES_AUTO
import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL
import org.isoron.uhabits.core.preferences.Preferences
import org.isoron.uhabits.inject.HabitsApplicationComponent
import org.isoron.uhabits.utils.InterfaceUtils.getDimension
import org.isoron.uhabits.utils.PaletteUtils.getAndroidTestColor
import org.isoron.uhabits.utils.StyledResources
import kotlin.math.min
class CheckmarkWidgetView : HabitWidgetView {
var activeColor: Int = 0
var percentage = 0f
var name: String? = null
private lateinit var ring: RingView
private lateinit var label: TextView
var entryValue = 0
var entryState = 0
var isNumerical = false
private var preferences: Preferences? = null
constructor(context: Context?) : super(context) {
init()
}
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
init()
}
fun refresh() {
if (backgroundPaint == null || frame == null) return
val res = StyledResources(context)
val bgColor: Int
val fgColor: Int
when (entryState) {
YES_MANUAL, SKIP -> {
bgColor = activeColor
fgColor = res.getColor(R.attr.highContrastReverseTextColor)
setShadowAlpha(0x4f)
backgroundPaint!!.color = bgColor
frame!!.setBackgroundDrawable(background)
}
YES_AUTO, NO, UNKNOWN -> {
bgColor = res.getColor(R.attr.cardBgColor)
fgColor = res.getColor(R.attr.mediumContrastTextColor)
setShadowAlpha(0x00)
}
else -> {
bgColor = res.getColor(R.attr.cardBgColor)
fgColor = res.getColor(R.attr.mediumContrastTextColor)
setShadowAlpha(0x00)
}
}
ring.percentage = percentage
ring.color = fgColor
ring.setBackgroundColor(bgColor)
ring.setText(text)
label.text = name
label.setTextColor(fgColor)
requestLayout()
postInvalidate()
}
private val text: String
get() = if (isNumerical) {
(entryValue / 1000.0).toShortString()
} else when (entryState) {
YES_MANUAL, YES_AUTO -> resources.getString(R.string.fa_check)
SKIP -> resources.getString(R.string.fa_skipped)
UNKNOWN -> {
run {
if (preferences!!.areQuestionMarksEnabled()) {
return resources.getString(R.string.fa_question)
} else {
resources.getString(R.string.fa_times)
}
}
resources.getString(R.string.fa_times)
}
NO -> resources.getString(R.string.fa_times)
else -> resources.getString(R.string.fa_times)
}
override val innerLayoutId: Int
get() = R.layout.widget_checkmark
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
var widthMeasureSpec = widthMeasureSpec
var heightMeasureSpec = heightMeasureSpec
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
var w = width.toFloat()
var h = width * 1.25f
val scale = min(width / w, height / h)
w *= scale
h *= scale
if (h < getDimension(context, R.dimen.checkmarkWidget_heightBreakpoint)) ring.visibility =
GONE else ring.visibility = VISIBLE
widthMeasureSpec = MeasureSpec.makeMeasureSpec(w.toInt(), MeasureSpec.EXACTLY)
heightMeasureSpec = MeasureSpec.makeMeasureSpec(h.toInt(), MeasureSpec.EXACTLY)
var textSize = 0.15f * h
val maxTextSize = getDimension(context, R.dimen.smallerTextSize)
textSize = min(textSize, maxTextSize)
label.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
ring.setTextSize(textSize)
ring.setThickness(0.15f * textSize)
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
private fun init() {
val appComponent: HabitsApplicationComponent = (context.applicationContext as HabitsApplication).component
preferences = appComponent.preferences
ring = findViewById<View>(R.id.scoreRing) as RingView
label = findViewById<View>(R.id.label) as TextView
ring.setIsTransparencyEnabled(true)
if (isInEditMode) {
percentage = 0.75f
name = "Wake up early"
activeColor = getAndroidTestColor(6)
entryValue = YES_MANUAL
refresh()
}
}
}

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

Loading…
Cancel
Save