diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..fbb473556 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,31 @@ +GnuCash Android is built by people like you! Please [join us](https://github.com/codinguser/gnucash-android). + +## Git and Pull requests +* Contributions are submitted, reviewed, and accepted using Github pull requests. [Read this article](https://help.github.com/articles/using-pull-requests) for some details. We use the _Fork and Pull_ model, as described there. +* You can maintain your stable installation of GnuCash and test with another installation. +The two instances of GnuCash Android will live side-by-side on your device and not affect each other. You can install the development version by executing `gradle installDD` inside the root project directory +* The latest changes are in the `develop` branch. +* The master branch contains only stable releases. + * Pull requests to the `master` branch will be rejected. +* Make a new branch for every feature you're working on. +* Try to make clean commits that are easily readable (including descriptive commit messages!) +* Test before you push make sure all test pass on your machine. + * Unit tests can be run with `gradle test` + * UI tests can be run with `gradle cDDAT` +* Make small pull requests that are easy to review but make sure they do add value by themselves. + +## Coding style +* Do write comments. You don't have to comment every line, but if you come up with something thats a bit complex/weird, just leave a comment. Bear in mind that you will probably leave the project at some point and that other people will read your code. Undocumented huge amounts of code are nearly worthless! +* Please make sure to document every method you write using Javadoc, even if the method seems trivial to you + * See [this guide](http://www.oracle.com/technetwork/articles/java/index-137868.html) on how to write good Javadoc comments +* Don't overengineer. Don't try to solve any possible problem in one step, but try to solve problems as easy as possible and improve the solution over time! +* Do generalize sooner or later! (if an old solution, quickly hacked together, poses more problems than it solves today, refactor it!) +* Keep it compatible. Do not introduce changes to the public API, or configurations too lightly. Don't make incompatible changes without good reasons! + +## Translation +* Tranlations for GnuCash Android are managed using [CrowdIn](crowdin.com/project/gnucash-android) +* You can sign up for an account and create/vote for translations. +* Translations will not be accepted via pull requests + +## Documentation +* Documentation should be kept up-to-date. This means, whenever you add a new API method, add a new hook or change the database model, pack the relevant changes to the docs in the same pull request. diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..a16247155 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,13 @@ +#### Expected behaviour + + +#### Actual behaviour + + +#### Steps to reproduce the behaviour +1. + +#### Software specifications +* GnuCash Android version: +* System Android version: +* Device type: diff --git a/.travis.yml b/.travis.yml index 08f381415..a66c86964 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ android: components: - platform-tools - tools - - build-tools-23.0.2 + - build-tools-23.0.3 # The SDK version used to compile your project - android-23 diff --git a/CHANGELOG.md b/CHANGELOG.md index cad161c0a..3c0bdc9ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,21 @@ Change Log =============================================================================== +Version 2.1.0 *(2016-09-01)* +---------------------------- +* Feature: Use multiple GnuCash books in single application +* Feature: Backup/Export to ownCloud servers +* Feature: Compact transactions list view for single-entry mode +* Improved: Redesign of passcode screen with included alphabet keys +* Improved: Scheduled transactions now have more accurate timestamps +* Improved: Generate all scheduled transactions even if a scheduled is missed (e.g. device off) +* Improved: Updated translations (and extracted some hard-coded strings) +* Fixed: Accounts lists not properly refreshed after switching between recent and all +* Fixed: Inaccurate execution of some scheduled transactions + Version 2.0.7 *(2016-05-05)* ---------------------------- * Fixed: Currency exchange rate does not accept very small rates (> 2 decimal places) -* Improved: Updated translations for Japanese, Polish, French, - -Version 2.0.6 *(2016-02-20)* +* Improved: Updated translations for Japanese, Polish, French, Version 2.0.6 *(2016-02-20)* ---------------------------- * Fixed: Saving transaction gets slower with increase in size of database * Fixed: Imbalance amount wrongly computed in split editor (for some accounts) diff --git a/CONTRIBUTORS b/CONTRIBUTORS deleted file mode 100644 index 2c70b48bf..000000000 --- a/CONTRIBUTORS +++ /dev/null @@ -1,32 +0,0 @@ -GnuCash for Android is a community effort which is made possible by the contributions of -several different people. -Appreciation goes to Muslim Chochlov and the to whole GnuCash community for guiding the -project through the early phases and providing valuable feedback. - -Maintainer: -Ngewi Fet - -Core contributors: -Yongxin Wang -Oleksandr Tyshkovets -Àlex Magaz Graça - -The following people (in no particular order) contributed (patches and translations) to GnuCash Android: -Christian Stimming -Cristian Marchi -Menelaos Maglis -Kjell Thomas Pedersen -Alexander Galanin -Jorge Martínez López -Israel Buitron -Geert Janssens -Nicolas Barranger -Sigurd Gartmann -Pedro Abel -windwarrior -Alex Lei -Matthew Hague -Spanti Nicola -Jesse Shieh -Terry Chung -Caesar Wirth diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 000000000..0ce07a1d1 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,34 @@ +GnuCash for Android is a community effort which is made possible by the contributions of +several different people. +Appreciation goes to Muslim Chochlov and the to whole GnuCash community for guiding the +project through the early phases (as Google Summer of Code project 2012) and providing valuable feedback. + +### Core Developers: +* Ngewi Fet - Project maintainer +* Yongxin Wang +* Oleksandr Tyshkovets +* Àlex Magaz Graça + +### Other Contributors +The following (incomplete list of) people (in no particular order) contributed (patches and translations) to GnuCash Android: +* Christian Stimming +* Cristian Marchi +* Menelaos Maglis +* Kjell Thomas Pedersen +* Alexander Galanin +* Jorge Martínez López +* Israel Buitron +* Geert Janssens +* Nicolas Barranger +* Sigurd Gartmann +* Pedro Abel +* windwarrior +* Alex Lei +* Matthew Hague +* Spanti Nicola +* Jesse Shieh +* Terry Chung +* Caesar Wirth +* Alceu Rodrigues Neto + +Please visit https://crowdin.com/project/gnucash-android for a more complete list of translation contributions \ No newline at end of file diff --git a/README.md b/README.md index 568d49476..13e89dc12 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ Features include: # Installation -There are different ways to get the GnuCash app for Android; through the app store, or building it yourself. +There are different ways to get the GnuCash app for Android; through +the app store, from github or building it yourself. ### App Store @@ -42,6 +43,9 @@ There are different ways to get the GnuCash app for Android; through the app sto Android app on Google Play +### From GitHub + +Download the .apk from https://github.com/codinguser/gnucash-android/releases ## Building @@ -55,14 +59,14 @@ the `ANDROID_HOME` environment variable to the location of your SDK. For example After satisfying those requirements, the build is pretty simple: -* Run `gradlew build installDevelopmentDebug` from the within the project folder. +* Run `./gradlew build installDevelopmentDebug` from the within the project folder. It will build the project for you and install it to the connected Android device or running emulator. The app is configured to allow you to install a development and production version in parallel on your device. ### With Android Studio -The easiest way to build is to install [Android Studio](https://developer.android.com/sdk/index.html) v1.+ -with [Gradle](https://www.gradle.org/) v2.4. +The easiest way to build is to install [Android Studio](https://developer.android.com/sdk/index.html) v2.+ +with [Gradle](https://www.gradle.org/) v2.10 Once installed, then you can import the project into Android Studio: 1. Open `File` @@ -81,7 +85,7 @@ Google+ Community: https://plus.google.com/communities/104728406764752407046 There are several ways you could contribute to the development. * One way is providing translations for locales which are not yet available, or improving translations. -See this [blog post](http://www.codinguser.com/2012/09/gnucash-for-android-beta-2-lost-in-translation/) for some guidelines. +Please visit [CrowdIn](https://crowdin.com/project/gnucash-android) in order to update and create new translations * You could as well contribute code, fixing bugs, new features or automated tests. Pull requests are always welcome. Take a look at the [bug tracker](https://github.com/codinguser/gnucash-android/issues?state=open) diff --git a/app/build.gradle b/app/build.gradle index cc8ab487f..0c8bf59cb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,12 +1,14 @@ +import org.apache.tools.ant.taskdefs.condition.Os + import java.text.SimpleDateFormat apply plugin: 'com.android.application' apply plugin: 'io.fabric' def versionMajor = 2 -def versionMinor = 0 -def versionPatch = 7 -def versionBuild = 2 +def versionMinor = 1 +def versionPatch = 0 +def versionBuild = 6 def buildTime() { def df = new SimpleDateFormat("yyyyMMdd HH:mm 'UTC'") @@ -21,12 +23,12 @@ def gitSha() { android { compileSdkVersion 23 - buildToolsVersion "23.0.2" + buildToolsVersion '23.0.3' defaultConfig { applicationId "org.gnucash.android" testApplicationId 'org.gnucash.android.test' minSdkVersion 10 - targetSdkVersion 23 //robolectric tests only support up to API level 21 at the moment + targetSdkVersion 23 versionCode versionMajor * 10000 + versionMinor * 1000 + versionPatch * 100 + versionBuild versionName "${versionMajor}.${versionMinor}.${versionPatch}" resValue "string", "app_version_name", "${versionName}" @@ -43,7 +45,7 @@ android { resValue "string", "dropbox_app_secret", "h2t9fphj3nr4wkw" resValue "string", "manifest_dropbox_app_key", "db-dhjh8ke9wf05948" } - testInstrumentationRunner "org.gnucash.android.test.ui.GnucashAndroidTestRunner" + testInstrumentationRunner "org.gnucash.android.test.ui.util.GnucashAndroidTestRunner" } @@ -79,8 +81,10 @@ android { } buildTypes { + //todo re-enable proguard and test coverage release { - minifyEnabled false +// minifyEnabled true +// shrinkResources true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' if (project.hasProperty("RELEASE_STORE_FILE")){ signingConfig signingConfigs.release @@ -90,6 +94,7 @@ android { } debug { debuggable true +// testCoverageEnabled true signingConfig signingConfigs.debug } } @@ -142,62 +147,76 @@ def initCrashlyticsPropertiesIfNeeded() { } } -def adb = android.getAdbExe().toString() +def adb = Os.isFamily(Os.FAMILY_WINDOWS) ? "..\\scripts\\adb_all.bat" : "../scripts/adb_all.sh" +//def adb = android.getAdbExe().toString() afterEvaluate { initCrashlyticsPropertiesIfNeeded() - task grantAnimationPermissionDevel(type: Exec, dependsOn: 'installDevelopmentDebug') { // or install{productFlavour}{buildType} - commandLine "$adb", 'devices' - standardOutput = new ByteArrayOutputStream() - - String output = standardOutput.toString() - output.eachLine { - def serial = it.split("\\s")[0] - commandLine "$adb -s $serial shell pm grant $android.productFlavors.development.applicationId android.permission.SET_ANIMATION_SCALE".split(' ') - commandLine "$adb -s $serial shell pm grant $android.productFlavors.development.applicationId android.permission.WRITE_EXTERNAL_STORAGE".split(' ') + task grantTestPermissionsDevel(type: Exec, dependsOn: 'installDevelopmentDebug') { // or install{productFlavour}{buildType} + if (Os.isFamily(Os.FAMILY_WINDOWS)){ + commandLine "cmd", "/c", "$adb", "shell pm grant $android.productFlavors.development.applicationId android.permission.SET_ANIMATION_SCALE" +// commandLine "cmd", "/c", "$adb", "shell pm grant $android.productFlavors.development.applicationId android.permission.WRITE_EXTERNAL_STORAGE" + } else { + commandLine "$adb shell pm grant $android.productFlavors.development.applicationId android.permission.SET_ANIMATION_SCALE".split(' ') +// commandLine "$adb shell pm grant $android.productFlavors.development.applicationId android.permission.WRITE_EXTERNAL_STORAGE".split(' ') } } - task grantAnimationPermissionProduction(type: Exec, dependsOn: 'installProductionDebug'){ - commandLine "$adb -e shell pm grant $android.defaultConfig.applicationId android.permission.SET_ANIMATION_SCALE".split(' ') - commandLine "$adb -e shell pm grant $android.defaultConfig.applicationId android.permission.WRITE_EXTERNAL_STORAGE".split(' ') + task grantTestPermissionsProduction(type: Exec, dependsOn: 'installProductionDebug'){ + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + commandLine "cmd", "/c", "$adb", "shell pm grant $android.productFlavors.development.applicationId android.permission.SET_ANIMATION_SCALE" + commandLine "cmd", "/c", "$adb", "shell pm grant $android.productFlavors.development.applicationId android.permission.WRITE_EXTERNAL_STORAGE" + } else { + commandLine "$adb shell pm grant $android.defaultConfig.applicationId android.permission.SET_ANIMATION_SCALE".split(' ') + commandLine "$adb shell pm grant $android.defaultConfig.applicationId android.permission.WRITE_EXTERNAL_STORAGE".split(' ') + } } + // When launching individual tests from Android Studio, it seems that only the assemble tasks // get called directly, not the install* versions tasks.each { task -> if (task.name.startsWith('assembleDevelopmentDebugAndroidTest')){ - task.dependsOn grantAnimationPermissionDevel + task.dependsOn grantTestPermissionsDevel } else if (task.name.startsWith('assembleBetaDebugAndroidTest')){ - task.dependsOn grantAnimationPermissionProduction + task.dependsOn grantTestPermissionsProduction } else if (task.name.startsWith('assembleProductionDebugAndroidTest')){ - task.dependsOn grantAnimationPermissionProduction + task.dependsOn grantTestPermissionsProduction } } } -def androidSupportVersion = "22.2.1" -def androidEspressoVersion = "2.2" -def androidSupportTestVersion = "0.3" +def androidSupportVersion = "23.3.0" +def androidEspressoVersion = "2.2.2" +def androidSupportTestVersion = "0.5" + +repositories{ + flatDir{ + dirs 'libs' + } +} dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) + compile(name:'owncloud_library', ext:'aar') compile('com.android.support:support-v4:' + androidSupportVersion, 'com.android.support:appcompat-v7:' + androidSupportVersion, 'com.android.support:design:' + androidSupportVersion, 'com.android.support:cardview-v7:' + androidSupportVersion, + 'com.android.support:preference-v7:' + androidSupportVersion, 'com.android.support:recyclerview-v7:' + androidSupportVersion, 'com.viewpagerindicator:library:2.4.1@aar', - 'com.code-troopers.betterpickers:library:2.0.3', + 'com.code-troopers.betterpickers:library:2.5.5', 'org.jraf:android-switch-backport:2.0.1@aar', 'com.github.PhilJay:MPAndroidChart:v2.1.3', 'joda-time:joda-time:2.7', - 'com.google.android.gms:play-services-drive:7.0.0', + 'com.google.android.gms:play-services-drive:8.3.0', 'com.jakewharton:butterknife:7.0.1', - 'com.kobakei:ratethisapp:0.0.3', - 'com.squareup:android-times-square:1.6.4@aar', - 'com.github.techfreak:wizardpager:1.0.0', - 'net.objecthunter:exp4j:0.4.5' + 'io.github.kobakei:ratethisapp:1.0.3', + 'com.squareup:android-times-square:1.6.5@aar', + 'com.github.techfreak:wizardpager:1.0.3', + 'net.objecthunter:exp4j:0.4.5', + 'org.apache.jackrabbit:jackrabbit-webdav:2.11.1' ) compile ('com.uservoice:uservoice-android-sdk:1.2.+') { @@ -210,7 +229,9 @@ dependencies { transitive = true; } - testCompile('org.robolectric:robolectric:3.0', + compile 'com.facebook.stetho:stetho:1.3.1' + + testCompile('org.robolectric:robolectric:3.1', 'junit:junit:4.12', 'joda-time:joda-time:2.7', 'org.assertj:assertj-core:1.7.1' @@ -218,13 +239,17 @@ dependencies { androidTestCompile ('com.android.support:support-annotations:' + androidSupportVersion, 'com.android.support.test:runner:' + androidSupportTestVersion, 'com.android.support.test:rules:' + androidSupportTestVersion, - 'com.android.support.test.espresso:espresso-core:' + androidEspressoVersion) + 'com.android.support.test.espresso:espresso-core:' + androidEspressoVersion, + 'com.android.support.test.espresso:espresso-intents:' + androidEspressoVersion, + //the following are only added so that the app and test version both us the same versions + 'com.android.support:appcompat-v7:' + androidSupportVersion, + 'com.android.support:design:' + androidSupportVersion) androidTestCompile ('com.android.support.test.espresso:espresso-contrib:' + androidEspressoVersion) { exclude group: 'com.android.support', module: 'support-v4' exclude module: 'recyclerview-v7' } - androidTestCompile('com.squareup.assertj:assertj-android:1.1.0'){ + androidTestCompile('com.squareup.assertj:assertj-android:1.1.1'){ exclude group: 'com.android.support', module:'support-annotations' } } diff --git a/app/libs/owncloud_library.aar b/app/libs/owncloud_library.aar new file mode 100644 index 000000000..533ed701a Binary files /dev/null and b/app/libs/owncloud_library.aar differ diff --git a/app/proguard-project.txt b/app/proguard-project.txt index c5f28521d..c4a987c8a 100644 --- a/app/proguard-project.txt +++ b/app/proguard-project.txt @@ -36,4 +36,4 @@ -keepnames class * implements android.os.Parcelable { public static final ** CREATOR; -} \ No newline at end of file +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 000000000..9e7ef5e63 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,15 @@ +-dontwarn android.support.** +-keep class butterknife.** { *; } +-dontwarn butterknife.internal.** +-keep class **$$ViewBinder { *; } + +-keepclasseswithmembernames class * { + @butterknife.* ; +} + +-keepclasseswithmembernames class * { + @butterknife.* ; +} + +-keep class org.gnucash.android.** {*;} +-keep class com.dropbox.** {*;} \ No newline at end of file diff --git a/app/src/androidTest/java/org/gnucash/android/test/ui/AccountsActivityTest.java b/app/src/androidTest/java/org/gnucash/android/test/ui/AccountsActivityTest.java index 3d2d8f97e..39b40f2ef 100644 --- a/app/src/androidTest/java/org/gnucash/android/test/ui/AccountsActivityTest.java +++ b/app/src/androidTest/java/org/gnucash/android/test/ui/AccountsActivityTest.java @@ -16,28 +16,32 @@ package org.gnucash.android.test.ui; +import android.annotation.TargetApi; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences.Editor; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; +import android.os.Build; import android.preference.PreferenceManager; -import android.support.test.InstrumentationRegistry; import android.support.test.espresso.Espresso; -import android.support.test.espresso.ViewInteraction; import android.support.test.espresso.matcher.ViewMatchers; +import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; import android.support.v4.app.Fragment; -import android.test.ActivityInstrumentationTestCase2; import android.util.Log; import com.kobakei.ratethisapp.RateThisApp; import org.gnucash.android.R; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.app.GnuCashApplication; import org.gnucash.android.db.DatabaseHelper; -import org.gnucash.android.db.SplitsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.Account; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.Commodity; @@ -45,15 +49,18 @@ import org.gnucash.android.model.Split; import org.gnucash.android.model.Transaction; import org.gnucash.android.receivers.AccountCreator; +import org.gnucash.android.test.ui.util.DisableAnimationsRule; import org.gnucash.android.ui.account.AccountsActivity; import org.gnucash.android.ui.account.AccountsListFragment; import org.junit.After; import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import java.math.BigDecimal; -import java.util.Currency; import java.util.List; import static android.support.test.espresso.Espresso.onData; @@ -65,12 +72,16 @@ import static android.support.test.espresso.action.ViewActions.scrollTo; import static android.support.test.espresso.action.ViewActions.swipeRight; import static android.support.test.espresso.action.ViewActions.typeText; +import static android.support.test.espresso.assertion.ViewAssertions.doesNotExist; import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.matcher.ViewMatchers.hasDescendant; import static android.support.test.espresso.matcher.ViewMatchers.isChecked; import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.isEnabled; import static android.support.test.espresso.matcher.ViewMatchers.isNotChecked; import static android.support.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withParent; import static android.support.test.espresso.matcher.ViewMatchers.withText; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.allOf; @@ -78,48 +89,71 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; + @RunWith(AndroidJUnit4.class) -public class AccountsActivityTest extends ActivityInstrumentationTestCase2 { - private static final String DUMMY_ACCOUNT_CURRENCY_CODE = "USD"; - private static final Commodity DUMMY_ACCOUNT_CURRENCY = Commodity.getInstance(DUMMY_ACCOUNT_CURRENCY_CODE); - private static final String DUMMY_ACCOUNT_NAME = "Dummy account"; - public static final String DUMMY_ACCOUNT_UID = "dummy-account"; - private DatabaseHelper mDbHelper; - private SQLiteDatabase mDb; - private AccountsDbAdapter mAccountsDbAdapter; - private TransactionsDbAdapter mTransactionsDbAdapter; - private SplitsDbAdapter mSplitsDbAdapter; - private AccountsActivity mAcccountsActivity; +public class AccountsActivityTest { + private static final String ACCOUNTS_CURRENCY_CODE = "USD"; + // Don't add static here, otherwise it gets set to null by super.tearDown() + private final Commodity ACCOUNTS_CURRENCY = Commodity.getInstance(ACCOUNTS_CURRENCY_CODE); + private static final String SIMPLE_ACCOUNT_NAME = "Simple account"; + private static final String SIMPLE_ACCOUNT_UID = "simple-account"; + private static final String ROOT_ACCOUNT_NAME = "Root account"; + private static final String ROOT_ACCOUNT_UID = "root-account"; + private static final String PARENT_ACCOUNT_NAME = "Parent account"; + private static final String PARENT_ACCOUNT_UID = "parent-account"; + private static final String CHILD_ACCOUNT_UID = "child-account"; + private static final String CHILD_ACCOUNT_NAME = "Child account"; + public static final String TEST_DB_NAME = "test_gnucash_db.sqlite"; + + private static DatabaseHelper mDbHelper; + private static SQLiteDatabase mDb; + private static AccountsDbAdapter mAccountsDbAdapter; + private static TransactionsDbAdapter mTransactionsDbAdapter; + private static SplitsDbAdapter mSplitsDbAdapter; + private AccountsActivity mAccountsActivity; public AccountsActivityTest() { - super(AccountsActivity.class); - } +// super(AccountsActivity.class); + } - @Before - public void setUp() throws Exception { - super.setUp(); - injectInstrumentation(InstrumentationRegistry.getInstrumentation()); - preventFirstRunDialogs(getInstrumentation().getTargetContext()); - mAcccountsActivity = getActivity(); + @ClassRule public static DisableAnimationsRule disableAnimationsRule = new DisableAnimationsRule(); + + @Rule + public ActivityTestRule mActivityRule = new ActivityTestRule<>(AccountsActivity.class); - mDbHelper = new DatabaseHelper(mAcccountsActivity); + @BeforeClass + public static void prepTest(){ + preventFirstRunDialogs(GnuCashApplication.getAppContext()); + + String activeBookUID = BooksDbAdapter.getInstance().getActiveBookUID(); + mDbHelper = new DatabaseHelper(GnuCashApplication.getAppContext(), activeBookUID); try { mDb = mDbHelper.getWritableDatabase(); } catch (SQLException e) { - Log.e(getClass().getName(), "Error getting database: " + e.getMessage()); + Log.e("AccountsActivityTest", "Error getting database: " + e.getMessage()); mDb = mDbHelper.getReadableDatabase(); } - mSplitsDbAdapter = new SplitsDbAdapter(mDb); - mTransactionsDbAdapter = new TransactionsDbAdapter(mDb, mSplitsDbAdapter); - mAccountsDbAdapter = new AccountsDbAdapter(mDb, mTransactionsDbAdapter); + mSplitsDbAdapter = SplitsDbAdapter.getInstance(); + mTransactionsDbAdapter = TransactionsDbAdapter.getInstance(); + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); + CommoditiesDbAdapter commoditiesDbAdapter = new CommoditiesDbAdapter(mDb); //initialize commodity constants + } + + @Before + public void setUp() throws Exception { + mAccountsActivity = mActivityRule.getActivity(); +// testPreconditions(); + mAccountsDbAdapter.deleteAllRecords(); //clear the data - Account account = new Account(DUMMY_ACCOUNT_NAME); - account.setUID(DUMMY_ACCOUNT_UID); - account.setCommodity(Commodity.getInstance(DUMMY_ACCOUNT_CURRENCY_CODE)); - mAccountsDbAdapter.addRecord(account); + Account simpleAccount = new Account(SIMPLE_ACCOUNT_NAME); + simpleAccount.setUID(SIMPLE_ACCOUNT_UID); + simpleAccount.setCommodity(Commodity.getInstance(ACCOUNTS_CURRENCY_CODE)); + mAccountsDbAdapter.addRecord(simpleAccount, DatabaseAdapter.UpdateMethod.insert); + refreshAccountsList(); - } + } + /** * Prevents the first-run dialogs (Whats new, Create accounts etc) from being displayed when testing @@ -141,11 +175,13 @@ public static void preventFirstRunDialogs(Context context) { } + @TargetApi(Build.VERSION_CODES.HONEYCOMB) public void testDisplayAccountsList(){ - AccountsActivity.createDefaultAccounts("EUR", mAcccountsActivity); - mAcccountsActivity.recreate(); + AccountsActivity.createDefaultAccounts("EUR", mAccountsActivity); + mAccountsActivity.recreate(); refreshAccountsList(); + sleep(1000); onView(withText("Assets")).perform(scrollTo()); onView(withText("Expenses")).perform(click()); onView(withText("Books")).perform(scrollTo()); @@ -156,8 +192,8 @@ public void testSearchAccounts(){ String SEARCH_ACCOUNT_NAME = "Search Account"; Account account = new Account(SEARCH_ACCOUNT_NAME); - account.setParentUID(DUMMY_ACCOUNT_UID); - mAccountsDbAdapter.addRecord(account); + account.setParentUID(SIMPLE_ACCOUNT_UID); + mAccountsDbAdapter.addRecord(account, DatabaseAdapter.UpdateMethod.insert); //enter search query // ActionBarUtils.clickSherlockActionBarItem(mSolo, R.id.menu_search); @@ -166,14 +202,15 @@ public void testSearchAccounts(){ onView(withText(SEARCH_ACCOUNT_NAME)).check(matches(isDisplayed())); onView(withId(R.id.search_src_text)).perform(clearText()); - onView(withId(R.id.primary_text)).check(matches(not(withText(SEARCH_ACCOUNT_NAME)))); + onView(withText(SEARCH_ACCOUNT_NAME)).check(doesNotExist()); } /** * Tests that an account can be created successfully and that the account list is sorted alphabetically. */ @Test - public void testCreateAccount(){ + public void testCreateAccount(){ + assertThat(mAccountsDbAdapter.getAllRecords()).hasSize(1); onView(allOf(isDisplayed(), withId(R.id.fab_create_account))).perform(click()); String NEW_ACCOUNT_NAME = "A New Account"; @@ -183,38 +220,39 @@ public void testCreateAccount(){ .check(matches(isNotChecked())) .perform(click()); - onView(withId(R.id.checkbox_parent_account)).perform(scrollTo()) - .check(matches(allOf(isDisplayed(), isNotChecked()))) - .perform(click()); - onView(withId(R.id.menu_save)).perform(click()); - List accounts = mAccountsDbAdapter.getAllRecords(); + List accounts = mAccountsDbAdapter.getAllRecords(); assertThat(accounts).isNotNull(); assertThat(accounts).hasSize(2); - Account newestAccount = accounts.get(0); //because of alphabetical sorting + Account newestAccount = accounts.get(0); //because of alphabetical sorting - assertThat(newestAccount.getName()).isEqualTo(NEW_ACCOUNT_NAME); - assertThat(newestAccount.getCurrency().getCurrencyCode()).isEqualTo(Money.DEFAULT_CURRENCY_CODE); + assertThat(newestAccount.getName()).isEqualTo(NEW_ACCOUNT_NAME); + assertThat(newestAccount.getCommodity().getCurrencyCode()).isEqualTo(Money.DEFAULT_CURRENCY_CODE); assertThat(newestAccount.isPlaceholderAccount()).isTrue(); - } + } @Test public void testChangeParentAccount() { final String accountName = "Euro Account"; Account account = new Account(accountName, Commodity.EUR); - mAccountsDbAdapter.addRecord(account); + mAccountsDbAdapter.addRecord(account, DatabaseAdapter.UpdateMethod.insert); refreshAccountsList(); onView(withText(accountName)).perform(click()); - openActionBarOverflowOrOptionsMenu(mAcccountsActivity); + openActionBarOverflowOrOptionsMenu(mAccountsActivity); onView(withText(R.string.title_edit_account)).perform(click()); onView(withId(R.id.fragment_account_form)).check(matches(isDisplayed())); Espresso.closeSoftKeyboard(); onView(withId(R.id.checkbox_parent_account)).perform(scrollTo()) .check(matches(isNotChecked())) .perform(click()); + // FIXME: explicitly select the parent account + + onView(withId(R.id.input_parent_account)).check(matches(isEnabled())).perform(click()); + + onView(withText(SIMPLE_ACCOUNT_NAME)).perform(click()); onView(withId(R.id.menu_save)).perform(click()); @@ -222,7 +260,7 @@ public void testChangeParentAccount() { String parentUID = editedAccount.getParentUID(); assertThat(parentUID).isNotNull(); - assertThat(DUMMY_ACCOUNT_UID).isEqualTo(parentUID); + assertThat(parentUID).isEqualTo(SIMPLE_ACCOUNT_UID); } /** @@ -232,7 +270,7 @@ public void testChangeParentAccount() { */ @Test public void shouldHideParentAccountViewWhenNoParentsExist(){ - onView(allOf(withText(DUMMY_ACCOUNT_NAME), isDisplayed())).perform(click()); + onView(allOf(withText(SIMPLE_ACCOUNT_NAME), isDisplayed())).perform(click()); onView(withId(R.id.fragment_transaction_list)).perform(swipeRight()); onView(withId(R.id.fab_create_transaction)).check(matches(isDisplayed())).perform(click()); sleep(1000); @@ -252,54 +290,59 @@ public void shouldHideParentAccountViewWhenNoParentsExist(){ onView(withId(R.id.menu_save)).perform(click()); sleep(1000); //no sub-accounts - assertThat(mAccountsDbAdapter.getSubAccountCount(DUMMY_ACCOUNT_UID)).isEqualTo(0); + assertThat(mAccountsDbAdapter.getSubAccountCount(SIMPLE_ACCOUNT_UID)).isEqualTo(0); assertThat(mAccountsDbAdapter.getSubAccountCount(mAccountsDbAdapter.getOrCreateGnuCashRootAccountUID())).isEqualTo(2); assertThat(mAccountsDbAdapter.getSimpleAccountList()).extracting("mAccountType").contains(AccountType.TRADING); } @Test - public void testEditAccount(){ - String editedAccountName = "Edited Account"; - sleep(2000); - onView(withId(R.id.options_menu)).perform(click()); - onView(withText(R.string.title_edit_account)).perform(click()); + public void testEditAccount(){ + refreshAccountsList(); + onView(allOf(withParent(hasDescendant(withText(SIMPLE_ACCOUNT_NAME))), + withId(R.id.options_menu))).perform(click()); +// onView(withId(R.id.options_menu)).perform(click()); //there should only be one account visible + sleep(1000); + onView(withText(R.string.title_edit_account)).check(matches(isDisplayed())).perform(click()); +// onView(withId(R.id.context_menu_edit_accounts)).check(matches(isDisplayed())).perform(click()); onView(withId(R.id.fragment_account_form)).check(matches(isDisplayed())); + String editedAccountName = "An Edited Account"; onView(withId(R.id.input_account_name)).perform(clearText()).perform(typeText(editedAccountName)); onView(withId(R.id.menu_save)).perform(click()); - List accounts = mAccountsDbAdapter.getAllRecords(); - Account latest = accounts.get(0); //will be the first due to alphabetical sorting + List accounts = mAccountsDbAdapter.getAllRecords(); + Account latest = accounts.get(0); //will be the first due to alphabetical sorting assertThat(latest.getName()).isEqualTo(editedAccountName); - assertThat(latest.getCurrency().getCurrencyCode()).isEqualTo(DUMMY_ACCOUNT_CURRENCY_CODE); - } + assertThat(latest.getCommodity().getCurrencyCode()).isEqualTo(ACCOUNTS_CURRENCY_CODE); + } @Test public void editingAccountShouldNotDeleteTransactions(){ - onView(allOf(withId(R.id.options_menu), isDisplayed())) - .perform(click()); + onView(allOf(withParent(hasDescendant(withText(SIMPLE_ACCOUNT_NAME))), + withId(R.id.options_menu), + isDisplayed())).perform(click()); Account account = new Account("Transfer Account"); - account.setCommodity(Commodity.getInstance(DUMMY_ACCOUNT_CURRENCY.getCurrencyCode())); - Transaction transaction = new Transaction("Simple trxn"); - transaction.setCurrencyCode(DUMMY_ACCOUNT_CURRENCY.getCurrencyCode()); - Split split = new Split(new Money(BigDecimal.TEN, DUMMY_ACCOUNT_CURRENCY), account.getUID()); + account.setCommodity(Commodity.getInstance(ACCOUNTS_CURRENCY.getCurrencyCode())); + Transaction transaction = new Transaction("Simple transaction"); + transaction.setCurrencyCode(ACCOUNTS_CURRENCY.getCurrencyCode()); + Split split = new Split(new Money(BigDecimal.TEN, ACCOUNTS_CURRENCY), account.getUID()); transaction.addSplit(split); - transaction.addSplit(split.createPair(DUMMY_ACCOUNT_UID)); + transaction.addSplit(split.createPair(SIMPLE_ACCOUNT_UID)); account.addTransaction(transaction); - mAccountsDbAdapter.addRecord(account); + mAccountsDbAdapter.addRecord(account, DatabaseAdapter.UpdateMethod.insert); - assertThat(mAccountsDbAdapter.getRecord(DUMMY_ACCOUNT_UID).getTransactionCount()).isEqualTo(1); + assertThat(mAccountsDbAdapter.getRecord(SIMPLE_ACCOUNT_UID).getTransactionCount()).isEqualTo(1); assertThat(mSplitsDbAdapter.getSplitsForTransaction(transaction.getUID())).hasSize(2); onView(withText(R.string.title_edit_account)).perform(click()); onView(withId(R.id.menu_save)).perform(click()); - assertThat(mAccountsDbAdapter.getRecord(DUMMY_ACCOUNT_UID).getTransactionCount()).isEqualTo(1); - assertThat(mSplitsDbAdapter.fetchSplitsForAccount(DUMMY_ACCOUNT_UID).getCount()).isEqualTo(1); + assertThat(mAccountsDbAdapter.getRecord(SIMPLE_ACCOUNT_UID).getTransactionCount()).isEqualTo(1); + assertThat(mSplitsDbAdapter.fetchSplitsForAccount(SIMPLE_ACCOUNT_UID).getCount()).isEqualTo(1); assertThat(mSplitsDbAdapter.getSplitsForTransaction(transaction.getUID())).hasSize(2); } @@ -316,86 +359,153 @@ private void sleep(long millis) { } } - //TODO: Add test for moving content of accounts before deleting it - @Test(expected = IllegalArgumentException.class) - public void testDeleteSimpleAccount() { - sleep(2000); - onView(withId(R.id.options_menu)).perform(click()); + public void testDeleteSimpleAccount() { + refreshAccountsList(); + assertThat(mAccountsDbAdapter.getRecordsCount()).isEqualTo(2); + onView(allOf(withParent(hasDescendant(withText(SIMPLE_ACCOUNT_NAME))), + withId(R.id.options_menu))).perform(click()); + + onView(withText(R.string.menu_delete)).perform(click()); + + assertThat(mAccountsDbAdapter.getRecordsCount()).isEqualTo(1); + + List accounts = mAccountsDbAdapter.getAllRecords(); + assertThat(accounts).hasSize(0); //root account is never returned + } + + @Test + public void testDeleteAccountWithSubaccounts() { + refreshAccountsList(); + Account account = new Account("Sub-account"); + account.setParentUID(SIMPLE_ACCOUNT_UID); + account.setUID(CHILD_ACCOUNT_UID); + mAccountsDbAdapter.addRecord(account); + + refreshAccountsList(); + + onView(allOf(withParent(hasDescendant(withText(SIMPLE_ACCOUNT_NAME))), + withId(R.id.options_menu))).perform(click()); + onView(withText(R.string.menu_delete)).perform(click()); + + onView(allOf(withParent(withId(R.id.accounts_options)), + withId(R.id.radio_delete))).perform(click()); + onView(withText(R.string.alert_dialog_ok_delete)).perform(click()); + + assertThat(accountExists(SIMPLE_ACCOUNT_UID)).isFalse(); + assertThat(accountExists(CHILD_ACCOUNT_UID)).isFalse(); + } + + @Test + public void testDeleteAccountMovingSubaccounts() { + long accountCount = mAccountsDbAdapter.getRecordsCount(); + Account subAccount = new Account("Child account"); + subAccount.setParentUID(SIMPLE_ACCOUNT_UID); + + Account tranferAcct = new Account("Other account"); + mAccountsDbAdapter.addRecord(subAccount, DatabaseAdapter.UpdateMethod.insert); + mAccountsDbAdapter.addRecord(tranferAcct, DatabaseAdapter.UpdateMethod.insert); + + assertThat(mAccountsDbAdapter.getRecordsCount()).isEqualTo(accountCount+2); + + refreshAccountsList(); + + onView(allOf(withParent(hasDescendant(withText(SIMPLE_ACCOUNT_NAME))), + withId(R.id.options_menu))).perform(click()); onView(withText(R.string.menu_delete)).perform(click()); - //the account has no sub-accounts -// onView(withId(R.id.accounts_options)).check(matches(not(isDisplayed()))); -// onView(withId(R.id.transactions_options)).check(matches(isDisplayed())); + //// FIXME: 17.08.2016 This enabled check fails during some test runs - not reliable, investigate why + onView(allOf(withParent(withId(R.id.accounts_options)), + withId(R.id.radio_move))).check(matches(isEnabled())).perform(click()); + + onView(withText(R.string.alert_dialog_ok_delete)).perform(click()); -// onView(withText(R.string.label_delete_transactions)).perform(click()); -// onView(withId(R.id.btn_save)).perform(click()); + assertThat(accountExists(SIMPLE_ACCOUNT_UID)).isFalse(); + assertThat(accountExists(subAccount.getUID())).isTrue(); - //should throw expected exception - mAccountsDbAdapter.getID(DUMMY_ACCOUNT_UID);; + String newParentUID = mAccountsDbAdapter.getParentAccountUID(subAccount.getUID()); + assertThat(newParentUID).isEqualTo(tranferAcct.getUID()); + } + + /** + * Checks if an account exists in the database + * @param accountUID GUID of the account + * @return {@code true} if the account exists, {@code false} otherwise + */ + private boolean accountExists(String accountUID) { + try { + mAccountsDbAdapter.getID(accountUID); + return true; + } catch (IllegalArgumentException e) { + return false; + } } - //TODO: Test import of account file + //TODO: Test import of account file //TODO: test settings activity @Test - public void testIntentAccountCreation(){ - Intent intent = new Intent(Intent.ACTION_INSERT); + public void testIntentAccountCreation(){ + Intent intent = new Intent(Intent.ACTION_INSERT); intent.putExtra(Intent.EXTRA_TITLE, "Intent Account"); intent.putExtra(Intent.EXTRA_UID, "intent-account"); intent.putExtra(Account.EXTRA_CURRENCY_CODE, "EUR"); intent.setType(Account.MIME_TYPE); - new AccountCreator().onReceive(mAcccountsActivity, intent); + new AccountCreator().onReceive(mAccountsActivity, intent); - Account account = mAccountsDbAdapter.getRecord("intent-account"); - assertThat(account).isNotNull(); + Account account = mAccountsDbAdapter.getRecord("intent-account"); + assertThat(account).isNotNull(); assertThat(account.getName()).isEqualTo("Intent Account"); assertThat(account.getUID()).isEqualTo("intent-account"); - assertThat(account.getCurrency().getCurrencyCode()).isEqualTo("EUR"); - } + assertThat(account.getCommodity().getCurrencyCode()).isEqualTo("EUR"); + } /** * Tests that the setup wizard is displayed on first run */ + @TargetApi(Build.VERSION_CODES.HONEYCOMB) @Test public void shouldShowWizardOnFirstRun() throws Throwable { - PreferenceManager.getDefaultSharedPreferences(mAcccountsActivity) - .edit() - .remove(mAcccountsActivity.getString(R.string.key_first_run)) - .commit(); + Editor editor = PreferenceManager.getDefaultSharedPreferences(mAccountsActivity) + .edit(); + //commit for immediate effect + editor.remove(mAccountsActivity.getString(R.string.key_first_run)).commit(); + - runTestOnUiThread(new Runnable() { + mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { - mAcccountsActivity.recreate(); + mAccountsActivity.recreate(); } }); //check that wizard is shown - onView(withText(mAcccountsActivity.getString(R.string.title_setup_gnucash))) + onView(withText(mAccountsActivity.getString(R.string.title_setup_gnucash))) .check(matches(isDisplayed())); + + editor.putBoolean(mAccountsActivity.getString(R.string.key_first_run), false).apply(); } - @After - public void tearDown() throws Exception { - mAcccountsActivity.finish(); - super.tearDown(); - } + @After + public void tearDown() throws Exception { + if (mAccountsActivity != null) { + mAccountsActivity.finish(); + } + } /** * Refresh the account list fragment */ private void refreshAccountsList(){ try { - runTestOnUiThread(new Runnable() { + mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { - Fragment fragment = mAcccountsActivity.getCurrentAccountListFragment(); + Fragment fragment = mAccountsActivity.getCurrentAccountListFragment(); ((AccountsListFragment) fragment).refresh(); } }); } catch (Throwable throwable) { System.err.println("Failed to refresh fragment"); } - } } diff --git a/app/src/androidTest/java/org/gnucash/android/test/ui/CalculatorEditTextTest.java b/app/src/androidTest/java/org/gnucash/android/test/ui/CalculatorEditTextTest.java index 141ca5c22..08c687313 100644 --- a/app/src/androidTest/java/org/gnucash/android/test/ui/CalculatorEditTextTest.java +++ b/app/src/androidTest/java/org/gnucash/android/test/ui/CalculatorEditTextTest.java @@ -19,22 +19,29 @@ import android.content.Intent; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; -import android.support.test.InstrumentationRegistry; +import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; -import android.test.ActivityInstrumentationTestCase2; import android.util.Log; import org.gnucash.android.R; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.app.GnuCashApplication; import org.gnucash.android.db.DatabaseHelper; -import org.gnucash.android.db.SplitsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.Account; import org.gnucash.android.model.Commodity; +import org.gnucash.android.test.ui.util.DisableAnimationsRule; import org.gnucash.android.ui.common.UxArgument; import org.gnucash.android.ui.transaction.TransactionsActivity; import org.junit.After; +import org.junit.AfterClass; import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -48,8 +55,7 @@ // TODO: Find out how to press the keys in the KeyboardView. @RunWith(AndroidJUnit4.class) -public class CalculatorEditTextTest extends - ActivityInstrumentationTestCase2 { +public class CalculatorEditTextTest { private static final String DUMMY_ACCOUNT_UID = "transactions-account"; private static final String DUMMY_ACCOUNT_NAME = "Transactions Account"; @@ -57,52 +63,68 @@ public class CalculatorEditTextTest extends private static final String TRANSFER_ACCOUNT_UID = "transfer_account"; public static final String CURRENCY_CODE = "USD"; - private SQLiteDatabase mDb; - private DatabaseHelper mDbHelper; - private AccountsDbAdapter mAccountsDbAdapter; - private TransactionsDbAdapter mTransactionsDbAdapter; - private SplitsDbAdapter mSplitsDbAdapter; + private static DatabaseHelper mDbHelper; + private static AccountsDbAdapter mAccountsDbAdapter; + private static TransactionsDbAdapter mTransactionsDbAdapter; + private static SplitsDbAdapter mSplitsDbAdapter; private TransactionsActivity mTransactionsActivity; public CalculatorEditTextTest() { - super(TransactionsActivity.class); } - - @Override - @Before - public void setUp() throws Exception { - super.setUp(); - injectInstrumentation(InstrumentationRegistry.getInstrumentation()); - AccountsActivityTest.preventFirstRunDialogs(getInstrumentation().getTargetContext()); + @ClassRule + public static DisableAnimationsRule disableAnimationsRule = new DisableAnimationsRule(); + + @Rule + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(TransactionsActivity.class, true, false); + + + @BeforeClass + public static void prepTestCase(){ + String activeBookUID = BooksDbAdapter.getInstance().getActiveBookUID(); + mDbHelper = new DatabaseHelper(GnuCashApplication.getAppContext(), activeBookUID); - mDbHelper = new DatabaseHelper(getInstrumentation().getTargetContext()); + SQLiteDatabase mDb; try { mDb = mDbHelper.getWritableDatabase(); } catch (SQLException e) { - Log.e(getClass().getName(), "Error getting database: " + e.getMessage()); + Log.e("CalculatorEditTextTest", "Error getting database: " + e.getMessage()); mDb = mDbHelper.getReadableDatabase(); } - mSplitsDbAdapter = new SplitsDbAdapter(mDb); - mTransactionsDbAdapter = new TransactionsDbAdapter(mDb, mSplitsDbAdapter); - mAccountsDbAdapter = new AccountsDbAdapter(mDb, mTransactionsDbAdapter); - mAccountsDbAdapter.deleteAllRecords(); +// mSplitsDbAdapter = new SplitsDbAdapter(mDb); +// mTransactionsDbAdapter = new TransactionsDbAdapter(mDb, mSplitsDbAdapter); +// mAccountsDbAdapter = new AccountsDbAdapter(mDb, mTransactionsDbAdapter); + + mSplitsDbAdapter = SplitsDbAdapter.getInstance(); + mTransactionsDbAdapter = TransactionsDbAdapter.getInstance(); + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); + + AccountsActivityTest.preventFirstRunDialogs(GnuCashApplication.getAppContext()); + } - Account account = new Account(DUMMY_ACCOUNT_NAME); + @Before + public void setUp() throws Exception { + + mAccountsDbAdapter.deleteAllRecords(); + + CommoditiesDbAdapter commoditiesDbAdapter = CommoditiesDbAdapter.getInstance(); + Commodity commodity = commoditiesDbAdapter.getCommodity(CURRENCY_CODE); + + Account account = new Account(DUMMY_ACCOUNT_NAME, commodity); account.setUID(DUMMY_ACCOUNT_UID); - account.setCommodity(Commodity.getInstance(CURRENCY_CODE)); - Account account2 = new Account(TRANSFER_ACCOUNT_NAME); + Account account2 = new Account(TRANSFER_ACCOUNT_NAME, commodity); account2.setUID(TRANSFER_ACCOUNT_UID); - account2.setCommodity(Commodity.getInstance(CURRENCY_CODE)); mAccountsDbAdapter.addRecord(account); mAccountsDbAdapter.addRecord(account2); Intent intent = new Intent(Intent.ACTION_VIEW); intent.putExtra(UxArgument.SELECTED_ACCOUNT_UID, DUMMY_ACCOUNT_UID); - setActivityIntent(intent); - mTransactionsActivity = getActivity(); + mActivityRule.launchActivity(intent); + mTransactionsActivity = mActivityRule.getActivity(); + } /** @@ -137,10 +159,15 @@ private void clickOnView(int viewId){ onView(withId(viewId)).perform(click()); } - @Override @After public void tearDown() throws Exception { - mTransactionsActivity.finish(); - super.tearDown(); + if (mTransactionsActivity != null) + mTransactionsActivity.finish(); } + + @AfterClass + public static void cleanup(){ + if (mDbHelper != null) + mDbHelper.close(); + } } diff --git a/app/src/androidTest/java/org/gnucash/android/test/ui/ExportTransactionsTest.java b/app/src/androidTest/java/org/gnucash/android/test/ui/ExportTransactionsTest.java index 05c9bbedc..c641bce18 100644 --- a/app/src/androidTest/java/org/gnucash/android/test/ui/ExportTransactionsTest.java +++ b/app/src/androidTest/java/org/gnucash/android/test/ui/ExportTransactionsTest.java @@ -18,35 +18,41 @@ import android.Manifest; import android.app.AlertDialog; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import android.os.Build; -import android.preference.PreferenceManager; import android.support.test.InstrumentationRegistry; import android.support.test.espresso.contrib.DrawerActions; import android.support.test.espresso.matcher.ViewMatchers; import android.support.test.runner.AndroidJUnit4; +import android.support.v7.preference.PreferenceManager; import android.test.ActivityInstrumentationTestCase2; import android.util.Log; import android.widget.CompoundButton; import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; import org.gnucash.android.db.DatabaseHelper; -import org.gnucash.android.db.ScheduledActionDbAdapter; -import org.gnucash.android.db.SplitsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.ScheduledActionDbAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.export.ExportFormat; import org.gnucash.android.export.Exporter; import org.gnucash.android.model.Account; +import org.gnucash.android.model.Commodity; import org.gnucash.android.model.Money; import org.gnucash.android.model.PeriodType; import org.gnucash.android.model.ScheduledAction; import org.gnucash.android.model.Split; import org.gnucash.android.model.Transaction; import org.gnucash.android.ui.account.AccountsActivity; +import org.gnucash.android.ui.settings.PreferenceActivity; import org.junit.After; import org.junit.Before; import org.junit.FixMethodOrder; @@ -60,6 +66,7 @@ import static android.support.test.espresso.Espresso.onView; import static android.support.test.espresso.action.ViewActions.click; +import static android.support.test.espresso.action.ViewActions.swipeUp; import static android.support.test.espresso.assertion.ViewAssertions.matches; import static android.support.test.espresso.matcher.RootMatchers.withDecorView; import static android.support.test.espresso.matcher.ViewMatchers.isAssignableFrom; @@ -89,7 +96,7 @@ public class ExportTransactionsTest extends public ExportTransactionsTest() { super(AccountsActivity.class); } - + @Override @Before public void setUp() throws Exception { @@ -98,30 +105,38 @@ public void setUp() throws Exception { AccountsActivityTest.preventFirstRunDialogs(getInstrumentation().getTargetContext()); mAcccountsActivity = getActivity(); - mDbHelper = new DatabaseHelper(getActivity()); + String activeBookUID = BooksDbAdapter.getInstance().getActiveBookUID(); + mDbHelper = new DatabaseHelper(getActivity(), activeBookUID); try { mDb = mDbHelper.getWritableDatabase(); } catch (SQLException e) { Log.e(getClass().getName(), "Error getting database: " + e.getMessage()); mDb = mDbHelper.getReadableDatabase(); } - mSplitsDbAdapter = new SplitsDbAdapter(mDb); - mTransactionsDbAdapter = new TransactionsDbAdapter(mDb, mSplitsDbAdapter); - mAccountsDbAdapter = new AccountsDbAdapter(mDb, mTransactionsDbAdapter); + + mSplitsDbAdapter = SplitsDbAdapter.getInstance(); + mTransactionsDbAdapter = TransactionsDbAdapter.getInstance(); + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); + + //this call initializes the static variables like DEFAULT_COMMODITY which are used implicitly by accounts/transactions + @SuppressWarnings("unused") + CommoditiesDbAdapter commoditiesDbAdapter = new CommoditiesDbAdapter(mDb); + String currencyCode = GnuCashApplication.getDefaultCurrencyCode(); + Commodity.DEFAULT_COMMODITY = CommoditiesDbAdapter.getInstance().getCommodity(currencyCode); + mAccountsDbAdapter.deleteAllRecords(); Account account = new Account("Exportable"); Transaction transaction = new Transaction("Pizza"); transaction.setNote("What up?"); transaction.setTime(System.currentTimeMillis()); - String currencyCode = GnuCashApplication.getDefaultCurrencyCode(); Split split = new Split(new Money("8.99", currencyCode), account.getUID()); split.setMemo("Hawaii is the best!"); transaction.addSplit(split); transaction.addSplit(split.createPair(mAccountsDbAdapter.getOrCreateImbalanceAccountUID(Currency.getInstance(currencyCode)))); account.addTransaction(transaction); - mAccountsDbAdapter.addRecord(account); + mAccountsDbAdapter.addRecord(account, DatabaseAdapter.UpdateMethod.insert); } @@ -133,27 +148,27 @@ public void setUp() throws Exception { */ @Test public void testOfxExport(){ - PreferenceManager.getDefaultSharedPreferences(mAcccountsActivity) - .edit().putBoolean(mAcccountsActivity.getString(R.string.key_use_double_entry), false) + SharedPreferences.Editor prefsEditor = PreferenceActivity.getActiveBookSharedPreferences() + .edit(); + prefsEditor.putBoolean(mAcccountsActivity.getString(R.string.key_use_double_entry), false) .commit(); testExport(ExportFormat.OFX); - PreferenceManager.getDefaultSharedPreferences(mAcccountsActivity) - .edit().putBoolean(mAcccountsActivity.getString(R.string.key_use_double_entry), true) + prefsEditor.putBoolean(mAcccountsActivity.getString(R.string.key_use_double_entry), true) .commit(); } @Test public void whenInSingleEntry_shouldHideXmlExportOption(){ - PreferenceManager.getDefaultSharedPreferences(mAcccountsActivity) - .edit().putBoolean(mAcccountsActivity.getString(R.string.key_use_double_entry), false) + SharedPreferences.Editor prefsEditor = PreferenceActivity.getActiveBookSharedPreferences() + .edit(); + prefsEditor.putBoolean(mAcccountsActivity.getString(R.string.key_use_double_entry), false) .commit(); DrawerActions.openDrawer(R.id.drawer_layout); onView(withText(R.string.nav_menu_export)).perform(click()); onView(withId(R.id.radio_xml_format)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))); - PreferenceManager.getDefaultSharedPreferences(mAcccountsActivity) - .edit().putBoolean(mAcccountsActivity.getString(R.string.key_use_double_entry), true) + prefsEditor.putBoolean(mAcccountsActivity.getString(R.string.key_use_double_entry), true) .commit(); } @@ -185,7 +200,7 @@ public void testExport(ExportFormat format){ } } - File folder = new File(Exporter.EXPORT_FOLDER_PATH); + File folder = new File(Exporter.getExportFolderPath(BooksDbAdapter.getInstance().getActiveBookUID())); folder.mkdirs(); assertThat(folder).exists(); @@ -193,7 +208,7 @@ public void testExport(ExportFormat format){ file.delete(); } - DrawerActions.openDrawer(R.id.drawer_layout); + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()); onView(withText(R.string.nav_menu_export)).perform(click()); onView(withId(R.id.spinner_export_destination)).perform(click()); @@ -213,14 +228,21 @@ public void testExport(ExportFormat format){ public void testDeleteTransactionsAfterExport(){ assertThat(mTransactionsDbAdapter.getRecordsCount()).isGreaterThan(0); - PreferenceManager.getDefaultSharedPreferences(getActivity()).edit() - .putBoolean(mAcccountsActivity.getString(R.string.key_delete_transactions_after_export), true).commit(); + SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(getActivity()).edit(); //PreferenceActivity.getActiveBookSharedPreferences(getActivity()).edit(); + editor.putBoolean(mAcccountsActivity.getString(R.string.key_delete_transactions_after_export), true); + editor.commit(); + + PreferenceActivity.getActiveBookSharedPreferences() + .edit() + .putBoolean(mAcccountsActivity.getString(R.string.key_use_double_entry), true) + .apply(); testExport(ExportFormat.XML); assertThat(mTransactionsDbAdapter.getRecordsCount()).isEqualTo(0); - PreferenceManager.getDefaultSharedPreferences(getActivity()).edit() - .putBoolean(mAcccountsActivity.getString(R.string.key_delete_transactions_after_export), false).commit(); + List transactions = mTransactionsDbAdapter.getAllTransactions(); + + editor.putBoolean(mAcccountsActivity.getString(R.string.key_delete_transactions_after_export), false).commit(); } /** @@ -229,7 +251,7 @@ public void testDeleteTransactionsAfterExport(){ */ @Test public void testShouldCreateExportSchedule(){ - DrawerActions.openDrawer(R.id.drawer_layout); + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()); onView(withText(R.string.nav_menu_export)).perform(click()); onView(withText(ExportFormat.XML.name())).perform(click()); @@ -237,23 +259,24 @@ public void testShouldCreateExportSchedule(){ //switch on recurrence dialog onView(allOf(isAssignableFrom(CompoundButton.class), isDisplayed(), isEnabled())).perform(click()); - onView(withText("Done")).perform(click()); + onView(withText("OK")).perform(click()); onView(withId(R.id.menu_save)).perform(click()); - ScheduledActionDbAdapter scheduledactionDbAdapter = new ScheduledActionDbAdapter(mDb); + ScheduledActionDbAdapter scheduledactionDbAdapter = ScheduledActionDbAdapter.getInstance(); //new ScheduledActionDbAdapter(mDb, new RecurrenceDbAdapter(mDb)); List scheduledActions = scheduledactionDbAdapter.getAllEnabledScheduledActions(); assertThat(scheduledActions) .hasSize(1) .extracting("mActionType").contains(ScheduledAction.ActionType.BACKUP); ScheduledAction action = scheduledActions.get(0); - assertThat(action.getPeriodType()).isEqualTo(PeriodType.WEEK); + assertThat(action.getRecurrence().getPeriodType()).isEqualTo(PeriodType.WEEK); assertThat(action.getEndTime()).isEqualTo(0); } @Test public void testCreateBackup(){ - DrawerActions.openDrawer(R.id.drawer_layout); + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()); + onView(withId(R.id.nav_view)).perform(swipeUp()); onView(withText(R.string.title_settings)).perform(click()); onView(withText(R.string.header_backup_and_export_settings)).perform(click()); diff --git a/app/src/androidTest/java/org/gnucash/android/test/ui/FirstRunWizardActivityTest.java b/app/src/androidTest/java/org/gnucash/android/test/ui/FirstRunWizardActivityTest.java index 96de31a91..2e0334859 100644 --- a/app/src/androidTest/java/org/gnucash/android/test/ui/FirstRunWizardActivityTest.java +++ b/app/src/androidTest/java/org/gnucash/android/test/ui/FirstRunWizardActivityTest.java @@ -24,10 +24,11 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; import org.gnucash.android.db.DatabaseHelper; -import org.gnucash.android.db.SplitsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; +import org.gnucash.android.model.BaseModel; import org.gnucash.android.ui.wizard.FirstRunWizardActivity; import org.junit.Before; import org.junit.Test; @@ -65,7 +66,7 @@ public void setUp() throws Exception { injectInstrumentation(InstrumentationRegistry.getInstrumentation()); mActivity = getActivity(); - mDbHelper = new DatabaseHelper(mActivity); + mDbHelper = new DatabaseHelper(mActivity, BaseModel.generateUID()); try { mDb = mDbHelper.getWritableDatabase(); } catch (SQLException e) { diff --git a/app/src/androidTest/java/org/gnucash/android/test/ui/MultiBookTest.java b/app/src/androidTest/java/org/gnucash/android/test/ui/MultiBookTest.java new file mode 100644 index 000000000..02809a01a --- /dev/null +++ b/app/src/androidTest/java/org/gnucash/android/test/ui/MultiBookTest.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2016 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.gnucash.android.test.ui; + +import android.support.test.espresso.contrib.DrawerActions; +import android.support.test.espresso.intent.Intents; +import android.support.test.espresso.intent.rule.IntentsTestRule; +import android.support.test.runner.AndroidJUnit4; + +import org.gnucash.android.R; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.model.Book; +import org.gnucash.android.test.ui.util.DisableAnimationsRule; +import org.gnucash.android.ui.account.AccountsActivity; +import org.gnucash.android.ui.settings.PreferenceActivity; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static android.support.test.espresso.Espresso.onView; +import static android.support.test.espresso.action.ViewActions.click; +import static android.support.test.espresso.action.ViewActions.swipeUp; +import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test support for multiple books in the application + */ +@RunWith(AndroidJUnit4.class) +public class MultiBookTest { + + private static BooksDbAdapter mBooksDbAdapter; + + @ClassRule + public static DisableAnimationsRule disableAnimationsRule = new DisableAnimationsRule(); + + @Rule + public IntentsTestRule mActivityRule = new IntentsTestRule<>(AccountsActivity.class); + + @BeforeClass + public static void prepTestCase(){ + mBooksDbAdapter = BooksDbAdapter.getInstance(); + } + + @Test + public void shouldOpenBookManager(){ + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()); + onView(withId(R.id.book_name)).check(matches(isDisplayed())).perform(click()); + + onView(withText(R.string.menu_manage_books)).perform(click()); + + Intents.intended(hasComponent(PreferenceActivity.class.getName())); + } + + public void testLoadBookFromBookManager(){ + Book book = new Book(); + book.setDisplayName("Launch Codes"); + BooksDbAdapter.getInstance().addRecord(book); + + shouldOpenBookManager(); + onView(withText(book.getDisplayName())).perform(click()); + + assertThat(BooksDbAdapter.getInstance().getActiveBookUID()).isEqualTo(book.getUID()); + } + + @Test + public void creatingNewAccounts_shouldCreatedNewBook(){ + long booksCount = mBooksDbAdapter.getRecordsCount(); + + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()); + onView(withId(R.id.drawer_layout)).perform(swipeUp()); + onView(withText(R.string.title_settings)).perform(click()); + + Intents.intended(hasComponent(PreferenceActivity.class.getName())); + + onView(withText(R.string.header_account_settings)).perform(click()); + onView(withText(R.string.title_create_default_accounts)).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + + //// TODO: 18.05.2016 wait for import to finish instead + sleep(2000); //give import time to finish + + assertThat(mBooksDbAdapter.getRecordsCount()).isEqualTo(booksCount+1); + + //// TODO: 25.08.2016 Delete all books before the start of this test + Book activeBook = mBooksDbAdapter.getRecord(mBooksDbAdapter.getActiveBookUID()); + assertThat(activeBook.getDisplayName()).isEqualTo("Book " + (booksCount+1)); + } + + @Test + public void testCreateNewBook(){ + long bookCount = mBooksDbAdapter.getRecordsCount(); + + shouldOpenBookManager(); + + onView(withId(R.id.menu_create_book)) + .check(matches(isDisplayed())) + .perform(click()); + + assertThat(mBooksDbAdapter.getRecordsCount()).isEqualTo(bookCount+1); + } + + private static void sleep(long millis){ + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } +} diff --git a/app/src/androidTest/java/org/gnucash/android/test/ui/OwnCloudExportTest.java b/app/src/androidTest/java/org/gnucash/android/test/ui/OwnCloudExportTest.java new file mode 100644 index 000000000..c40414e8f --- /dev/null +++ b/app/src/androidTest/java/org/gnucash/android/test/ui/OwnCloudExportTest.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2016 Felipe Morato + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.test.ui; + +import android.content.Context; +import android.content.SharedPreferences; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.support.test.espresso.contrib.DrawerActions; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; +import android.test.ActivityInstrumentationTestCase2; +import android.util.Log; + +import org.gnucash.android.R; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseHelper; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.model.Account; +import org.gnucash.android.model.Commodity; +import org.gnucash.android.model.Money; +import org.gnucash.android.model.Split; +import org.gnucash.android.model.Transaction; +import org.gnucash.android.ui.account.AccountsActivity; +import org.junit.Assume; +import org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; + +import java.util.Currency; + +import static android.support.test.InstrumentationRegistry.getInstrumentation; +import static android.support.test.espresso.Espresso.onView; +import static android.support.test.espresso.action.ViewActions.clearText; +import static android.support.test.espresso.action.ViewActions.click; +import static android.support.test.espresso.action.ViewActions.closeSoftKeyboard; +import static android.support.test.espresso.action.ViewActions.swipeUp; +import static android.support.test.espresso.action.ViewActions.typeText; +import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.matcher.RootMatchers.withDecorView; +import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertTrue; +import static org.gnucash.android.test.ui.AccountsActivityTest.preventFirstRunDialogs; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + + +@RunWith(AndroidJUnit4.class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class OwnCloudExportTest { + + private AccountsActivity mAccountsActivity; + private SharedPreferences mPrefs; + + private String OC_SERVER = "https://demo.owncloud.org"; + private String OC_USERNAME = "test"; + private String OC_PASSWORD = "test"; + private String OC_DIR = "gc_test"; + + /** + * A JUnit {@link Rule @Rule} to launch your activity under test. This is a replacement + * for {@link ActivityInstrumentationTestCase2}. + *

+ * Rules are interceptors which are executed for each test method and will run before + * any of your setup code in the {@link Before @Before} method. + *

+ * {@link ActivityTestRule} will create and launch of the activity for you and also expose + * the activity under test. To get a reference to the activity you can use + * the {@link ActivityTestRule#getActivity()} method. + */ + @Rule + public ActivityTestRule mActivityRule = new ActivityTestRule<>( + AccountsActivity.class); + + + @Before + public void setUp() throws Exception { + + mAccountsActivity = mActivityRule.getActivity(); + mPrefs = mAccountsActivity.getSharedPreferences( + mAccountsActivity.getString(R.string.owncloud_pref), Context.MODE_PRIVATE); + + preventFirstRunDialogs(getInstrumentation().getTargetContext()); + + // creates Account and transaction + String activeBookUID = BooksDbAdapter.getInstance().getActiveBookUID(); + DatabaseHelper mDbHelper = new DatabaseHelper(mAccountsActivity, activeBookUID); + SQLiteDatabase mDb; + try { + mDb = mDbHelper.getWritableDatabase(); + } catch (SQLException e) { + Log.e(getClass().getName(), "Error getting database: " + e.getMessage()); + mDb = mDbHelper.getReadableDatabase(); + } + + @SuppressWarnings("unused") //this call initializes constants in Commodity + CommoditiesDbAdapter commoditiesDbAdapter = new CommoditiesDbAdapter(mDb); + AccountsDbAdapter mAccountsDbAdapter = AccountsDbAdapter.getInstance(); + mAccountsDbAdapter.deleteAllRecords(); + + String currencyCode = GnuCashApplication.getDefaultCurrencyCode(); + Commodity.DEFAULT_COMMODITY = CommoditiesDbAdapter.getInstance().getCommodity(currencyCode); + + Account account = new Account("ownCloud"); + Transaction transaction = new Transaction("birds"); + transaction.setTime(System.currentTimeMillis()); + Split split = new Split(new Money("11.11", currencyCode), account.getUID()); + transaction.addSplit(split); + transaction.addSplit(split.createPair( + mAccountsDbAdapter.getOrCreateImbalanceAccountUID(Currency.getInstance(currencyCode)))); + account.addTransaction(transaction); + + mAccountsDbAdapter.addRecord(account, DatabaseAdapter.UpdateMethod.insert); + + + SharedPreferences.Editor editor = mPrefs.edit(); + + editor.putBoolean(mAccountsActivity.getString(R.string.key_owncloud_sync), false).apply(); + editor.putInt(mAccountsActivity.getString(R.string.key_last_export_destination), 0); + editor.apply(); + } + + /** + * Test if there is an active internet connection on the device/emulator + * @return {@code true} is an internet connection is available, {@code false} otherwise + */ + public static boolean hasActiveInternetConnection(){ + ConnectivityManager connectivityManager + = (ConnectivityManager) GnuCashApplication.getAppContext().getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); + return activeNetworkInfo != null && activeNetworkInfo.isConnected(); + } + + /** + * It might fail if it takes too long to connect to the server or if there is no network + */ + @Test + public void OwnCloudCredentials() { + Assume.assumeTrue(hasActiveInternetConnection()); + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()); + onView(withId(R.id.nav_view)).perform(swipeUp()); + onView(withText(R.string.title_settings)).perform(click()); + onView(withText(R.string.header_backup_and_export_settings)).perform(click()); + onView(withText(R.string.title_owncloud_sync_preference)).perform(click()); + onView(withId(R.id.owncloud_hostname)).check(matches(isDisplayed())); + + onView(withId(R.id.owncloud_hostname)).perform(clearText()).perform(typeText(OC_SERVER), closeSoftKeyboard()); + onView(withId(R.id.owncloud_username)).perform(clearText()).perform(typeText(OC_USERNAME), closeSoftKeyboard()); + onView(withId(R.id.owncloud_password)).perform(clearText()).perform(typeText(OC_PASSWORD), closeSoftKeyboard()); + onView(withId(R.id.owncloud_dir)).perform(clearText()).perform(typeText(OC_DIR), closeSoftKeyboard()); + onView(withId(R.id.btn_save)).perform(click()); + sleep(5000); + onView(withId(R.id.btn_save)).perform(click()); + + assertEquals(mPrefs.getString(mAccountsActivity.getString(R.string.key_owncloud_server), null), OC_SERVER); + assertEquals(mPrefs.getString(mAccountsActivity.getString(R.string.key_owncloud_username), null), OC_USERNAME); + assertEquals(mPrefs.getString(mAccountsActivity.getString(R.string.key_owncloud_password), null), OC_PASSWORD); + assertEquals(mPrefs.getString(mAccountsActivity.getString(R.string.key_owncloud_dir), null), OC_DIR); + + assertTrue(mPrefs.getBoolean(mAccountsActivity.getString(R.string.key_owncloud_sync), false)); + } + + @Test + public void OwnCloudExport() { + Assume.assumeTrue(hasActiveInternetConnection()); + mPrefs.edit().putBoolean(mAccountsActivity.getString(R.string.key_owncloud_sync), true).commit(); + + onView(withId(R.id.drawer_layout)).perform(DrawerActions.open()); + onView(withText(R.string.nav_menu_export)).perform(click()); + onView(withId(R.id.spinner_export_destination)).perform(click()); + String[] destinations = mAccountsActivity.getResources().getStringArray(R.array.export_destinations); + onView(withText(destinations[3])).perform(click()); + onView(withId(R.id.menu_save)).perform(click()); +// onView(withSpinnerText( +// mAccountsActivity.getResources().getStringArray(R.array.export_destinations)[3])) +// .perform(click()); + assertToastDisplayed(String.format(mAccountsActivity.getString(R.string.toast_exported_to), "ownCloud -> " + OC_DIR)); + } + + /** + * Checks that a specific toast message is displayed + * @param toastString String that should be displayed + */ + private void assertToastDisplayed(String toastString) { + onView(withText(toastString)) + .inRoot(withDecorView(not(is(mActivityRule.getActivity().getWindow().getDecorView())))) + .check(matches(isDisplayed())); + } + /** + * Sleep the thread for a specified period + * @param millis Duration to sleep in milliseconds + */ + private void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } +} + diff --git a/app/src/androidTest/java/org/gnucash/android/test/ui/PieChartReportTest.java b/app/src/androidTest/java/org/gnucash/android/test/ui/PieChartReportTest.java index 72ce5e0fe..b0b1a15c4 100644 --- a/app/src/androidTest/java/org/gnucash/android/test/ui/PieChartReportTest.java +++ b/app/src/androidTest/java/org/gnucash/android/test/ui/PieChartReportTest.java @@ -16,62 +16,57 @@ package org.gnucash.android.test.ui; -import android.content.Intent; -import android.database.SQLException; -import android.database.sqlite.SQLiteDatabase; -import android.preference.PreferenceManager; -import android.support.test.InstrumentationRegistry; +import android.content.Context; import android.support.test.espresso.ViewAction; import android.support.test.espresso.action.CoordinatesProvider; import android.support.test.espresso.action.GeneralClickAction; import android.support.test.espresso.action.Press; import android.support.test.espresso.action.Tap; -import android.support.test.espresso.contrib.PickerActions; +import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; -import android.test.ActivityInstrumentationTestCase2; -import android.util.Log; import android.view.View; -import android.widget.DatePicker; import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.DatabaseHelper; -import org.gnucash.android.db.SplitsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.importer.GncXmlImporter; -import org.gnucash.android.model.Account; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.Commodity; import org.gnucash.android.model.Money; import org.gnucash.android.model.Split; import org.gnucash.android.model.Transaction; import org.gnucash.android.model.TransactionType; -import org.gnucash.android.ui.report.PieChartFragment; +import org.gnucash.android.test.ui.util.DisableAnimationsRule; +import org.gnucash.android.ui.report.BaseReportFragment; import org.gnucash.android.ui.report.ReportsActivity; +import org.gnucash.android.ui.report.piechart.PieChartFragment; +import org.gnucash.android.ui.settings.PreferenceActivity; import org.joda.time.LocalDateTime; import org.junit.After; +import org.junit.AfterClass; import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import java.math.BigDecimal; -import java.util.Currency; +import java.util.Locale; import static android.support.test.espresso.Espresso.onView; import static android.support.test.espresso.action.ViewActions.click; import static android.support.test.espresso.assertion.ViewAssertions.matches; -import static android.support.test.espresso.matcher.ViewMatchers.isEnabled; -import static android.support.test.espresso.matcher.ViewMatchers.withClassName; import static android.support.test.espresso.matcher.ViewMatchers.withId; import static android.support.test.espresso.matcher.ViewMatchers.withText; -import static org.hamcrest.Matchers.anyOf; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.not; +import static org.assertj.core.api.Assertions.assertThat; @RunWith(AndroidJUnit4.class) -public class PieChartReportTest extends ActivityInstrumentationTestCase2 { +public class PieChartReportTest { public static final String TAG = PieChartReportTest.class.getName(); @@ -95,53 +90,58 @@ public class PieChartReportTest extends ActivityInstrumentationTestCase2 mActivityRule = new ActivityTestRule<>(ReportsActivity.class); + + @ClassRule + public static DisableAnimationsRule disableAnimationsRule = new DisableAnimationsRule(); + + private static String testBookUID; + private static String oldActiveBookUID; + + + public PieChartReportTest() { + //nothing to se here, move along + CURRENCY = new Commodity("US Dollars", "USD", 100); } - - @Override - @Before - public void setUp() throws Exception { - super.setUp(); - injectInstrumentation(InstrumentationRegistry.getInstrumentation()); - mReportsActivity = getActivity(); + @BeforeClass + public static void prepareTestCase() throws Exception { + Context context = GnuCashApplication.getAppContext(); + oldActiveBookUID = BooksDbAdapter.getInstance().getActiveBookUID(); + testBookUID = GncXmlImporter.parse(context.getResources().openRawResource(R.raw.default_accounts)); - SQLiteDatabase db; - DatabaseHelper dbHelper = new DatabaseHelper(getInstrumentation().getTargetContext()); - try { - db = dbHelper.getWritableDatabase(); - } catch (SQLException e) { - Log.e(TAG, "Error getting database: " + e.getMessage()); - db = dbHelper.getReadableDatabase(); - } - mTransactionsDbAdapter = new TransactionsDbAdapter(db, new SplitsDbAdapter(db)); - mAccountsDbAdapter = new AccountsDbAdapter(db, mTransactionsDbAdapter); - mAccountsDbAdapter.deleteAllRecords(); + GnuCashApplication.loadBook(testBookUID); + mTransactionsDbAdapter = TransactionsDbAdapter.getInstance(); + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); - PreferenceManager.getDefaultSharedPreferences(mReportsActivity).edit() - .putString(mReportsActivity.getString(R.string.key_default_currency), CURRENCY.getCurrencyCode()) + CURRENCY = CommoditiesDbAdapter.getInstance().getCommodity("USD"); + + PreferenceActivity.getActiveBookSharedPreferences().edit() + .putString(context.getString(R.string.key_default_currency), CURRENCY.getCurrencyCode()) .commit(); - // creates default accounts - GncXmlImporter.parse(GnuCashApplication.getAppContext().getResources().openRawResource(R.raw.default_accounts)); + } + + + @Before + public void setUp() throws Exception { + mTransactionsDbAdapter.deleteAllRecords(); + mReportsActivity = mActivityRule.getActivity(); + assertThat(mAccountsDbAdapter.getRecordsCount()).isGreaterThan(20); //lots of accounts in the default + onView(withId(R.id.btn_pie_chart)).perform(click()); } /** - * Call this method in every tests after adding data + * Add a transaction for the current month in order to test the report view + * @throws Exception */ - private void getTestActivity() { - setActivityIntent(new Intent(Intent.ACTION_VIEW)); - mReportsActivity = getActivity(); - onView(withId(R.id.btn_pie_chart)).perform(click()); - } - private void addTransactionForCurrentMonth() throws Exception { Transaction transaction = new Transaction(TRANSACTION_NAME); transaction.setTime(System.currentTimeMillis()); @@ -152,11 +152,13 @@ private void addTransactionForCurrentMonth() throws Exception { transaction.addSplit(split); transaction.addSplit(split.createPair(CASH_IN_WALLET_ASSET_ACCOUNT_UID)); - Account account = mAccountsDbAdapter.getRecord(DINING_EXPENSE_ACCOUNT_UID); - account.addTransaction(transaction); - mTransactionsDbAdapter.addRecord(transaction); + mTransactionsDbAdapter.addRecord(transaction, DatabaseAdapter.UpdateMethod.insert); } + /** + * Add a transactions for the previous month for testing pie chart + * @param minusMonths Number of months prior + */ private void addTransactionForPreviousMonth(int minusMonths) { Transaction transaction = new Transaction(TRANSACTION2_NAME); transaction.setTime(new LocalDateTime().minusMonths(minusMonths).toDate().getTime()); @@ -167,15 +169,12 @@ private void addTransactionForPreviousMonth(int minusMonths) { transaction.addSplit(split); transaction.addSplit(split.createPair(CASH_IN_WALLET_ASSET_ACCOUNT_UID)); - Account account = mAccountsDbAdapter.getRecord(BOOKS_EXPENSE_ACCOUNT_UID); - account.addTransaction(transaction); - mTransactionsDbAdapter.addRecord(transaction); + mTransactionsDbAdapter.addRecord(transaction, DatabaseAdapter.UpdateMethod.insert); } @Test public void testNoData() { - getTestActivity(); onView(withId(R.id.pie_chart)).perform(click()); onView(withId(R.id.selected_chart_slice)).check(matches(withText(R.string.label_select_pie_slice_to_see_details))); } @@ -184,11 +183,11 @@ public void testNoData() { public void testSelectingValue() throws Exception { addTransactionForCurrentMonth(); addTransactionForPreviousMonth(1); - getTestActivity(); + refreshReport(); onView(withId(R.id.pie_chart)).perform(clickXY(Position.BEGIN, Position.MIDDLE)); float percent = (float) (TRANSACTION_AMOUNT / (TRANSACTION_AMOUNT + TRANSACTION2_AMOUNT) * 100); - String selectedText = String.format(PieChartFragment.SELECTED_VALUE_PATTERN, DINING_EXPENSE_ACCOUNT_NAME, TRANSACTION_AMOUNT, percent); + String selectedText = String.format(Locale.US, BaseReportFragment.SELECTED_VALUE_PATTERN, DINING_EXPENSE_ACCOUNT_NAME, TRANSACTION_AMOUNT, percent); onView(withId(R.id.selected_chart_slice)).check(matches(withText(selectedText))); } @@ -199,19 +198,15 @@ public void testSpinner() throws Exception { transaction.addSplit(split); transaction.addSplit(split.createPair(CASH_IN_WALLET_ASSET_ACCOUNT_UID)); - mAccountsDbAdapter.getRecord(GIFTS_RECEIVED_INCOME_ACCOUNT_UID).addTransaction(transaction); - mTransactionsDbAdapter.addRecord(transaction); + mTransactionsDbAdapter.addRecord(transaction, DatabaseAdapter.UpdateMethod.insert); - getTestActivity(); + refreshReport(); Thread.sleep(1000); onView(withId(R.id.report_account_type_spinner)).perform(click()); onView(withText(AccountType.INCOME.name())).perform(click()); - - Thread.sleep(1000); - - onView(withId(R.id.pie_chart)).perform(click()); + onView(withId(R.id.pie_chart)).perform(clickXY(Position.BEGIN, Position.MIDDLE)); String selectedText = String.format(PieChartFragment.SELECTED_VALUE_PATTERN, GIFTS_RECEIVED_INCOME_ACCOUNT_NAME, TRANSACTION3_AMOUNT, 100f); onView(withId(R.id.selected_chart_slice)).check(matches(withText(selectedText))); @@ -262,11 +257,31 @@ public float getPosition(int viewPos, int viewLength) { abstract float getPosition(int widgetPos, int widgetLength); } - @Override + /** + * Refresh reports + */ + private void refreshReport(){ + try { + mActivityRule.runOnUiThread(new Runnable() { + @Override + public void run() { + mReportsActivity.refresh(); + } + }); + } catch (Throwable t){ + System.err.println("Faile to refresh reports"); + } + } + @After public void tearDown() throws Exception { mReportsActivity.finish(); - super.tearDown(); } + @AfterClass + public static void cleanup(){ + BooksDbAdapter booksDbAdapter = BooksDbAdapter.getInstance(); + booksDbAdapter.setActive(oldActiveBookUID); + booksDbAdapter.deleteRecord(testBookUID); + } } diff --git a/app/src/androidTest/java/org/gnucash/android/test/ui/TransactionsActivityTest.java b/app/src/androidTest/java/org/gnucash/android/test/ui/TransactionsActivityTest.java index 4298926c4..bf5373a56 100644 --- a/app/src/androidTest/java/org/gnucash/android/test/ui/TransactionsActivityTest.java +++ b/app/src/androidTest/java/org/gnucash/android/test/ui/TransactionsActivityTest.java @@ -17,24 +17,22 @@ package org.gnucash.android.test.ui; import android.content.ContentValues; +import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; -import android.database.SQLException; -import android.database.sqlite.SQLiteDatabase; -import android.preference.PreferenceManager; -import android.support.test.InstrumentationRegistry; import android.support.test.espresso.Espresso; +import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; -import android.test.ActivityInstrumentationTestCase2; -import android.util.Log; import org.gnucash.android.R; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.DatabaseHelper; +import org.gnucash.android.app.GnuCashApplication; import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.db.SplitsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.Account; import org.gnucash.android.model.Commodity; import org.gnucash.android.model.Money; @@ -42,11 +40,16 @@ import org.gnucash.android.model.Transaction; import org.gnucash.android.model.TransactionType; import org.gnucash.android.receivers.TransactionRecorder; +import org.gnucash.android.test.ui.util.DisableAnimationsRule; import org.gnucash.android.ui.common.UxArgument; +import org.gnucash.android.ui.settings.PreferenceActivity; import org.gnucash.android.ui.transaction.TransactionFormFragment; import org.gnucash.android.ui.transaction.TransactionsActivity; import org.junit.After; import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -57,7 +60,6 @@ import java.util.List; import java.util.Locale; -import static android.support.test.espresso.Espresso.onData; import static android.support.test.espresso.Espresso.onView; import static android.support.test.espresso.action.ViewActions.clearText; import static android.support.test.espresso.action.ViewActions.click; @@ -68,88 +70,95 @@ import static android.support.test.espresso.matcher.ViewMatchers.isChecked; import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withParent; import static android.support.test.espresso.matcher.ViewMatchers.withText; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.allOf; -import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; @RunWith(AndroidJUnit4.class) -public class TransactionsActivityTest extends - ActivityInstrumentationTestCase2 { +public class TransactionsActivityTest { private static final String TRANSACTION_AMOUNT = "9.99"; private static final String TRANSACTION_NAME = "Pizza"; - private static final String DUMMY_ACCOUNT_UID = "transactions-account"; - private static final String DUMMY_ACCOUNT_NAME = "Transactions Account"; + private static final String TRANSACTIONS_ACCOUNT_UID = "transactions-account"; + private static final String TRANSACTIONS_ACCOUNT_NAME = "Transactions Account"; private static final String TRANSFER_ACCOUNT_NAME = "Transfer account"; private static final String TRANSFER_ACCOUNT_UID = "transfer_account"; public static final String CURRENCY_CODE = "USD"; + public static Commodity COMMODITY = Commodity.DEFAULT_COMMODITY; private Transaction mTransaction; private long mTransactionTimeMillis; - private SQLiteDatabase mDb; - private DatabaseHelper mDbHelper; - private AccountsDbAdapter mAccountsDbAdapter; - private TransactionsDbAdapter mTransactionsDbAdapter; - private SplitsDbAdapter mSplitsDbAdapter; + private static AccountsDbAdapter mAccountsDbAdapter; + private static TransactionsDbAdapter mTransactionsDbAdapter; + private static SplitsDbAdapter mSplitsDbAdapter; private TransactionsActivity mTransactionsActivity; + @ClassRule + public static DisableAnimationsRule disableAnimationsRule = new DisableAnimationsRule(); + + @Rule + public ActivityTestRule mActivityRule = + new ActivityTestRule<>(TransactionsActivity.class, true, false); + + private Account mBaseAccount; + private Account mTransferAccount; + public TransactionsActivityTest() { - super(TransactionsActivity.class); + mBaseAccount = new Account(TRANSACTIONS_ACCOUNT_NAME, COMMODITY); + mBaseAccount.setUID(TRANSACTIONS_ACCOUNT_UID); + + mTransferAccount = new Account(TRANSFER_ACCOUNT_NAME, COMMODITY); + mTransferAccount.setUID(TRANSFER_ACCOUNT_UID); + + mTransactionTimeMillis = System.currentTimeMillis(); + mTransaction = new Transaction(TRANSACTION_NAME); + mTransaction.setCommodity(COMMODITY); + mTransaction.setNote("What up?"); + mTransaction.setTime(mTransactionTimeMillis); + Split split = new Split(new Money(TRANSACTION_AMOUNT, CURRENCY_CODE), TRANSACTIONS_ACCOUNT_UID); + split.setType(TransactionType.DEBIT); + + mTransaction.addSplit(split); + mTransaction.addSplit(split.createPair(TRANSFER_ACCOUNT_UID)); + + mBaseAccount.addTransaction(mTransaction); } - - @Override + + @BeforeClass + public static void prepareTestCase(){ + Context context = GnuCashApplication.getAppContext(); + AccountsActivityTest.preventFirstRunDialogs(context); + + mSplitsDbAdapter = SplitsDbAdapter.getInstance(); + mTransactionsDbAdapter = TransactionsDbAdapter.getInstance(); + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); + COMMODITY = CommoditiesDbAdapter.getInstance().getCommodity(CURRENCY_CODE); + +// PreferenceActivity.getActiveBookSharedPreferences(context) +// .edit().putBoolean(context.getString(R.string.key_use_compact_list), false) +// .apply(); + } + @Before public void setUp() throws Exception { - super.setUp(); - injectInstrumentation(InstrumentationRegistry.getInstrumentation()); - AccountsActivityTest.preventFirstRunDialogs(getInstrumentation().getTargetContext()); - - - mDbHelper = new DatabaseHelper(getInstrumentation().getTargetContext()); - try { - mDb = mDbHelper.getWritableDatabase(); - } catch (SQLException e) { - Log.e(getClass().getName(), "Error getting database: " + e.getMessage()); - mDb = mDbHelper.getReadableDatabase(); - } - mSplitsDbAdapter = new SplitsDbAdapter(mDb); - mTransactionsDbAdapter = new TransactionsDbAdapter(mDb, mSplitsDbAdapter); - mAccountsDbAdapter = new AccountsDbAdapter(mDb, mTransactionsDbAdapter); mAccountsDbAdapter.deleteAllRecords(); + mAccountsDbAdapter.addRecord(mBaseAccount, DatabaseAdapter.UpdateMethod.insert); + mAccountsDbAdapter.addRecord(mTransferAccount, DatabaseAdapter.UpdateMethod.insert); - mTransactionTimeMillis = System.currentTimeMillis(); - Account account = new Account(DUMMY_ACCOUNT_NAME); - account.setUID(DUMMY_ACCOUNT_UID); - account.setCommodity(Commodity.getInstance(CURRENCY_CODE)); - - Account account2 = new Account(TRANSFER_ACCOUNT_NAME); - account2.setUID(TRANSFER_ACCOUNT_UID); - account2.setCommodity(Commodity.getInstance(CURRENCY_CODE)); - - mAccountsDbAdapter.addRecord(account); - mAccountsDbAdapter.addRecord(account2); - - mTransaction = new Transaction(TRANSACTION_NAME); - mTransaction.setCurrencyCode(CURRENCY_CODE); - mTransaction.setNote("What up?"); - mTransaction.setTime(mTransactionTimeMillis); - Split split = new Split(new Money(TRANSACTION_AMOUNT, CURRENCY_CODE), DUMMY_ACCOUNT_UID); - split.setType(TransactionType.DEBIT); - - mTransaction.addSplit(split); - mTransaction.addSplit(split.createPair(TRANSFER_ACCOUNT_UID)); - account.addTransaction(mTransaction); - - mTransactionsDbAdapter.addRecord(mTransaction); - - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.putExtra(UxArgument.SELECTED_ACCOUNT_UID, DUMMY_ACCOUNT_UID); - setActivityIntent(intent); - mTransactionsActivity = getActivity(); + mTransactionsDbAdapter.addRecord(mTransaction, DatabaseAdapter.UpdateMethod.insert); + + assertThat(mAccountsDbAdapter.getRecordsCount()).isEqualTo(3); //including ROOT account + assertThat(mTransactionsDbAdapter.getRecordsCount()).isEqualTo(1); + + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.putExtra(UxArgument.SELECTED_ACCOUNT_UID, TRANSACTIONS_ACCOUNT_UID); + mTransactionsActivity = mActivityRule.launchActivity(intent); + + //refreshTransactionsList(); } @@ -158,7 +167,7 @@ private void validateTransactionListDisplayed(){ } private int getTransactionCount(){ - return mTransactionsDbAdapter.getAllTransactionsForAccount(DUMMY_ACCOUNT_UID).size(); + return mTransactionsDbAdapter.getAllTransactionsForAccount(TRANSACTIONS_ACCOUNT_UID).size(); } private void validateTimeInput(long timeMillis){ @@ -173,19 +182,25 @@ private void validateTimeInput(long timeMillis){ public void testAddTransactionShouldRequireAmount(){ validateTransactionListDisplayed(); - int beforeCount = mTransactionsDbAdapter.getTransactionsCount(DUMMY_ACCOUNT_UID); + int beforeCount = mTransactionsDbAdapter.getTransactionsCount(TRANSACTIONS_ACCOUNT_UID); onView(withId(R.id.fab_create_transaction)).perform(click()); onView(withId(R.id.input_transaction_name)) .check(matches(isDisplayed())) .perform(typeText("Lunch")); - onView(withId(R.id.menu_save)).perform(click()); + Espresso.closeSoftKeyboard(); + + onView(withId(R.id.menu_save)) + .check(matches(isDisplayed())) + .perform(click()); onView(withText(R.string.title_add_transaction)).check(matches(isDisplayed())); + sleep(1000); + assertToastDisplayed(R.string.toast_transanction_amount_required); - int afterCount = mTransactionsDbAdapter.getTransactionsCount(DUMMY_ACCOUNT_UID); + int afterCount = mTransactionsDbAdapter.getTransactionsCount(TRANSACTIONS_ACCOUNT_UID); assertThat(afterCount).isEqualTo(beforeCount); } @@ -208,7 +223,7 @@ private void sleep(long millis) { */ private void assertToastDisplayed(int toastString) { onView(withText(toastString)) - .inRoot(withDecorView(not(is(getActivity().getWindow().getDecorView())))) + .inRoot(withDecorView(not(mTransactionsActivity.getWindow().getDecorView()))) .check(matches(isDisplayed())); } @@ -217,7 +232,7 @@ private void validateEditTransactionFields(Transaction transaction){ onView(withId(R.id.input_transaction_name)).check(matches(withText(transaction.getDescription()))); - Money balance = transaction.getBalance(DUMMY_ACCOUNT_UID); + Money balance = transaction.getBalance(TRANSACTIONS_ACCOUNT_UID); NumberFormat formatter = NumberFormat.getInstance(Locale.getDefault()); formatter.setMinimumFractionDigits(2); formatter.setMaximumFractionDigits(2); @@ -253,7 +268,7 @@ public void testAddTransaction(){ validateTransactionListDisplayed(); - List transactions = mTransactionsDbAdapter.getAllTransactionsForAccount(DUMMY_ACCOUNT_UID); + List transactions = mTransactionsDbAdapter.getAllTransactionsForAccount(TRANSACTIONS_ACCOUNT_UID); assertThat(transactions).hasSize(2); Transaction transaction = transactions.get(0); assertThat(transaction.getSplits()).hasSize(2); @@ -261,6 +276,58 @@ public void testAddTransaction(){ assertThat(getTransactionCount()).isEqualTo(transactionsCount + 1); } + @Test + public void testAddMultiCurrencyTransaction(){ + Commodity euro = Commodity.getInstance("EUR"); + Account euroAccount = new Account("Euro Konto", euro); + mAccountsDbAdapter.addRecord(euroAccount); + + int transactionCount = mTransactionsDbAdapter.getTransactionsCount(TRANSACTIONS_ACCOUNT_UID); + setDoubleEntryEnabled(true); + setDefaultTransactionType(TransactionType.DEBIT); + validateTransactionListDisplayed(); + + onView(withId(R.id.fab_create_transaction)).perform(click()); + + String transactionName = "Multicurrency lunch"; + onView(withId(R.id.input_transaction_name)).perform(typeText(transactionName)); + onView(withId(R.id.input_transaction_amount)).perform(typeText("10")); + Espresso.pressBack(); //close calculator keyboard + + onView(withId(R.id.input_transfer_account_spinner)).perform(click()); + onView(withText(euroAccount.getFullName())) + .check(matches(isDisplayed())) + .perform(click()); + + onView(withId(R.id.menu_save)).perform(click()); + + onView(withText(R.string.msg_provide_exchange_rate)).check(matches(isDisplayed())); + onView(withId(R.id.radio_converted_amount)).perform(click()); + onView(withId(R.id.input_converted_amount)).perform(typeText("5")); + Espresso.closeSoftKeyboard(); + onView(withId(R.id.btn_save)).perform(click()); + + onView(withId(R.id.menu_save)).perform(click()); + + List allTransactions = mTransactionsDbAdapter.getAllTransactionsForAccount(TRANSACTIONS_ACCOUNT_UID); + assertThat(allTransactions).hasSize(transactionCount+1); + Transaction multiTrans = allTransactions.get(0); + assertThat(multiTrans.getSplits()).hasSize(2); + assertThat(multiTrans.getSplits()).extracting("mAccountUID") + .contains(TRANSACTIONS_ACCOUNT_UID) + .contains(euroAccount.getUID()); + + Split euroSplit = multiTrans.getSplits(euroAccount.getUID()).get(0); + Money expectedQty = new Money("5", euro.getCurrencyCode()); + Money expectedValue = new Money(BigDecimal.TEN, COMMODITY); + assertThat(euroSplit.getQuantity()).isEqualTo(expectedQty); + assertThat(euroSplit.getValue()).isEqualTo(expectedValue); + + Split usdSplit = multiTrans.getSplits(TRANSACTIONS_ACCOUNT_UID).get(0); + assertThat(usdSplit.getQuantity()).isEqualTo(expectedValue); + assertThat(usdSplit.getValue()).isEqualTo(expectedValue); + } + @Test public void testEditTransaction(){ validateTransactionListDisplayed(); @@ -269,8 +336,21 @@ public void testEditTransaction(){ validateEditTransactionFields(mTransaction); - onView(withId(R.id.input_transaction_name)).perform(clearText(), typeText("Pasta")); + String trnName = "Pasta"; + onView(withId(R.id.input_transaction_name)).perform(clearText(), typeText(trnName)); onView(withId(R.id.menu_save)).perform(click()); + + Transaction editedTransaction = mTransactionsDbAdapter.getRecord(mTransaction.getUID()); + assertThat(editedTransaction.getDescription()).isEqualTo(trnName); + assertThat(editedTransaction.getSplits()).hasSize(2); + + Split split = mTransaction.getSplits(TRANSACTIONS_ACCOUNT_UID).get(0); + Split editedSplit = editedTransaction.getSplits(TRANSACTIONS_ACCOUNT_UID).get(0); + assertThat(split.isEquivalentTo(editedSplit)).isTrue(); + + split = mTransaction.getSplits(TRANSFER_ACCOUNT_UID).get(0); + editedSplit = editedTransaction.getSplits(TRANSFER_ACCOUNT_UID).get(0); + assertThat(split.isEquivalentTo(editedSplit)).isTrue(); } /** @@ -303,7 +383,7 @@ public void testAutoBalanceTransactions(){ imbalanceAcctUID = mAccountsDbAdapter.getImbalanceAccountUID(Currency.getInstance(CURRENCY_CODE)); assertThat(imbalanceAcctUID).isNotNull(); assertThat(imbalanceAcctUID).isNotEmpty(); - assertTrue(mAccountsDbAdapter.isHiddenAccount(imbalanceAcctUID)); //imbalance account should be hidden in single entry mode + assertThat(mAccountsDbAdapter.isHiddenAccount(imbalanceAcctUID)).isTrue(); //imbalance account should be hidden in single entry mode assertThat(transaction.getSplits()).extracting("mAccountUID").contains(imbalanceAcctUID); @@ -334,8 +414,7 @@ public void testSplitEditor(){ onView(withId(R.id.split_list_layout)).check(matches(allOf(isDisplayed(), hasDescendant(withId(R.id.input_split_amount))))); - onView(withId(R.id.menu_add_split)).perform(click()); - + onView(allOf(withId(R.id.input_split_amount), withText("-499"))).perform(clearText()); onView(allOf(withId(R.id.input_split_amount), withText(""))).perform(typeText("400")); onView(withId(R.id.menu_save)).perform(click()); @@ -353,7 +432,7 @@ public void testSplitEditor(){ imbalanceAcctUID = mAccountsDbAdapter.getImbalanceAccountUID(Currency.getInstance(CURRENCY_CODE)); assertThat(imbalanceAcctUID).isNotNull(); assertThat(imbalanceAcctUID).isNotEmpty(); - assertFalse(mAccountsDbAdapter.isHiddenAccount(imbalanceAcctUID)); + assertThat(mAccountsDbAdapter.isHiddenAccount(imbalanceAcctUID)).isFalse(); //at least one split will belong to the imbalance account assertThat(transaction.getSplits()).extracting("mAccountUID").contains(imbalanceAcctUID); @@ -368,10 +447,10 @@ public void testSplitEditor(){ private void setDoubleEntryEnabled(boolean enabled){ - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + SharedPreferences prefs = PreferenceActivity.getActiveBookSharedPreferences(); Editor editor = prefs.edit(); - editor.putBoolean(getActivity().getString(R.string.key_use_double_entry), enabled); - editor.commit(); + editor.putBoolean(mTransactionsActivity.getString(R.string.key_use_double_entry), enabled); + editor.apply(); } @Test @@ -383,24 +462,24 @@ public void testDefaultTransactionType(){ } private void setDefaultTransactionType(TransactionType type) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + SharedPreferences prefs = PreferenceActivity.getActiveBookSharedPreferences(); Editor editor = prefs.edit(); - editor.putString(getActivity().getString(R.string.key_default_transaction_type), type.name()); + editor.putString(mTransactionsActivity.getString(R.string.key_default_transaction_type), type.name()); editor.commit(); } //FIXME: Improve on this test public void childAccountsShouldUseParentTransferAccountSetting(){ Account transferAccount = new Account("New Transfer Acct"); - mAccountsDbAdapter.addRecord(transferAccount); - mAccountsDbAdapter.addRecord(new Account("Higher account")); + mAccountsDbAdapter.addRecord(transferAccount, DatabaseAdapter.UpdateMethod.insert); + mAccountsDbAdapter.addRecord(new Account("Higher account"), DatabaseAdapter.UpdateMethod.insert); Account childAccount = new Account("Child Account"); - childAccount.setParentUID(DUMMY_ACCOUNT_UID); - mAccountsDbAdapter.addRecord(childAccount); + childAccount.setParentUID(TRANSACTIONS_ACCOUNT_UID); + mAccountsDbAdapter.addRecord(childAccount, DatabaseAdapter.UpdateMethod.insert); ContentValues contentValues = new ContentValues(); contentValues.put(DatabaseSchema.AccountEntry.COLUMN_DEFAULT_TRANSFER_ACCOUNT_UID, transferAccount.getUID()); - mAccountsDbAdapter.updateRecord(DUMMY_ACCOUNT_UID, contentValues); + mAccountsDbAdapter.updateRecord(TRANSACTIONS_ACCOUNT_UID, contentValues); Intent intent = new Intent(mTransactionsActivity, TransactionsActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); @@ -432,11 +511,11 @@ public void testToggleTransactionType(){ onView(withId(R.id.menu_save)).perform(click()); - List transactions = mTransactionsDbAdapter.getAllTransactionsForAccount(DUMMY_ACCOUNT_UID); + List transactions = mTransactionsDbAdapter.getAllTransactionsForAccount(TRANSACTIONS_ACCOUNT_UID); assertThat(transactions).hasSize(1); Transaction trx = transactions.get(0); assertThat(trx.getSplits()).hasSize(2); //auto-balancing of splits - assertTrue(trx.getBalance(DUMMY_ACCOUNT_UID).isNegative()); + assertThat(trx.getBalance(TRANSACTIONS_ACCOUNT_UID).isNegative()).isTrue(); } @Test @@ -448,17 +527,29 @@ public void testOpenTransactionEditShouldNotModifyTransaction(){ clickOnView(R.id.menu_save); - List transactions = mTransactionsDbAdapter.getAllTransactionsForAccount(DUMMY_ACCOUNT_UID); + List transactions = mTransactionsDbAdapter.getAllTransactionsForAccount(TRANSACTIONS_ACCOUNT_UID); assertThat(transactions).hasSize(1); - Transaction trx = transactions.get(0); - assertEquals(TRANSACTION_NAME, trx.getDescription()); + Transaction transaction = transactions.get(0); + assertThat(TRANSACTION_NAME).isEqualTo(transaction.getDescription()); Date expectedDate = new Date(mTransactionTimeMillis); - Date trxDate = new Date(trx.getTimeMillis()); - assertEquals(TransactionFormFragment.DATE_FORMATTER.format(expectedDate), - TransactionFormFragment.DATE_FORMATTER.format(trxDate)); - assertEquals(TransactionFormFragment.TIME_FORMATTER.format(expectedDate), - TransactionFormFragment.TIME_FORMATTER.format(trxDate)); + Date trxDate = new Date(transaction.getTimeMillis()); + assertThat(TransactionFormFragment.DATE_FORMATTER.format(expectedDate)) + .isEqualTo(TransactionFormFragment.DATE_FORMATTER.format(trxDate)); + assertThat(TransactionFormFragment.TIME_FORMATTER.format(expectedDate)) + .isEqualTo(TransactionFormFragment.TIME_FORMATTER.format(trxDate)); + + Split baseSplit = transaction.getSplits(TRANSACTIONS_ACCOUNT_UID).get(0); + Money expectedAmount = new Money(TRANSACTION_AMOUNT, CURRENCY_CODE); + assertThat(baseSplit.getValue()).isEqualTo(expectedAmount); + assertThat(baseSplit.getQuantity()).isEqualTo(expectedAmount); + assertThat(baseSplit.getType()).isEqualTo(TransactionType.DEBIT); + + Split transferSplit = transaction.getSplits(TRANSFER_ACCOUNT_UID).get(0); + assertThat(transferSplit.getValue()).isEqualTo(expectedAmount); + assertThat(transferSplit.getQuantity()).isEqualTo(expectedAmount); + assertThat(transferSplit.getType()).isEqualTo(TransactionType.CREDIT); + } @Test @@ -466,15 +557,14 @@ public void testDeleteTransaction(){ onView(withId(R.id.options_menu)).perform(click()); onView(withText(R.string.menu_delete)).perform(click()); - long id = mAccountsDbAdapter.getID(DUMMY_ACCOUNT_UID); - assertEquals(0, mTransactionsDbAdapter.getTransactionsCount(id)); + assertThat(0).isEqualTo(mTransactionsDbAdapter.getTransactionsCount(TRANSACTIONS_ACCOUNT_UID)); } @Test public void testMoveTransaction(){ Account account = new Account("Move account"); account.setCommodity(Commodity.getInstance(CURRENCY_CODE)); - mAccountsDbAdapter.addRecord(account); + mAccountsDbAdapter.addRecord(account, DatabaseAdapter.UpdateMethod.insert); assertThat(mTransactionsDbAdapter.getAllTransactionsForAccount(account.getUID())).hasSize(0); @@ -483,60 +573,61 @@ public void testMoveTransaction(){ onView(withId(R.id.btn_save)).perform(click()); - assertThat(mTransactionsDbAdapter.getAllTransactionsForAccount(DUMMY_ACCOUNT_UID)).hasSize(0); + assertThat(mTransactionsDbAdapter.getAllTransactionsForAccount(TRANSACTIONS_ACCOUNT_UID)).hasSize(0); assertThat(mTransactionsDbAdapter.getAllTransactionsForAccount(account.getUID())).hasSize(1); } -// @Test //// FIXME: 03.11.2015 fix and re-enable this test + /** + * This test edits a transaction from within an account and removes the split belonging to that account. + * The account should then have a balance of 0 and the transaction has "moved" to another account + */ + @Test public void editingSplit_shouldNotSetAmountToZero(){ setDoubleEntryEnabled(true); + setDefaultTransactionType(TransactionType.DEBIT); + mTransactionsDbAdapter.deleteAllRecords(); Account account = new Account("Z Account", Commodity.getInstance(CURRENCY_CODE)); - mAccountsDbAdapter.addRecord(account); + mAccountsDbAdapter.addRecord(account, DatabaseAdapter.UpdateMethod.insert); + //create new transaction "Transaction Acct" --> "Transfer Account" onView(withId(R.id.fab_create_transaction)).perform(click()); - onView(withId(R.id.input_transaction_name)).perform(typeText("Test Split")); onView(withId(R.id.input_transaction_amount)).perform(typeText("1024")); onView(withId(R.id.menu_save)).perform(click()); + assertThat(mTransactionsDbAdapter.getTransactionsCount(TRANSACTIONS_ACCOUNT_UID)).isEqualTo(1); + + sleep(500); onView(withText("Test Split")).perform(click()); onView(withId(R.id.fab_edit_transaction)).perform(click()); onView(withId(R.id.btn_split_editor)).perform(click()); -// onView(withSpinnerText(DUMMY_ACCOUNT_NAME)).perform(click()); //// FIXME: 03.11.2015 properly select the spinner - onData(withId(R.id.input_accounts_spinner)) - .inAdapterView(withId(R.id.split_list_layout)) - .atPosition(1) - .perform(click()); - onData(allOf(is(instanceOf(String.class)), is(account.getFullName()))).perform(click()); -// onView(withText(account.getFullName())).perform(click()); + onView(withText(TRANSACTIONS_ACCOUNT_NAME)).perform(click()); + onView(withText(account.getFullName())).perform(click()); onView(withId(R.id.menu_save)).perform(click()); onView(withId(R.id.menu_save)).perform(click()); - //split should have moved from account, it should now be empty - onView(withId(R.id.empty_view)).check(matches(isDisplayed())); - - assertThat(mAccountsDbAdapter.getAccountBalance(DUMMY_ACCOUNT_UID)).isEqualTo(Money.createZeroInstance(CURRENCY_CODE)); + assertThat(mTransactionsDbAdapter.getTransactionsCount(TRANSACTIONS_ACCOUNT_UID)).isZero(); - //split - assertThat(mAccountsDbAdapter.getAccountBalance(account.getUID())).isEqualTo(new Money("1024", CURRENCY_CODE)); + assertThat(mAccountsDbAdapter.getAccountBalance(account.getUID())) + .isEqualTo(new Money("1024", CURRENCY_CODE)); } @Test public void testDuplicateTransaction(){ - assertThat(mTransactionsDbAdapter.getAllTransactionsForAccount(DUMMY_ACCOUNT_UID)).hasSize(1); + assertThat(mTransactionsDbAdapter.getAllTransactionsForAccount(TRANSACTIONS_ACCOUNT_UID)).hasSize(1); onView(withId(R.id.options_menu)).perform(click()); onView(withText(R.string.menu_duplicate_transaction)).perform(click()); - List dummyAccountTrns = mTransactionsDbAdapter.getAllTransactionsForAccount(DUMMY_ACCOUNT_UID); + List dummyAccountTrns = mTransactionsDbAdapter.getAllTransactionsForAccount(TRANSACTIONS_ACCOUNT_UID); assertThat(dummyAccountTrns).hasSize(2); assertThat(dummyAccountTrns.get(0).getDescription()).isEqualTo(dummyAccountTrns.get(1).getDescription()); @@ -546,32 +637,234 @@ public void testDuplicateTransaction(){ //TODO: add normal transaction recording @Test public void testLegacyIntentTransactionRecording(){ - int beforeCount = mTransactionsDbAdapter.getTransactionsCount(DUMMY_ACCOUNT_UID); + int beforeCount = mTransactionsDbAdapter.getTransactionsCount(TRANSACTIONS_ACCOUNT_UID); Intent transactionIntent = new Intent(Intent.ACTION_INSERT); transactionIntent.setType(Transaction.MIME_TYPE); transactionIntent.putExtra(Intent.EXTRA_TITLE, "Power intents"); transactionIntent.putExtra(Intent.EXTRA_TEXT, "Intents for sale"); transactionIntent.putExtra(Transaction.EXTRA_AMOUNT, new BigDecimal(4.99)); - transactionIntent.putExtra(Transaction.EXTRA_ACCOUNT_UID, DUMMY_ACCOUNT_UID); + transactionIntent.putExtra(Transaction.EXTRA_ACCOUNT_UID, TRANSACTIONS_ACCOUNT_UID); transactionIntent.putExtra(Transaction.EXTRA_TRANSACTION_TYPE, TransactionType.DEBIT.name()); transactionIntent.putExtra(Account.EXTRA_CURRENCY_CODE, "USD"); new TransactionRecorder().onReceive(mTransactionsActivity, transactionIntent); - int afterCount = mTransactionsDbAdapter.getTransactionsCount(DUMMY_ACCOUNT_UID); + int afterCount = mTransactionsDbAdapter.getTransactionsCount(TRANSACTIONS_ACCOUNT_UID); assertThat(beforeCount + 1).isEqualTo(afterCount); - List transactions = mTransactionsDbAdapter.getAllTransactionsForAccount(DUMMY_ACCOUNT_UID); + List transactions = mTransactionsDbAdapter.getAllTransactionsForAccount(TRANSACTIONS_ACCOUNT_UID); for (Transaction transaction : transactions) { if (transaction.getDescription().equals("Power intents")){ - assertEquals("Intents for sale", transaction.getNote()); - assertEquals(4.99, transaction.getBalance(DUMMY_ACCOUNT_UID).asDouble()); + assertThat("Intents for sale").isEqualTo(transaction.getNote()); + assertThat(4.99).isEqualTo(transaction.getBalance(TRANSACTIONS_ACCOUNT_UID).asDouble()); } } } + /** + * Opening a transactions and then hitting save button without changing anything should have no side-effects + * This is similar to the test @{@link #testOpenTransactionEditShouldNotModifyTransaction()} + * with the difference that this test checks multi-currency transactions + */ + @Test + public void openingAndSavingMultiCurrencyTransaction_shouldNotModifyTheSplits(){ + Commodity bgnCommodity = CommoditiesDbAdapter.getInstance().getCommodity("BGN"); + Account account = new Account("Zen Account", bgnCommodity); + + mAccountsDbAdapter.addRecord(account); + + onView(withId(R.id.fab_create_transaction)).perform(click()); + String trnDescription = "Multi-currency trn"; + onView(withId(R.id.input_transaction_name)).perform(typeText(trnDescription)); + onView(withId(R.id.input_transaction_amount)).perform(typeText("10")); + Espresso.closeSoftKeyboard(); + + onView(withId(R.id.input_transfer_account_spinner)).perform(click()); + onView(withText(account.getFullName())).perform(click()); + + //at this point, the transfer funds dialog should be shown + onView(withText(R.string.msg_provide_exchange_rate)).check(matches(isDisplayed())); + onView(withId(R.id.radio_converted_amount)).perform(click()); + onView(withId(R.id.input_converted_amount)).perform(typeText("5")); + + Espresso.closeSoftKeyboard(); + onView(withId(R.id.btn_save)).perform(click()); //close currency exchange dialog + onView(withId(R.id.menu_save)).perform(click()); //save transaction + + List transactions = mTransactionsDbAdapter.getAllTransactionsForAccount(account.getUID()); + assertThat(transactions).hasSize(1); + Transaction transaction = transactions.get(0); + assertThat(transaction.getSplits()).hasSize(2); + assertThat(transaction.getSplits()).extracting("mAccountUID") + .contains(account.getUID()).contains(mBaseAccount.getUID()); + + + onView(allOf(withParent(hasDescendant(withText(trnDescription))), + withId(R.id.edit_transaction))).perform(click()); + + //do nothing to the transaction, just save it + onView(withId(R.id.menu_save)).perform(click()); + + transaction = mTransactionsDbAdapter.getRecord(transaction.getUID()); + + Split baseSplit = transaction.getSplits(mBaseAccount.getUID()).get(0); + Money expectedValueAmount = new Money(BigDecimal.TEN, COMMODITY); + assertThat(baseSplit.getValue()).isEqualTo(expectedValueAmount); + assertThat(baseSplit.getQuantity()).isEqualTo(expectedValueAmount); + + Split transferSplit = transaction.getSplits(account.getUID()).get(0); + Money convertedQuantity = new Money("5", "BGN"); + assertThat(transferSplit.getValue()).isEqualTo(expectedValueAmount); + assertThat(transferSplit.getQuantity()).isEqualTo(convertedQuantity); + } + + /** + * If a multi-currency transaction is edited so that it is no longer multicurrency, then the + * values for split and quantity should be adjusted accordingly so that they are consistent + *

+ * Basically the test works like this: + *

    + *
  1. Create a multicurrency transaction
  2. + *
  3. Change the transfer account so that both splits are of the same currency
  4. + *
  5. We now expect both the values and quantities of the splits to be the same
  6. + *
+ *

+ */ + @Test + public void testEditingTransferAccountOfMultiCurrencyTransaction(){ + mTransactionsDbAdapter.deleteAllRecords(); //clean slate + Commodity euroCommodity = CommoditiesDbAdapter.getInstance().getCommodity("EUR"); + Account euroAccount = new Account("Euro Account", euroCommodity); + + mAccountsDbAdapter.addRecord(euroAccount); + + Money expectedValue = new Money(BigDecimal.TEN, COMMODITY); + Money expectedQty = new Money("5", "EUR"); + + String trnDescription = "Multicurrency Test Trn"; + Transaction multiTransaction = new Transaction(trnDescription); + Split split1 = new Split(expectedValue, TRANSACTIONS_ACCOUNT_UID); + split1.setType(TransactionType.DEBIT); + Split split2 = new Split(expectedValue, expectedQty, euroAccount.getUID()); + split2.setType(TransactionType.CREDIT); + multiTransaction.addSplit(split1); + multiTransaction.addSplit(split2); + multiTransaction.setCommodity(COMMODITY); + + mTransactionsDbAdapter.addRecord(multiTransaction); + + Transaction savedTransaction = mTransactionsDbAdapter.getRecord(multiTransaction.getUID()); + assertThat(savedTransaction.getSplits()).extracting("mQuantity").contains(expectedQty); + assertThat(savedTransaction.getSplits()).extracting("mValue").contains(expectedValue); + + refreshTransactionsList(); + onView(withText(trnDescription)).check(matches(isDisplayed())); //transaction was added + onView(allOf(withParent(hasDescendant(withText(trnDescription))), + withId(R.id.edit_transaction))).perform(click()); + + //now change the transfer account to be no longer multi-currency + onView(withId(R.id.input_transfer_account_spinner)).perform(click()); + onView(withText(mTransferAccount.getFullName())).perform(click()); + + onView(withId(R.id.menu_save)).perform(click()); + + //no splits should be in the euro account anymore + List euroTransxns = mTransactionsDbAdapter.getAllTransactionsForAccount(euroAccount.getUID()); + assertThat(euroTransxns).hasSize(0); + + List transferAcctTrns = mTransactionsDbAdapter.getAllTransactionsForAccount(mTransferAccount.getUID()); + assertThat(transferAcctTrns).hasSize(1); + + Transaction singleCurrencyTrn = transferAcctTrns.get(0); + assertThat(singleCurrencyTrn.getUID()).isEqualTo(multiTransaction.getUID()); //should be the same one, just different splits + + //the crux of the test. All splits should now have value and quantity of USD $10 + List allSplits = singleCurrencyTrn.getSplits(); + assertThat(allSplits).extracting("mAccountUID") + .contains(mTransferAccount.getUID()) + .doesNotContain(euroAccount.getUID()); + assertThat(allSplits).extracting("mValue").contains(expectedValue).doesNotContain(expectedQty); + assertThat(allSplits).extracting("mQuantity").contains(expectedValue).doesNotContain(expectedQty); + } + + /** + * In this test we check that editing a transaction and switching the transfer account to one + * which is of a different currency and then back again should not have side-effects. + * The split value and quantity should remain consistent. + */ + @Test + public void editingTransferAccount_shouldKeepSplitAmountsConsistent() { + mTransactionsDbAdapter.deleteAllRecords(); //clean slate + Commodity euroCommodity = CommoditiesDbAdapter.getInstance().getCommodity("EUR"); + Account euroAccount = new Account("Euro Account", euroCommodity); + + mAccountsDbAdapter.addRecord(euroAccount); + + Money expectedValue = new Money(BigDecimal.TEN, COMMODITY); + Money expectedQty = new Money("5", "EUR"); + + String trnDescription = "Multicurrency Test Trn"; + Transaction multiTransaction = new Transaction(trnDescription); + Split split1 = new Split(expectedValue, TRANSACTIONS_ACCOUNT_UID); + split1.setType(TransactionType.CREDIT); + Split split2 = new Split(expectedValue, expectedQty, euroAccount.getUID()); + split2.setType(TransactionType.DEBIT); + multiTransaction.addSplit(split1); + multiTransaction.addSplit(split2); + multiTransaction.setCommodity(COMMODITY); + + mTransactionsDbAdapter.addRecord(multiTransaction); + + Transaction savedTransaction = mTransactionsDbAdapter.getRecord(multiTransaction.getUID()); + assertThat(savedTransaction.getSplits()).extracting("mQuantity").contains(expectedQty); + assertThat(savedTransaction.getSplits()).extracting("mValue").contains(expectedValue); + + assertThat(savedTransaction.getSplits(TRANSACTIONS_ACCOUNT_UID).get(0) + .isEquivalentTo(multiTransaction.getSplits(TRANSACTIONS_ACCOUNT_UID).get(0))) + .isTrue(); + + refreshTransactionsList(); + + //open transaction for editing + onView(withText(trnDescription)).check(matches(isDisplayed())); //transaction was added + onView(allOf(withParent(hasDescendant(withText(trnDescription))), + withId(R.id.edit_transaction))).perform(click()); + + onView(withId(R.id.input_transfer_account_spinner)).perform(click()); + onView(withText(TRANSFER_ACCOUNT_NAME)).perform(click()); + + onView(withId(R.id.input_transfer_account_spinner)).perform(click()); + onView(withText(euroAccount.getFullName())).perform(click()); + onView(withId(R.id.input_converted_amount)).perform(typeText("5")); + Espresso.closeSoftKeyboard(); + onView(withId(R.id.btn_save)).perform(click()); + + onView(withId(R.id.input_transfer_account_spinner)).perform(click()); + onView(withText(TRANSFER_ACCOUNT_NAME)).perform(click()); + + onView(withId(R.id.menu_save)).perform(click()); + + Transaction editedTransaction = mTransactionsDbAdapter.getRecord(multiTransaction.getUID()); + assertThat(editedTransaction.getSplits(TRANSACTIONS_ACCOUNT_UID).get(0) + .isEquivalentTo(savedTransaction.getSplits(TRANSACTIONS_ACCOUNT_UID).get(0))) + .isTrue(); + + Money firstAcctBalance = mAccountsDbAdapter.getAccountBalance(TRANSACTIONS_ACCOUNT_UID); + assertThat(firstAcctBalance).isEqualTo(editedTransaction.getBalance(TRANSACTIONS_ACCOUNT_UID)); + + Money transferBalance = mAccountsDbAdapter.getAccountBalance(TRANSFER_ACCOUNT_UID); + assertThat(transferBalance).isEqualTo(editedTransaction.getBalance(TRANSFER_ACCOUNT_UID)); + + assertThat(editedTransaction.getBalance(TRANSFER_ACCOUNT_UID)).isEqualTo(expectedValue); + + Split transferAcctSplit = editedTransaction.getSplits(TRANSFER_ACCOUNT_UID).get(0); + assertThat(transferAcctSplit.getQuantity()).isEqualTo(expectedValue); + assertThat(transferAcctSplit.getValue()).isEqualTo(expectedValue); + + } + /** * Simple wrapper for clicking on views with espresso * @param viewId View resource ID @@ -580,10 +873,26 @@ private void clickOnView(int viewId){ onView(withId(viewId)).perform(click()); } - @Override + /** + * Refresh the account list fragment + */ + private void refreshTransactionsList(){ + try { + mActivityRule.runOnUiThread(new Runnable() { + @Override + public void run() { + mTransactionsActivity.refresh(); + } + }); + } catch (Throwable throwable) { + System.err.println("Failed to refresh fragment"); + } + } + @After public void tearDown() throws Exception { - mTransactionsActivity.finish(); - super.tearDown(); + if (mTransactionsActivity != null) + mTransactionsActivity.finish(); } + } diff --git a/app/src/androidTest/java/org/gnucash/android/test/ui/util/DisableAnimationsRule.java b/app/src/androidTest/java/org/gnucash/android/test/ui/util/DisableAnimationsRule.java new file mode 100644 index 000000000..03fdbdefe --- /dev/null +++ b/app/src/androidTest/java/org/gnucash/android/test/ui/util/DisableAnimationsRule.java @@ -0,0 +1,59 @@ +package org.gnucash.android.test.ui.util; + +import android.os.IBinder; + +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +import java.lang.reflect.Method; +import java.util.Arrays; + +/** + * Created by Ngewi on 19.04.2016. + * Credit: https://product.reverb.com/2015/06/06/disabling-animations-in-espresso-for-android-testing/ + */ +public class DisableAnimationsRule implements TestRule { + private Method mSetAnimationScalesMethod; + private Method mGetAnimationScalesMethod; + private Object mWindowManagerObject; + + public DisableAnimationsRule() { + try { + Class windowManagerStubClazz = Class.forName("android.view.IWindowManager$Stub"); + Method asInterface = windowManagerStubClazz.getDeclaredMethod("asInterface", IBinder.class); + + Class serviceManagerClazz = Class.forName("android.os.ServiceManager"); + Method getService = serviceManagerClazz.getDeclaredMethod("getService", String.class); + + Class windowManagerClazz = Class.forName("android.view.IWindowManager"); + + mSetAnimationScalesMethod = windowManagerClazz.getDeclaredMethod("setAnimationScales", float[].class); + mGetAnimationScalesMethod = windowManagerClazz.getDeclaredMethod("getAnimationScales"); + + IBinder windowManagerBinder = (IBinder) getService.invoke(null, "window"); + mWindowManagerObject = asInterface.invoke(null, windowManagerBinder); + } + catch (Exception e) { + throw new RuntimeException("Failed to access animation methods", e); + } + } + + @Override + public Statement apply(final Statement statement, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + setAnimationScaleFactors(0.0f); + try { statement.evaluate(); } + finally { setAnimationScaleFactors(1.0f); } + } + }; + } + + private void setAnimationScaleFactors(float scaleFactor) throws Exception { + float[] scaleFactors = (float[]) mGetAnimationScalesMethod.invoke(mWindowManagerObject); + Arrays.fill(scaleFactors, scaleFactor); + mSetAnimationScalesMethod.invoke(mWindowManagerObject, scaleFactors); + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/gnucash/android/test/ui/GnucashAndroidTestRunner.java b/app/src/androidTest/java/org/gnucash/android/test/ui/util/GnucashAndroidTestRunner.java similarity index 99% rename from app/src/androidTest/java/org/gnucash/android/test/ui/GnucashAndroidTestRunner.java rename to app/src/androidTest/java/org/gnucash/android/test/ui/util/GnucashAndroidTestRunner.java index 3418f8796..939b7bb45 100644 --- a/app/src/androidTest/java/org/gnucash/android/test/ui/GnucashAndroidTestRunner.java +++ b/app/src/androidTest/java/org/gnucash/android/test/ui/util/GnucashAndroidTestRunner.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.gnucash.android.test.ui; +package org.gnucash.android.test.ui.util; import android.content.pm.PackageManager; import android.os.Bundle; diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b35c4450d..45ec83f68 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,7 +50,8 @@ + android:theme="@style/Theme.GnucashTheme.NoActionBar" + android:allowBackup="true"> @@ -82,8 +83,6 @@ android:noHistory="true" android:windowSoftInputMode="stateAlwaysHidden"> - @@ -102,8 +101,12 @@ android:configChanges="orientation|screenSize"/> + + - color. * Use for theming the status bar color when setting the color of the actionBar @@ -104,34 +118,50 @@ public void onCreate(){ GnuCashApplication.context = getApplicationContext(); Fabric.with(this, new Crashlytics.Builder().core( - new CrashlyticsCore.Builder().disabled(!isCrashlyticsEnabled()).build()).build()); + new CrashlyticsCore.Builder().disabled(!isCrashlyticsEnabled()).build()) + .build()); - // Set this up once when your application launches - Config config = new Config("gnucash.uservoice.com"); - config.setTopicId(107400); - config.setForumId(320493); - config.putUserTrait("app_version_name", BuildConfig.VERSION_NAME); - config.putUserTrait("app_version_code", BuildConfig.VERSION_CODE); - config.putUserTrait("android_version", Build.VERSION.RELEASE); - // config.identifyUser("USER_ID", "User Name", "email@example.com"); - UserVoice.init(config, this); + setUpUserVoice(); - mDbHelper = new DatabaseHelper(getApplicationContext()); + BookDbHelper bookDbHelper = new BookDbHelper(getApplicationContext()); + mBooksDbAdapter = new BooksDbAdapter(bookDbHelper.getWritableDatabase()); + + initDatabaseAdapters(); + setDefaultCurrencyCode(getDefaultCurrencyCode()); + + if (BuildConfig.DEBUG && !isRoboUnitTest()) + setUpRemoteDebuggingFromChrome(); + } + + /** + * Initialize database adapter singletons for use in the application + * This method should be called every time a new book is opened + */ + private static void initDatabaseAdapters() { + if (mDbHelper != null){ //close if open + mDbHelper.getReadableDatabase().close(); + } + + mDbHelper = new DatabaseHelper(getAppContext(), + mBooksDbAdapter.getActiveBookUID()); + SQLiteDatabase mainDb; try { - mDb = mDbHelper.getWritableDatabase(); + mainDb = mDbHelper.getWritableDatabase(); } catch (SQLException e) { Crashlytics.logException(e); - Log.e(getClass().getName(), "Error getting database: " + e.getMessage()); - mDb = mDbHelper.getReadableDatabase(); + Log.e("GnuCashApplication", "Error getting database: " + e.getMessage()); + mainDb = mDbHelper.getReadableDatabase(); } - mSplitsDbAdapter = new SplitsDbAdapter(mDb); - mTransactionsDbAdapter = new TransactionsDbAdapter(mDb, mSplitsDbAdapter); - mAccountsDbAdapter = new AccountsDbAdapter(mDb, mTransactionsDbAdapter); - mScheduledActionDbAdapter = new ScheduledActionDbAdapter(mDb); - mCommoditiesDbAdapter = new CommoditiesDbAdapter(mDb); - mPricesDbAdapter = new PricesDbAdapter(mDb); - setDefaultCurrencyCode(getDefaultCurrencyCode()); + mSplitsDbAdapter = new SplitsDbAdapter(mainDb); + mTransactionsDbAdapter = new TransactionsDbAdapter(mainDb, mSplitsDbAdapter); + mAccountsDbAdapter = new AccountsDbAdapter(mainDb, mTransactionsDbAdapter); + mRecurrenceDbAdapter = new RecurrenceDbAdapter(mainDb); + mScheduledActionDbAdapter = new ScheduledActionDbAdapter(mainDb, mRecurrenceDbAdapter); + mPricesDbAdapter = new PricesDbAdapter(mainDb); + mCommoditiesDbAdapter = new CommoditiesDbAdapter(mainDb); + mBudgetAmountsDbAdapter = new BudgetAmountsDbAdapter(mainDb); + mBudgetsDbAdapter = new BudgetsDbAdapter(mainDb, mBudgetAmountsDbAdapter, mRecurrenceDbAdapter); } public static AccountsDbAdapter getAccountsDbAdapter() { @@ -158,6 +188,40 @@ public static PricesDbAdapter getPricesDbAdapter(){ return mPricesDbAdapter; } + public static BudgetsDbAdapter getBudgetDbAdapter() { + return mBudgetsDbAdapter; + } + + public static RecurrenceDbAdapter getRecurrenceDbAdapter() { + return mRecurrenceDbAdapter; + } + + public static BudgetAmountsDbAdapter getBudgetAmountsDbAdapter(){ + return mBudgetAmountsDbAdapter; + } + + public static BooksDbAdapter getBooksDbAdapter(){ + return mBooksDbAdapter; + } + + /** + * Loads the book with GUID {@code bookUID} + * @param bookUID GUID of the book to be loaded + */ + public static void loadBook(@NonNull String bookUID){ + mBooksDbAdapter.setActive(bookUID); + initDatabaseAdapters(); + AccountsActivity.start(getAppContext()); + } + + /** + * Returns the currently active database in the application + * @return Currently active {@link SQLiteDatabase} + */ + public static SQLiteDatabase getActiveDb(){ + return mDbHelper.getWritableDatabase(); + } + /** * Returns the application context * @return Application {@link Context} object @@ -171,18 +235,25 @@ public static Context getAppContext() { * @return {@code true} if crashlytics is enabled, {@code false} otherwise */ public static boolean isCrashlyticsEnabled(){ - final Context context = getAppContext(); return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(context.getString(R.string.key_enable_crashlytics), false); } + /** + * Returns {@code true} if the app is being run by robolectric + * @return {@code true} if in unit testing, {@code false} otherwise + */ + public static boolean isRoboUnitTest(){ + return "robolectric".equals(Build.FINGERPRINT); + } + /** * Returns true if double entry is enabled in the app settings, false otherwise. * If the value is not set, the default value can be specified in the parameters. * @return true if double entry is enabled, false otherwise */ public static boolean isDoubleEntryEnabled(){ - SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context); - return sharedPrefs.getBoolean(context.getString(R.string.key_use_double_entry), false); + SharedPreferences sharedPrefs = PreferenceActivity.getActiveBookSharedPreferences(); + return sharedPrefs.getBoolean(context.getString(R.string.key_use_double_entry), true); } /** @@ -192,7 +263,7 @@ public static boolean isDoubleEntryEnabled(){ * @return true if opening balances should be saved, false otherwise */ public static boolean shouldSaveOpeningBalances(boolean defaultValue){ - SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context); + SharedPreferences sharedPrefs = PreferenceActivity.getActiveBookSharedPreferences(); return sharedPrefs.getBoolean(context.getString(R.string.key_save_opening_balances), defaultValue); } @@ -233,7 +304,7 @@ public static String getDefaultCurrencyCode(){ * @see #getDefaultCurrencyCode() */ public static void setDefaultCurrencyCode(@NonNull String currencyCode){ - PreferenceManager.getDefaultSharedPreferences(getAppContext()).edit() + PreferenceManager.getDefaultSharedPreferences(context).edit() .putString(getAppContext().getString(R.string.key_default_currency), currencyCode) .apply(); Money.DEFAULT_CURRENCY_CODE = currencyCode; @@ -270,19 +341,51 @@ public static Locale getDefaultLocale() { * @param context Application context */ public static void startScheduledActionExecutionService(Context context){ - Intent alarmIntent = new Intent(context, SchedulerService.class); + Intent alarmIntent = new Intent(context, ScheduledActionService.class); PendingIntent pendingIntent = PendingIntent.getService(context, 0, alarmIntent, PendingIntent.FLAG_NO_CREATE); - if (pendingIntent != null) + + if (pendingIntent != null) //if service is already scheduled, just return return; else pendingIntent = PendingIntent.getService(context, 0, alarmIntent, 0); AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, - System.currentTimeMillis() + AlarmManager.INTERVAL_DAY, - AlarmManager.INTERVAL_HALF_DAY, - pendingIntent); + alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + AlarmManager.INTERVAL_FIFTEEN_MINUTES, + AlarmManager.INTERVAL_HALF_DAY, pendingIntent); context.startService(alarmIntent); //run the service the first time } + + /** + * Sets up UserVoice. + * + *

Allows users to contact with us and access help topics.

+ */ + private void setUpUserVoice() { + // Set this up once when your application launches + Config config = new Config("gnucash.uservoice.com"); + config.setTopicId(107400); + config.setForumId(320493); + config.putUserTrait("app_version_name", BuildConfig.VERSION_NAME); + config.putUserTrait("app_version_code", BuildConfig.VERSION_CODE); + config.putUserTrait("android_version", Build.VERSION.RELEASE); + // config.identifyUser("USER_ID", "User Name", "email@example.com"); + UserVoice.init(config, this); + } + + /** + * Sets up Stetho to enable remote debugging from Chrome developer tools. + * + *

Among other things, allows access to the database and preferences. + * See http://facebook.github.io/stetho/#features

+ */ + private void setUpRemoteDebuggingFromChrome() { + Stetho.Initializer initializer = + Stetho.newInitializerBuilder(this) + .enableWebKitInspector( + Stetho.defaultInspectorModulesProvider(this)) + .build(); + Stetho.initialize(initializer); + } } \ No newline at end of file diff --git a/app/src/main/java/org/gnucash/android/db/BookDbHelper.java b/app/src/main/java/org/gnucash/android/db/BookDbHelper.java new file mode 100644 index 000000000..87adc17d2 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/db/BookDbHelper.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.db; + +import android.content.ContentValues; +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteStatement; +import android.util.Log; + +import com.crashlytics.android.Crashlytics; + +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseSchema.BookEntry; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; +import org.gnucash.android.export.Exporter; +import org.gnucash.android.model.Book; +import org.gnucash.android.util.RecursiveMoveFiles; + +import java.io.File; +import java.io.IOException; + +/** + * Database helper for managing database which stores information about the books in the application + * This is a different database from the one which contains the accounts and transaction data because + * there are multiple accounts/transactions databases in the system and this one will be used to + * switch between them. + */ +public class BookDbHelper extends SQLiteOpenHelper { + + public static final String LOG_TAG = "BookDbHelper"; + + private Context mContext; + + /** + * Create the books table + */ + private static final String BOOKS_TABLE_CREATE = "CREATE TABLE " + BookEntry.TABLE_NAME + " (" + + BookEntry._ID + " integer primary key autoincrement, " + + BookEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + + BookEntry.COLUMN_DISPLAY_NAME + " varchar(255) not null, " + + BookEntry.COLUMN_ROOT_GUID + " varchar(255) not null, " + + BookEntry.COLUMN_TEMPLATE_GUID + " varchar(255), " + + BookEntry.COLUMN_ACTIVE + " tinyint default 0, " + + BookEntry.COLUMN_SOURCE_URI + " varchar(255), " + + BookEntry.COLUMN_LAST_SYNC + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + BookEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + BookEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP " + + ");" + DatabaseHelper.createUpdatedAtTrigger(BookEntry.TABLE_NAME); + + public BookDbHelper(Context context) { + super(context, DatabaseSchema.BOOK_DATABASE_NAME, null, DatabaseSchema.BOOK_DATABASE_VERSION); + mContext = context; + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(BOOKS_TABLE_CREATE); + + if (mContext.getDatabasePath(DatabaseSchema.LEGACY_DATABASE_NAME).exists()){ + Log.d(LOG_TAG, "Legacy database found. Migrating to multibook format"); + DatabaseHelper helper = new DatabaseHelper(GnuCashApplication.getAppContext(), + DatabaseSchema.LEGACY_DATABASE_NAME); + SQLiteDatabase mainDb = helper.getWritableDatabase(); + AccountsDbAdapter accountsDbAdapter = new AccountsDbAdapter(mainDb, + new TransactionsDbAdapter(mainDb, new SplitsDbAdapter(mainDb))); + + String rootAccountUID = accountsDbAdapter.getOrCreateGnuCashRootAccountUID(); + + Book book = new Book(rootAccountUID); + book.setActive(true); + insertBook(db, book); + + String mainDbPath = mainDb.getPath(); + helper.close(); + + File src = new File(mainDbPath); + File dst = new File(src.getParent(), book.getUID()); + try { + MigrationHelper.moveFile(src, dst); + } catch (IOException e) { + String err_msg = "Error renaming database file"; + Crashlytics.log(err_msg); + Log.e(LOG_TAG, err_msg, e); + } + + migrateBackupFiles(book.getUID()); + } + + String sql = "SELECT COUNT(*) FROM " + BookEntry.TABLE_NAME; + SQLiteStatement statement = db.compileStatement(sql); + long count = statement.simpleQueryForLong(); + if (count == 0) { //no book in the database, create a default one + Log.i(LOG_TAG, "No books found in database, creating default book"); + Book book = new Book(); + DatabaseHelper helper = new DatabaseHelper(GnuCashApplication.getAppContext(), book.getUID()); + SQLiteDatabase mainDb = helper.getWritableDatabase(); //actually create the db + AccountsDbAdapter accountsDbAdapter = new AccountsDbAdapter(mainDb, + new TransactionsDbAdapter(mainDb, new SplitsDbAdapter(mainDb))); + + String rootAccountUID = accountsDbAdapter.getOrCreateGnuCashRootAccountUID(); + book.setRootAccountUID(rootAccountUID); + book.setActive(true); + insertBook(db, book); + } + + } + + /** + * Inserts the book into the database + * @param db Book database + * @param book Book to insert + */ + private void insertBook(SQLiteDatabase db, Book book) { + ContentValues contentValues = new ContentValues(); + contentValues.put(BookEntry.COLUMN_UID, book.getUID()); + contentValues.put(BookEntry.COLUMN_ROOT_GUID, book.getRootAccountUID()); + contentValues.put(BookEntry.COLUMN_TEMPLATE_GUID, Book.generateUID()); + contentValues.put(BookEntry.COLUMN_DISPLAY_NAME, new BooksDbAdapter(db).generateDefaultBookName()); + contentValues.put(BookEntry.COLUMN_ACTIVE, book.isActive() ? 1 : 0); + + db.insert(BookEntry.TABLE_NAME, null, contentValues); + } + + /** + * Move the backup and export files from the old location (single-book) to the new multi-book + * backup folder structure. Each book has its own directory as well as backups and exports. + *

This method should be called only once during the initial migration to multi-book support

+ * @param activeBookUID GUID of the book for which to migrate the files + */ + private void migrateBackupFiles(String activeBookUID){ + + Log.d(LOG_TAG, "Moving export and backup files to book-specific folders"); + File newBasePath = new File(Exporter.BASE_FOLDER_PATH + "/" + activeBookUID); + newBasePath.mkdirs(); + + File src = new File(Exporter.BASE_FOLDER_PATH + "/backups/"); + File dst = new File(Exporter.BASE_FOLDER_PATH + "/" + activeBookUID + "/backups/"); + new Thread(new RecursiveMoveFiles(src, dst)).start(); + + src = new File(Exporter.BASE_FOLDER_PATH + "/exports/"); + dst = new File(Exporter.BASE_FOLDER_PATH + "/" + activeBookUID + "/exports/"); + new Thread(new RecursiveMoveFiles(src, dst)).start(); + + File nameFile = new File(newBasePath, "Book 1"); + try { + nameFile.createNewFile(); + } catch (IOException e) { + Log.e(LOG_TAG, "Error creating name file for the database: " + nameFile.getName()); + e.printStackTrace(); + } + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + //nothing to see here yet, move along + } +} diff --git a/app/src/main/java/org/gnucash/android/db/DatabaseCursorLoader.java b/app/src/main/java/org/gnucash/android/db/DatabaseCursorLoader.java index 21a426e17..849400e64 100644 --- a/app/src/main/java/org/gnucash/android/db/DatabaseCursorLoader.java +++ b/app/src/main/java/org/gnucash/android/db/DatabaseCursorLoader.java @@ -21,11 +21,13 @@ import android.support.v4.content.AsyncTaskLoader; import android.support.v4.content.Loader; +import org.gnucash.android.db.adapter.DatabaseAdapter; + /** * Abstract base class for asynchronously loads records from a database and manages the cursor. * In order to use this class, you must subclass it and implement the * {@link #loadInBackground()} method to load the particular records from the database. - * Ideally, the database has {@link DatabaseAdapter} which is used for managing access to the + * Ideally, the database has {@link DatabaseAdapter} which is used for managing access to the * records from the database * @author Ngewi Fet * @see DatabaseAdapter diff --git a/app/src/main/java/org/gnucash/android/db/DatabaseHelper.java b/app/src/main/java/org/gnucash/android/db/DatabaseHelper.java index 639b79cd4..ac7dbabda 100644 --- a/app/src/main/java/org/gnucash/android/db/DatabaseHelper.java +++ b/app/src/main/java/org/gnucash/android/db/DatabaseHelper.java @@ -34,7 +34,16 @@ import javax.xml.parsers.ParserConfigurationException; -import static org.gnucash.android.db.DatabaseSchema.*; +import static org.gnucash.android.db.DatabaseSchema.AccountEntry; +import static org.gnucash.android.db.DatabaseSchema.BudgetAmountEntry; +import static org.gnucash.android.db.DatabaseSchema.BudgetEntry; +import static org.gnucash.android.db.DatabaseSchema.CommodityEntry; +import static org.gnucash.android.db.DatabaseSchema.CommonColumns; +import static org.gnucash.android.db.DatabaseSchema.PriceEntry; +import static org.gnucash.android.db.DatabaseSchema.RecurrenceEntry; +import static org.gnucash.android.db.DatabaseSchema.ScheduledActionEntry; +import static org.gnucash.android.db.DatabaseSchema.SplitEntry; +import static org.gnucash.android.db.DatabaseSchema.TransactionEntry; /** * Helper class for managing the SQLite database. * Creates the database and handles upgrades @@ -47,13 +56,8 @@ public class DatabaseHelper extends SQLiteOpenHelper { * Tag for logging */ public static final String LOG_TAG = DatabaseHelper.class.getName(); - - /** - * Name of the database - */ - private static final String DATABASE_NAME = "gnucash_db"; - /** + /** * SQL statement to create the accounts table in the database */ private static final String ACCOUNTS_TABLE_CREATE = "create table " + AccountEntry.TABLE_NAME + " (" @@ -111,28 +115,36 @@ public class DatabaseHelper extends SQLiteOpenHelper { + SplitEntry.COLUMN_QUANTITY_DENOM + " integer not null, " + SplitEntry.COLUMN_ACCOUNT_UID + " varchar(255) not null, " + SplitEntry.COLUMN_TRANSACTION_UID + " varchar(255) not null, " - + SplitEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " - + SplitEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + SplitEntry.COLUMN_RECONCILE_STATE + " varchar(1) not null default 'n', " + + SplitEntry.COLUMN_RECONCILE_DATE + " timestamp not null default current_timestamp, " + + SplitEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + SplitEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + "FOREIGN KEY (" + SplitEntry.COLUMN_ACCOUNT_UID + ") REFERENCES " + AccountEntry.TABLE_NAME + " (" + AccountEntry.COLUMN_UID + ") ON DELETE CASCADE, " + "FOREIGN KEY (" + SplitEntry.COLUMN_TRANSACTION_UID + ") REFERENCES " + TransactionEntry.TABLE_NAME + " (" + TransactionEntry.COLUMN_UID + ") ON DELETE CASCADE " + ");" + createUpdatedAtTrigger(SplitEntry.TABLE_NAME); public static final String SCHEDULED_ACTIONS_TABLE_CREATE = "CREATE TABLE " + ScheduledActionEntry.TABLE_NAME + " (" - + ScheduledActionEntry._ID + " integer primary key autoincrement, " - + ScheduledActionEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " - + ScheduledActionEntry.COLUMN_ACTION_UID + " varchar(255) not null, " - + ScheduledActionEntry.COLUMN_TYPE + " varchar(255) not null, " - + ScheduledActionEntry.COLUMN_PERIOD + " integer not null, " - + ScheduledActionEntry.COLUMN_LAST_RUN + " integer default 0, " - + ScheduledActionEntry.COLUMN_START_TIME + " integer not null, " - + ScheduledActionEntry.COLUMN_END_TIME + " integer default 0, " - + ScheduledActionEntry.COLUMN_TAG + " text, " - + ScheduledActionEntry.COLUMN_ENABLED + " tinyint default 1, " //enabled by default - + ScheduledActionEntry.COLUMN_TOTAL_FREQUENCY + " integer default 0, " - + ScheduledActionEntry.COLUMN_EXECUTION_COUNT+ " integer default 0, " - + ScheduledActionEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " - + ScheduledActionEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP " + + ScheduledActionEntry._ID + " integer primary key autoincrement, " + + ScheduledActionEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + + ScheduledActionEntry.COLUMN_ACTION_UID + " varchar(255) not null, " + + ScheduledActionEntry.COLUMN_TYPE + " varchar(255) not null, " + + ScheduledActionEntry.COLUMN_RECURRENCE_UID + " varchar(255) not null, " + + ScheduledActionEntry.COLUMN_TEMPLATE_ACCT_UID + " varchar(255) not null, " + + ScheduledActionEntry.COLUMN_LAST_RUN + " integer default 0, " + + ScheduledActionEntry.COLUMN_START_TIME + " integer not null, " + + ScheduledActionEntry.COLUMN_END_TIME + " integer default 0, " + + ScheduledActionEntry.COLUMN_TAG + " text, " + + ScheduledActionEntry.COLUMN_ENABLED + " tinyint default 1, " //enabled by default + + ScheduledActionEntry.COLUMN_AUTO_CREATE + " tinyint default 1, " + + ScheduledActionEntry.COLUMN_AUTO_NOTIFY + " tinyint default 0, " + + ScheduledActionEntry.COLUMN_ADVANCE_CREATION + " integer default 0, " + + ScheduledActionEntry.COLUMN_ADVANCE_NOTIFY + " integer default 0, " + + ScheduledActionEntry.COLUMN_TOTAL_FREQUENCY + " integer default 0, " + + ScheduledActionEntry.COLUMN_EXECUTION_COUNT + " integer default 0, " + + ScheduledActionEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + ScheduledActionEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "FOREIGN KEY (" + ScheduledActionEntry.COLUMN_RECURRENCE_UID + ") REFERENCES " + RecurrenceEntry.TABLE_NAME + " (" + RecurrenceEntry.COLUMN_UID + ") " + ");" + createUpdatedAtTrigger(ScheduledActionEntry.TABLE_NAME); public static final String COMMODITIES_TABLE_CREATE = "CREATE TABLE " + DatabaseSchema.CommodityEntry.TABLE_NAME + " (" @@ -169,12 +181,54 @@ public class DatabaseHelper extends SQLiteOpenHelper { + "FOREIGN KEY (" + PriceEntry.COLUMN_CURRENCY_UID + ") REFERENCES " + CommodityEntry.TABLE_NAME + " (" + CommodityEntry.COLUMN_UID + ") ON DELETE CASCADE " + ");" + createUpdatedAtTrigger(PriceEntry.TABLE_NAME); + + private static final String BUDGETS_TABLE_CREATE = "CREATE TABLE " + BudgetEntry.TABLE_NAME + " (" + + BudgetEntry._ID + " integer primary key autoincrement, " + + BudgetEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + + BudgetEntry.COLUMN_NAME + " varchar(255) not null, " + + BudgetEntry.COLUMN_DESCRIPTION + " varchar(255), " + + BudgetEntry.COLUMN_RECURRENCE_UID + " varchar(255) not null, " + + BudgetEntry.COLUMN_NUM_PERIODS + " integer, " + + BudgetEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + BudgetEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "FOREIGN KEY (" + BudgetEntry.COLUMN_RECURRENCE_UID + ") REFERENCES " + RecurrenceEntry.TABLE_NAME + " (" + RecurrenceEntry.COLUMN_UID + ") " + + ");" + createUpdatedAtTrigger(BudgetEntry.TABLE_NAME); + + private static final String BUDGET_AMOUNTS_TABLE_CREATE = "CREATE TABLE " + BudgetAmountEntry.TABLE_NAME + " (" + + BudgetAmountEntry._ID + " integer primary key autoincrement, " + + BudgetAmountEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + + BudgetAmountEntry.COLUMN_BUDGET_UID + " varchar(255) not null, " + + BudgetAmountEntry.COLUMN_ACCOUNT_UID + " varchar(255) not null, " + + BudgetAmountEntry.COLUMN_AMOUNT_NUM + " integer not null, " + + BudgetAmountEntry.COLUMN_AMOUNT_DENOM + " integer not null, " + + BudgetAmountEntry.COLUMN_PERIOD_NUM + " integer not null, " + + BudgetAmountEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + BudgetAmountEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "FOREIGN KEY (" + BudgetAmountEntry.COLUMN_ACCOUNT_UID + ") REFERENCES " + AccountEntry.TABLE_NAME + " (" + AccountEntry.COLUMN_UID + ") ON DELETE CASCADE, " + + "FOREIGN KEY (" + BudgetAmountEntry.COLUMN_BUDGET_UID + ") REFERENCES " + BudgetEntry.TABLE_NAME + " (" + BudgetEntry.COLUMN_UID + ") ON DELETE CASCADE " + + ");" + createUpdatedAtTrigger(BudgetAmountEntry.TABLE_NAME); + + + private static final String RECURRENCE_TABLE_CREATE = "CREATE TABLE " + RecurrenceEntry.TABLE_NAME + " (" + + RecurrenceEntry._ID + " integer primary key autoincrement, " + + RecurrenceEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + + RecurrenceEntry.COLUMN_MULTIPLIER + " integer not null default 1, " + + RecurrenceEntry.COLUMN_PERIOD_TYPE + " varchar(255) not null, " + + RecurrenceEntry.COLUMN_BYDAY + " varchar(255), " + + RecurrenceEntry.COLUMN_PERIOD_START + " timestamp not null, " + + RecurrenceEntry.COLUMN_PERIOD_END + " timestamp, " + + RecurrenceEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + RecurrenceEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP); " + + createUpdatedAtTrigger(RecurrenceEntry.TABLE_NAME); + + /** * Constructor * @param context Application context + * @param databaseName Name of the database */ - public DatabaseHelper(Context context){ - super(context, DATABASE_NAME, null, DatabaseSchema.DATABASE_VERSION); + public DatabaseHelper(Context context, String databaseName){ + super(context, databaseName, null, DatabaseSchema.DATABASE_VERSION); } @@ -196,6 +250,7 @@ static String createUpdatedAtTrigger(String tableName){ @Override public void onCreate(SQLiteDatabase db) { createDatabaseTables(db); + } @Override @@ -213,8 +268,8 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion){ /* * NOTE: In order to modify the database, create a new static method in the MigrationHelper class * called upgradeDbToVersion<#>, e.g. int upgradeDbToVersion10(SQLiteDatabase) in order to upgrade to version 10. - * The upgrade method should return the upgraded database version as the return value. - * Then all you need to do is increment the DatabaseSchema.DATABASE_VERSION to the appropriate number. + * The upgrade method should return the new (upgraded) database version as the return value. + * Then all you need to do is increment the DatabaseSchema.DATABASE_VERSION to the appropriate number to trigger an upgrade. */ if (oldVersion > newVersion) { throw new IllegalArgumentException("Database downgrades are not supported at the moment"); @@ -247,7 +302,7 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion){ /** - * Creates the tables in the database + * Creates the tables in the database and import default commodities into the database * @param db Database instance */ private void createDatabaseTables(SQLiteDatabase db) { @@ -258,6 +313,10 @@ private void createDatabaseTables(SQLiteDatabase db) { db.execSQL(SCHEDULED_ACTIONS_TABLE_CREATE); db.execSQL(COMMODITIES_TABLE_CREATE); db.execSQL(PRICES_TABLE_CREATE); + db.execSQL(RECURRENCE_TABLE_CREATE); + db.execSQL(BUDGETS_TABLE_CREATE); + db.execSQL(BUDGET_AMOUNTS_TABLE_CREATE); + String createAccountUidIndex = "CREATE UNIQUE INDEX '" + AccountEntry.INDEX_UID + "' ON " + AccountEntry.TABLE_NAME + "(" + AccountEntry.COLUMN_UID + ")"; @@ -277,12 +336,24 @@ private void createDatabaseTables(SQLiteDatabase db) { String createPriceUidIndex = "CREATE UNIQUE INDEX '" + PriceEntry.INDEX_UID + "' ON " + PriceEntry.TABLE_NAME + "(" + PriceEntry.COLUMN_UID + ")"; + String createBudgetUidIndex = "CREATE UNIQUE INDEX '" + BudgetEntry.INDEX_UID + + "' ON " + BudgetEntry.TABLE_NAME + "(" + BudgetEntry.COLUMN_UID + ")"; + + String createBudgetAmountUidIndex = "CREATE UNIQUE INDEX '" + BudgetAmountEntry.INDEX_UID + + "' ON " + BudgetAmountEntry.TABLE_NAME + "(" + BudgetAmountEntry.COLUMN_UID + ")"; + + String createRecurrenceUidIndex = "CREATE UNIQUE INDEX '" + RecurrenceEntry.INDEX_UID + + "' ON " + RecurrenceEntry.TABLE_NAME + "(" + RecurrenceEntry.COLUMN_UID + ")"; + db.execSQL(createAccountUidIndex); db.execSQL(createTransactionUidIndex); db.execSQL(createSplitUidIndex); db.execSQL(createScheduledEventUidIndex); db.execSQL(createCommodityUidIndex); db.execSQL(createPriceUidIndex); + db.execSQL(createBudgetUidIndex); + db.execSQL(createRecurrenceUidIndex); + db.execSQL(createBudgetAmountUidIndex); try { MigrationHelper.importCommodities(db); diff --git a/app/src/main/java/org/gnucash/android/db/DatabaseSchema.java b/app/src/main/java/org/gnucash/android/db/DatabaseSchema.java index 7d833a848..0f32f068d 100644 --- a/app/src/main/java/org/gnucash/android/db/DatabaseSchema.java +++ b/app/src/main/java/org/gnucash/android/db/DatabaseSchema.java @@ -24,16 +24,32 @@ * @author Ngewi Fet */ public class DatabaseSchema { + /** - * Database version. + * Name of database storing information about the books in the application + */ + public static final String BOOK_DATABASE_NAME = "gnucash_books.db"; + + /** + * Version number of database containing information about the books in the application + */ + public static final int BOOK_DATABASE_VERSION = 1; + + /** + * Version number of database containing accounts and transactions info. * With any change to the database schema, this number must increase */ - public static final int DATABASE_VERSION = 12; + public static final int DATABASE_VERSION = 13; /** - * Database version where Splits were introduced + * Name of the database + *

This was used when the application had only one database per instance. + * Now there can be multiple databases for each book imported + *

+ * @deprecated Each database uses the GUID of the root account as name */ - public static final int SPLITS_DB_VERSION = 7; + @Deprecated + public static final String LEGACY_DATABASE_NAME = "gnucash_db"; //no instances are to be instantiated private DatabaseSchema(){} @@ -44,6 +60,17 @@ public interface CommonColumns extends BaseColumns { public static final String COLUMN_MODIFIED_AT = "modified_at"; } + public static abstract class BookEntry implements CommonColumns { + public static final String TABLE_NAME = "books"; + + public static final String COLUMN_DISPLAY_NAME = "name"; + public static final String COLUMN_SOURCE_URI = "uri"; + public static final String COLUMN_ROOT_GUID = "root_account_guid"; + public static final String COLUMN_TEMPLATE_GUID = "root_template_guid"; + public static final String COLUMN_ACTIVE = "is_active"; + public static final String COLUMN_LAST_SYNC = "last_export_time"; + } + /** * Columns for the account tables */ @@ -116,6 +143,9 @@ public static abstract class SplitEntry implements CommonColumns { public static final String COLUMN_ACCOUNT_UID = "account_uid"; public static final String COLUMN_TRANSACTION_UID = "transaction_uid"; + public static final String COLUMN_RECONCILE_STATE = "reconcile_state"; + public static final String COLUMN_RECONCILE_DATE = "reconcile_date"; + public static final String INDEX_UID = "split_uid_index"; } @@ -127,12 +157,27 @@ public static abstract class ScheduledActionEntry implements CommonColumns { public static final String COLUMN_START_TIME = "start_time"; public static final String COLUMN_END_TIME = "end_time"; public static final String COLUMN_LAST_RUN = "last_run"; - public static final String COLUMN_PERIOD = "period"; - public static final String COLUMN_TAG = "tag"; //for any action-specific information + + /** + * Tag for scheduledAction-specific information e.g. backup parameters for backup + */ + public static final String COLUMN_TAG = "tag"; public static final String COLUMN_ENABLED = "is_enabled"; public static final String COLUMN_TOTAL_FREQUENCY = "total_frequency"; + + /** + * Number of times this scheduledAction has been run. Analogous to instance_count in GnuCash desktop SQL + */ public static final String COLUMN_EXECUTION_COUNT = "execution_count"; + public static final String COLUMN_RECURRENCE_UID = "recurrence_uid"; + public static final String COLUMN_AUTO_CREATE = "auto_create"; + public static final String COLUMN_AUTO_NOTIFY = "auto_notify"; + public static final String COLUMN_ADVANCE_CREATION = "adv_creation"; + public static final String COLUMN_ADVANCE_NOTIFY = "adv_notify"; + public static final String COLUMN_TEMPLATE_ACCT_UID = "template_act_uid"; + + public static final String INDEX_UID = "scheduled_action_uid_index"; } @@ -191,4 +236,42 @@ public static abstract class PriceEntry implements CommonColumns { public static final String INDEX_UID = "prices_uid_index"; } + + + public static abstract class BudgetEntry implements CommonColumns { + public static final String TABLE_NAME = "budgets"; + + public static final String COLUMN_NAME = "name"; + public static final String COLUMN_DESCRIPTION = "description"; + public static final String COLUMN_NUM_PERIODS = "num_periods"; + public static final String COLUMN_RECURRENCE_UID = "recurrence_uid"; + + public static final String INDEX_UID = "budgets_uid_index"; + } + + + public static abstract class BudgetAmountEntry implements CommonColumns { + public static final String TABLE_NAME = "budget_amounts"; + + public static final String COLUMN_BUDGET_UID = "budget_uid"; + public static final String COLUMN_ACCOUNT_UID = "account_uid"; + public static final String COLUMN_PERIOD_NUM = "period_num"; + public static final String COLUMN_AMOUNT_NUM = "amount_num"; + public static final String COLUMN_AMOUNT_DENOM = "amount_denom"; + + public static final String INDEX_UID = "budget_amounts_uid_index"; + } + + + public static abstract class RecurrenceEntry implements CommonColumns { + public static final String TABLE_NAME = "recurrences"; + + public static final String COLUMN_MULTIPLIER = "recurrence_mult"; + public static final String COLUMN_PERIOD_TYPE = "recurrence_period_type"; + public static final String COLUMN_PERIOD_START = "recurrence_period_start"; + public static final String COLUMN_PERIOD_END = "recurrence_period_end"; + public static final String COLUMN_BYDAY = "recurrence_byday"; + + public static final String INDEX_UID = "recurrence_uid_index"; + } } diff --git a/app/src/main/java/org/gnucash/android/db/MigrationHelper.java b/app/src/main/java/org/gnucash/android/db/MigrationHelper.java index 1b43bdee1..ce8e6c294 100644 --- a/app/src/main/java/org/gnucash/android/db/MigrationHelper.java +++ b/app/src/main/java/org/gnucash/android/db/MigrationHelper.java @@ -21,9 +21,11 @@ import android.content.ContentValues; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.os.Environment; +import android.support.v7.preference.PreferenceManager; import android.text.TextUtils; import android.util.Log; @@ -31,6 +33,7 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.export.ExportFormat; import org.gnucash.android.export.ExportParams; import org.gnucash.android.export.Exporter; @@ -39,8 +42,11 @@ import org.gnucash.android.model.BaseModel; import org.gnucash.android.model.Commodity; import org.gnucash.android.model.Money; +import org.gnucash.android.model.PeriodType; +import org.gnucash.android.model.Recurrence; import org.gnucash.android.model.ScheduledAction; import org.gnucash.android.model.Transaction; +import org.gnucash.android.service.ScheduledActionService; import org.gnucash.android.util.PreferencesHelper; import org.gnucash.android.util.TimestampHelper; import org.xml.sax.InputSource; @@ -67,9 +73,12 @@ import javax.xml.parsers.SAXParserFactory; import static org.gnucash.android.db.DatabaseSchema.AccountEntry; +import static org.gnucash.android.db.DatabaseSchema.BudgetAmountEntry; +import static org.gnucash.android.db.DatabaseSchema.BudgetEntry; import static org.gnucash.android.db.DatabaseSchema.CommodityEntry; import static org.gnucash.android.db.DatabaseSchema.CommonColumns; import static org.gnucash.android.db.DatabaseSchema.PriceEntry; +import static org.gnucash.android.db.DatabaseSchema.RecurrenceEntry; import static org.gnucash.android.db.DatabaseSchema.ScheduledActionEntry; import static org.gnucash.android.db.DatabaseSchema.SplitEntry; import static org.gnucash.android.db.DatabaseSchema.TransactionEntry; @@ -85,7 +94,7 @@ public class MigrationHelper { /** * Performs same function as {@link AccountsDbAdapter#getFullyQualifiedAccountName(String)} - *

This method is only necessary because we cannot open the database again (by instantiating {@link org.gnucash.android.db.AccountsDbAdapter} + *

This method is only necessary because we cannot open the database again (by instantiating {@link AccountsDbAdapter} * while it is locked for upgrades. So we re-implement the method here.

* @param db SQLite database * @param accountUID Unique ID of account whose fully qualified name is to be determined @@ -186,7 +195,7 @@ public void run() { for (File src : oldExportFolder.listFiles()) { if (src.isDirectory()) continue; - File dst = new File(Exporter.EXPORT_FOLDER_PATH + "/" + src.getName()); + File dst = new File(Exporter.BASE_FOLDER_PATH + "/exports/" + src.getName()); try { MigrationHelper.moveFile(src, dst); } catch (IOException e) { @@ -202,7 +211,7 @@ public void run() { File oldBackupFolder = new File(oldExportFolder, "backup"); if (oldBackupFolder.exists()){ for (File src : new File(oldExportFolder, "backup").listFiles()) { - File dst = new File(Exporter.BACKUP_FOLDER_PATH + "/" + src.getName()); + File dst = new File(Exporter.BASE_FOLDER_PATH + "/backups/" + src.getName()); try { MigrationHelper.moveFile(src, dst); } catch (IOException e) { @@ -217,7 +226,6 @@ public void run() { } }; - /** * Imports commodities into the database from XML resource file */ @@ -483,8 +491,8 @@ static int upgradeDbToVersion7(SQLiteDatabase db) { static int upgradeDbToVersion8(SQLiteDatabase db) { Log.i(DatabaseHelper.LOG_TAG, "Upgrading database to version 8"); int oldVersion = 7; - new File(Exporter.BACKUP_FOLDER_PATH).mkdirs(); - new File(Exporter.EXPORT_FOLDER_PATH).mkdirs(); + new File(Exporter.BASE_FOLDER_PATH + "/backups/").mkdirs(); + new File(Exporter.BASE_FOLDER_PATH + "/exports/").mkdirs(); //start moving the files in background thread before we do the database stuff new Thread(moveExportedFilesToNewDefaultLocation).start(); @@ -497,7 +505,7 @@ static int upgradeDbToVersion8(SQLiteDatabase db) { + ScheduledActionEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + ScheduledActionEntry.COLUMN_ACTION_UID + " varchar(255) not null, " + ScheduledActionEntry.COLUMN_TYPE + " varchar(255) not null, " - + ScheduledActionEntry.COLUMN_PERIOD + " integer not null, " + + "period " + " integer not null, " + ScheduledActionEntry.COLUMN_LAST_RUN + " integer default 0, " + ScheduledActionEntry.COLUMN_START_TIME + " integer not null, " + ScheduledActionEntry.COLUMN_END_TIME + " integer default 0, " @@ -713,7 +721,7 @@ static int upgradeDbToVersion8(SQLiteDatabase db) { contentValues.put(CommonColumns.COLUMN_UID, BaseModel.generateUID()); contentValues.put(CommonColumns.COLUMN_CREATED_AT, timestamp); contentValues.put(ScheduledActionEntry.COLUMN_ACTION_UID, cursor.getString(cursor.getColumnIndexOrThrow(TransactionEntry.COLUMN_UID))); - contentValues.put(ScheduledActionEntry.COLUMN_PERIOD, cursor.getLong(cursor.getColumnIndexOrThrow("recurrence_period"))); + contentValues.put("period", cursor.getLong(cursor.getColumnIndexOrThrow("recurrence_period"))); contentValues.put(ScheduledActionEntry.COLUMN_START_TIME, timestampT.getTime()); contentValues.put(ScheduledActionEntry.COLUMN_END_TIME, 0); contentValues.put(ScheduledActionEntry.COLUMN_LAST_RUN, lastRun); @@ -852,7 +860,7 @@ static int upgradeDbToVersion9(SQLiteDatabase db){ db.beginTransaction(); try { - String createCommoditiesSql = "CREATE TABLE " + CommodityEntry.TABLE_NAME + " (" + db.execSQL("CREATE TABLE " + CommodityEntry.TABLE_NAME + " (" + CommodityEntry._ID + " integer primary key autoincrement, " + CommodityEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + CommodityEntry.COLUMN_NAMESPACE + " varchar(255) not null default " + Commodity.Namespace.ISO4217.name() + ", " @@ -864,8 +872,10 @@ static int upgradeDbToVersion9(SQLiteDatabase db){ + CommodityEntry.COLUMN_QUOTE_FLAG + " integer not null, " + CommodityEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + CommodityEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP " - + ");" + DatabaseHelper.createUpdatedAtTrigger(CommodityEntry.TABLE_NAME); - db.execSQL(createCommoditiesSql); + + ");" + DatabaseHelper.createUpdatedAtTrigger(CommodityEntry.TABLE_NAME)); + db.execSQL("CREATE UNIQUE INDEX '" + CommodityEntry.INDEX_UID + + "' ON " + CommodityEntry.TABLE_NAME + "(" + CommodityEntry.COLUMN_UID + ")"); + try { importCommodities(db); } catch (SAXException | ParserConfigurationException | IOException e) { @@ -894,7 +904,7 @@ static int upgradeDbToVersion9(SQLiteDatabase db){ + " WHERE " + TransactionEntry.TABLE_NAME + "." + TransactionEntry.COLUMN_COMMODITY_UID + " = " + CommodityEntry.TABLE_NAME + "." + CommodityEntry.COLUMN_UID + ")"); - String createPricesSql = "CREATE TABLE " + PriceEntry.TABLE_NAME + " (" + db.execSQL("CREATE TABLE " + PriceEntry.TABLE_NAME + " (" + PriceEntry._ID + " integer primary key autoincrement, " + PriceEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + PriceEntry.COLUMN_COMMODITY_UID + " varchar(255) not null, " @@ -909,12 +919,13 @@ static int upgradeDbToVersion9(SQLiteDatabase db){ + "UNIQUE (" + PriceEntry.COLUMN_COMMODITY_UID + ", " + PriceEntry.COLUMN_CURRENCY_UID + ") ON CONFLICT REPLACE, " + "FOREIGN KEY (" + PriceEntry.COLUMN_COMMODITY_UID + ") REFERENCES " + CommodityEntry.TABLE_NAME + " (" + CommodityEntry.COLUMN_UID + ") ON DELETE CASCADE, " + "FOREIGN KEY (" + PriceEntry.COLUMN_CURRENCY_UID + ") REFERENCES " + CommodityEntry.TABLE_NAME + " (" + CommodityEntry.COLUMN_UID + ") ON DELETE CASCADE " - + ");" + DatabaseHelper.createUpdatedAtTrigger(PriceEntry.TABLE_NAME); - db.execSQL(createPricesSql); + + ");" + DatabaseHelper.createUpdatedAtTrigger(PriceEntry.TABLE_NAME)); + db.execSQL("CREATE UNIQUE INDEX '" + PriceEntry.INDEX_UID + + "' ON " + PriceEntry.TABLE_NAME + "(" + PriceEntry.COLUMN_UID + ")"); //store split amounts as integer components numerator and denominator - + db.execSQL("ALTER TABLE " + SplitEntry.TABLE_NAME + " RENAME TO " + SplitEntry.TABLE_NAME + "_bak"); // create new split table db.execSQL("CREATE TABLE " + SplitEntry.TABLE_NAME + " (" @@ -1206,4 +1217,261 @@ static int upgradeDbToVersion12(SQLiteDatabase db){ return oldVersion; } + + /** + * Upgrades the database to version 13. + *

This migration makes the following changes to the database: + *

    + *
  • Adds support for multiple database for different books and one extra database for storing book info
  • + *
  • Adds a table for budgets
  • + *
  • Adds an extra table for recurrences
  • + *
  • Migrate scheduled transaction recurrences to own table
  • + *
  • Adds flags for reconciled status to split table
  • + *
  • Add flags for auto-/advance- create and notification to scheduled actions
  • + *
  • Migrate old shared preferences into new book-specific preferences
  • + *
+ *

+ * @param db SQlite database to be upgraded + * @return New database version, 13 if migration succeeds, 11 otherwise + */ + static int upgradeDbToVersion13(SQLiteDatabase db){ + Log.i(DatabaseHelper.LOG_TAG, "Upgrading database to version 13"); + int oldVersion = 12; + + db.beginTransaction(); + try { + db.execSQL("CREATE TABLE " + RecurrenceEntry.TABLE_NAME + " (" + + RecurrenceEntry._ID + " integer primary key autoincrement, " + + RecurrenceEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + + RecurrenceEntry.COLUMN_MULTIPLIER + " integer not null default 1, " + + RecurrenceEntry.COLUMN_PERIOD_TYPE + " varchar(255) not null, " + + RecurrenceEntry.COLUMN_BYDAY + " varchar(255), " + + RecurrenceEntry.COLUMN_PERIOD_START + " timestamp not null, " + + RecurrenceEntry.COLUMN_PERIOD_END + " timestamp, " + + RecurrenceEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + RecurrenceEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP); " + + DatabaseHelper.createUpdatedAtTrigger(RecurrenceEntry.TABLE_NAME)); + + db.execSQL("CREATE TABLE " + BudgetEntry.TABLE_NAME + " (" + + BudgetEntry._ID + " integer primary key autoincrement, " + + BudgetEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + + BudgetEntry.COLUMN_NAME + " varchar(255) not null, " + + BudgetEntry.COLUMN_DESCRIPTION + " varchar(255), " + + BudgetEntry.COLUMN_RECURRENCE_UID + " varchar(255) not null, " + + BudgetEntry.COLUMN_NUM_PERIODS + " integer, " + + BudgetEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + BudgetEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "FOREIGN KEY (" + BudgetEntry.COLUMN_RECURRENCE_UID + ") REFERENCES " + RecurrenceEntry.TABLE_NAME + " (" + RecurrenceEntry.COLUMN_UID + ") " + + ");" + DatabaseHelper.createUpdatedAtTrigger(BudgetEntry.TABLE_NAME)); + + db.execSQL("CREATE UNIQUE INDEX '" + BudgetEntry.INDEX_UID + + "' ON " + BudgetEntry.TABLE_NAME + "(" + BudgetEntry.COLUMN_UID + ")"); + + db.execSQL("CREATE TABLE " + BudgetAmountEntry.TABLE_NAME + " (" + + BudgetAmountEntry._ID + " integer primary key autoincrement, " + + BudgetAmountEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + + BudgetAmountEntry.COLUMN_BUDGET_UID + " varchar(255) not null, " + + BudgetAmountEntry.COLUMN_ACCOUNT_UID + " varchar(255) not null, " + + BudgetAmountEntry.COLUMN_AMOUNT_NUM + " integer not null, " + + BudgetAmountEntry.COLUMN_AMOUNT_DENOM + " integer not null, " + + BudgetAmountEntry.COLUMN_PERIOD_NUM + " integer not null, " + + BudgetAmountEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + BudgetAmountEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "FOREIGN KEY (" + BudgetAmountEntry.COLUMN_ACCOUNT_UID + ") REFERENCES " + AccountEntry.TABLE_NAME + " (" + AccountEntry.COLUMN_UID + ") ON DELETE CASCADE, " + + "FOREIGN KEY (" + BudgetAmountEntry.COLUMN_BUDGET_UID + ") REFERENCES " + BudgetEntry.TABLE_NAME + " (" + BudgetEntry.COLUMN_UID + ") ON DELETE CASCADE " + + ");" + DatabaseHelper.createUpdatedAtTrigger(BudgetAmountEntry.TABLE_NAME)); + + db.execSQL("CREATE UNIQUE INDEX '" + BudgetAmountEntry.INDEX_UID + + "' ON " + BudgetAmountEntry.TABLE_NAME + "(" + BudgetAmountEntry.COLUMN_UID + ")"); + + + //extract recurrences from scheduled actions table and put in the recurrence table + db.execSQL("ALTER TABLE " + ScheduledActionEntry.TABLE_NAME + " RENAME TO " + ScheduledActionEntry.TABLE_NAME + "_bak"); + + db.execSQL("CREATE TABLE " + ScheduledActionEntry.TABLE_NAME + " (" + + ScheduledActionEntry._ID + " integer primary key autoincrement, " + + ScheduledActionEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + + ScheduledActionEntry.COLUMN_ACTION_UID + " varchar(255) not null, " + + ScheduledActionEntry.COLUMN_TYPE + " varchar(255) not null, " + + ScheduledActionEntry.COLUMN_RECURRENCE_UID + " varchar(255) not null, " + + ScheduledActionEntry.COLUMN_TEMPLATE_ACCT_UID + " varchar(255) not null, " + + ScheduledActionEntry.COLUMN_LAST_RUN + " integer default 0, " + + ScheduledActionEntry.COLUMN_START_TIME + " integer not null, " + + ScheduledActionEntry.COLUMN_END_TIME + " integer default 0, " + + ScheduledActionEntry.COLUMN_TAG + " text, " + + ScheduledActionEntry.COLUMN_ENABLED + " tinyint default 1, " //enabled by default + + ScheduledActionEntry.COLUMN_AUTO_CREATE + " tinyint default 1, " + + ScheduledActionEntry.COLUMN_AUTO_NOTIFY + " tinyint default 0, " + + ScheduledActionEntry.COLUMN_ADVANCE_CREATION + " integer default 0, " + + ScheduledActionEntry.COLUMN_ADVANCE_NOTIFY + " integer default 0, " + + ScheduledActionEntry.COLUMN_TOTAL_FREQUENCY + " integer default 0, " + + ScheduledActionEntry.COLUMN_EXECUTION_COUNT + " integer default 0, " + + ScheduledActionEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + ScheduledActionEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "FOREIGN KEY (" + ScheduledActionEntry.COLUMN_RECURRENCE_UID + ") REFERENCES " + RecurrenceEntry.TABLE_NAME + " (" + RecurrenceEntry.COLUMN_UID + ") " + + ");" + DatabaseHelper.createUpdatedAtTrigger(ScheduledActionEntry.TABLE_NAME)); + + + // initialize new transaction table with data from old table + db.execSQL("INSERT INTO " + ScheduledActionEntry.TABLE_NAME + " ( " + + ScheduledActionEntry._ID + " , " + + ScheduledActionEntry.COLUMN_UID + " , " + + ScheduledActionEntry.COLUMN_ACTION_UID + " , " + + ScheduledActionEntry.COLUMN_TYPE + " , " + + ScheduledActionEntry.COLUMN_LAST_RUN + " , " + + ScheduledActionEntry.COLUMN_START_TIME + " , " + + ScheduledActionEntry.COLUMN_END_TIME + " , " + + ScheduledActionEntry.COLUMN_ENABLED + " , " + + ScheduledActionEntry.COLUMN_TOTAL_FREQUENCY + " , " + + ScheduledActionEntry.COLUMN_EXECUTION_COUNT + " , " + + ScheduledActionEntry.COLUMN_CREATED_AT + " , " + + ScheduledActionEntry.COLUMN_MODIFIED_AT + " , " + + ScheduledActionEntry.COLUMN_RECURRENCE_UID + " , " + + ScheduledActionEntry.COLUMN_TEMPLATE_ACCT_UID + " , " + + ScheduledActionEntry.COLUMN_TAG + + ") SELECT " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry._ID + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_UID + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_ACTION_UID + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_TYPE + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_LAST_RUN + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_START_TIME + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_END_TIME + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_ENABLED + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_TOTAL_FREQUENCY + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_EXECUTION_COUNT + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_CREATED_AT + " , " + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_MODIFIED_AT + " , " + + " 'dummy-string' ," //will be updated in next steps + + " 'dummy-string' ," + + ScheduledActionEntry.TABLE_NAME + "_bak." + ScheduledActionEntry.COLUMN_TAG + + " FROM " + ScheduledActionEntry.TABLE_NAME + "_bak;"); + + //update the template-account-guid and the recurrence guid for all scheduled actions + Cursor cursor = db.query(ScheduledActionEntry.TABLE_NAME + "_bak", + new String[]{ScheduledActionEntry.COLUMN_UID, + "period", + ScheduledActionEntry.COLUMN_START_TIME + }, + null, null, null, null, null); + + ContentValues contentValues = new ContentValues(); + while (cursor.moveToNext()){ + String uid = cursor.getString(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_UID)); + long period = cursor.getLong(cursor.getColumnIndexOrThrow("period")); + long startTime = cursor.getLong(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_START_TIME)); + PeriodType periodType = PeriodType.parse(period); + Recurrence recurrence = new Recurrence(periodType); + recurrence.setPeriodStart(new Timestamp(startTime)); + + contentValues.clear(); + contentValues.put(RecurrenceEntry.COLUMN_UID, recurrence.getUID()); + contentValues.put(RecurrenceEntry.COLUMN_MULTIPLIER, recurrence.getPeriodType().getMultiplier()); + contentValues.put(RecurrenceEntry.COLUMN_PERIOD_TYPE, recurrence.getPeriodType().name()); + contentValues.put(RecurrenceEntry.COLUMN_PERIOD_START, recurrence.getPeriodStart().toString()); + db.insert(RecurrenceEntry.TABLE_NAME, null, contentValues); + + contentValues.clear(); + contentValues.put(ScheduledActionEntry.COLUMN_RECURRENCE_UID, recurrence.getUID()); + contentValues.put(ScheduledActionEntry.COLUMN_TEMPLATE_ACCT_UID, BaseModel.generateUID()); + db.update(ScheduledActionEntry.TABLE_NAME, contentValues, + ScheduledActionEntry.COLUMN_UID + " = ?", new String[]{uid}); + } + cursor.close(); + + db.execSQL("DROP TABLE " + ScheduledActionEntry.TABLE_NAME + "_bak"); + + + //============== Add RECONCILE_STATE and RECONCILE_DATE to the splits table ========== + //We migrate the whole table because we want those columns to have default values + + db.execSQL("ALTER TABLE " + SplitEntry.TABLE_NAME + " RENAME TO " + SplitEntry.TABLE_NAME + "_bak"); + db.execSQL("CREATE TABLE " + SplitEntry.TABLE_NAME + " (" + + SplitEntry._ID + " integer primary key autoincrement, " + + SplitEntry.COLUMN_UID + " varchar(255) not null UNIQUE, " + + SplitEntry.COLUMN_MEMO + " text, " + + SplitEntry.COLUMN_TYPE + " varchar(255) not null, " + + SplitEntry.COLUMN_VALUE_NUM + " integer not null, " + + SplitEntry.COLUMN_VALUE_DENOM + " integer not null, " + + SplitEntry.COLUMN_QUANTITY_NUM + " integer not null, " + + SplitEntry.COLUMN_QUANTITY_DENOM + " integer not null, " + + SplitEntry.COLUMN_ACCOUNT_UID + " varchar(255) not null, " + + SplitEntry.COLUMN_TRANSACTION_UID + " varchar(255) not null, " + + SplitEntry.COLUMN_RECONCILE_STATE + " varchar(1) not null default 'n', " + + SplitEntry.COLUMN_RECONCILE_DATE + " timestamp not null default current_timestamp, " + + SplitEntry.COLUMN_CREATED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + SplitEntry.COLUMN_MODIFIED_AT + " TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "FOREIGN KEY (" + SplitEntry.COLUMN_ACCOUNT_UID + ") REFERENCES " + AccountEntry.TABLE_NAME + " (" + AccountEntry.COLUMN_UID + ") ON DELETE CASCADE, " + + "FOREIGN KEY (" + SplitEntry.COLUMN_TRANSACTION_UID + ") REFERENCES " + TransactionEntry.TABLE_NAME + " (" + TransactionEntry.COLUMN_UID + ") ON DELETE CASCADE " + + ");" + DatabaseHelper.createUpdatedAtTrigger(SplitEntry.TABLE_NAME)); + + db.execSQL("INSERT INTO " + SplitEntry.TABLE_NAME + " ( " + + SplitEntry._ID + " , " + + SplitEntry.COLUMN_UID + " , " + + SplitEntry.COLUMN_MEMO + " , " + + SplitEntry.COLUMN_TYPE + " , " + + SplitEntry.COLUMN_VALUE_NUM + " , " + + SplitEntry.COLUMN_VALUE_DENOM + " , " + + SplitEntry.COLUMN_QUANTITY_NUM + " , " + + SplitEntry.COLUMN_QUANTITY_DENOM + " , " + + SplitEntry.COLUMN_ACCOUNT_UID + " , " + + SplitEntry.COLUMN_TRANSACTION_UID + + ") SELECT " + + SplitEntry.TABLE_NAME + "_bak." + SplitEntry._ID + " , " + + SplitEntry.TABLE_NAME + "_bak." + SplitEntry.COLUMN_UID + " , " + + SplitEntry.TABLE_NAME + "_bak." + SplitEntry.COLUMN_MEMO + " , " + + SplitEntry.TABLE_NAME + "_bak." + SplitEntry.COLUMN_TYPE + " , " + + SplitEntry.TABLE_NAME + "_bak." + SplitEntry.COLUMN_VALUE_NUM + " , " + + SplitEntry.TABLE_NAME + "_bak." + SplitEntry.COLUMN_VALUE_DENOM + " , " + + SplitEntry.TABLE_NAME + "_bak." + SplitEntry.COLUMN_QUANTITY_NUM + " , " + + SplitEntry.TABLE_NAME + "_bak." + SplitEntry.COLUMN_QUANTITY_DENOM + " , " + + SplitEntry.TABLE_NAME + "_bak." + SplitEntry.COLUMN_ACCOUNT_UID + " , " + + SplitEntry.TABLE_NAME + "_bak." + SplitEntry.COLUMN_TRANSACTION_UID + + " FROM " + SplitEntry.TABLE_NAME + "_bak;"); + + + db.execSQL("DROP TABLE " + SplitEntry.TABLE_NAME + "_bak"); + + db.setTransactionSuccessful(); + oldVersion = 13; + } finally { + db.endTransaction(); + } + + //Migrate book-specific preferences away from shared preferences + Log.d(LOG_TAG, "Migrating shared preferences into book preferences"); + Context context = GnuCashApplication.getAppContext(); + String keyUseDoubleEntry = context.getString(R.string.key_use_double_entry); + String keySaveOpeningBalance = context.getString(R.string.key_save_opening_balances); + String keyLastExportTime = PreferencesHelper.PREFERENCE_LAST_EXPORT_TIME_KEY; + String keyUseCompactView = context.getString(R.string.key_use_compact_list); + + SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context); + String lastExportTime = sharedPrefs.getString(keyLastExportTime, TimestampHelper.getTimestampFromEpochZero().toString()); + boolean useDoubleEntry = sharedPrefs.getBoolean(keyUseDoubleEntry, true); + boolean saveOpeningBalance = sharedPrefs.getBoolean(keySaveOpeningBalance, false); + boolean useCompactTrnView = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.key_use_double_entry), !useDoubleEntry); + + String rootAccountUID = getGnuCashRootAccountUID(db); + SharedPreferences bookPrefs = context.getSharedPreferences(rootAccountUID, Context.MODE_PRIVATE); + + bookPrefs.edit() + .putString(keyLastExportTime, lastExportTime) + .putBoolean(keyUseDoubleEntry, useDoubleEntry) + .putBoolean(keySaveOpeningBalance, saveOpeningBalance) + .putBoolean(keyUseCompactView, useCompactTrnView) + .apply(); + + //cancel the existing pending intent so that the alarm can be rescheduled + Intent alarmIntent = new Intent(context, ScheduledActionService.class); + PendingIntent pendingIntent = PendingIntent.getService(context, 0, alarmIntent, PendingIntent.FLAG_NO_CREATE); + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + alarmManager.cancel(pendingIntent); + pendingIntent.cancel(); + + GnuCashApplication.startScheduledActionExecutionService(GnuCashApplication.getAppContext()); + + return oldVersion; + } } diff --git a/app/src/main/java/org/gnucash/android/db/AccountsDbAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/AccountsDbAdapter.java similarity index 91% rename from app/src/main/java/org/gnucash/android/db/AccountsDbAdapter.java rename to app/src/main/java/org/gnucash/android/db/adapter/AccountsDbAdapter.java index f364e37fe..d37d85b86 100644 --- a/app/src/main/java/org/gnucash/android/db/AccountsDbAdapter.java +++ b/app/src/main/java/org/gnucash/android/db/adapter/AccountsDbAdapter.java @@ -15,7 +15,7 @@ * limitations under the License. */ -package org.gnucash.android.db; +package org.gnucash.android.db.adapter; import android.content.ContentValues; import android.content.Context; @@ -30,6 +30,7 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseSchema; import org.gnucash.android.model.Account; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.Commodity; @@ -76,14 +77,61 @@ public class AccountsDbAdapter extends DatabaseAdapter { */ private final TransactionsDbAdapter mTransactionsAdapter; + /** + * Commodities database adapter for commodity manipulation + */ + private final CommoditiesDbAdapter mCommoditiesDbAdapter; + /** * Overloaded constructor. Creates an adapter for an already open database * @param db SQliteDatabase instance */ public AccountsDbAdapter(SQLiteDatabase db, TransactionsDbAdapter transactionsDbAdapter) { - super(db, AccountEntry.TABLE_NAME); + super(db, AccountEntry.TABLE_NAME, new String[]{ + AccountEntry.COLUMN_NAME , + AccountEntry.COLUMN_DESCRIPTION , + AccountEntry.COLUMN_TYPE , + AccountEntry.COLUMN_CURRENCY , + AccountEntry.COLUMN_COLOR_CODE , + AccountEntry.COLUMN_FAVORITE , + AccountEntry.COLUMN_FULL_NAME , + AccountEntry.COLUMN_PLACEHOLDER , + AccountEntry.COLUMN_CREATED_AT , + AccountEntry.COLUMN_HIDDEN , + AccountEntry.COLUMN_COMMODITY_UID, + AccountEntry.COLUMN_PARENT_ACCOUNT_UID, + AccountEntry.COLUMN_DEFAULT_TRANSFER_ACCOUNT_UID + }); mTransactionsAdapter = transactionsDbAdapter; - LOG_TAG = "AccountsDbAdapter"; + mCommoditiesDbAdapter = new CommoditiesDbAdapter(db); + } + + /** + * Convenience overloaded constructor. + * This is used when an AccountsDbAdapter object is needed quickly. Otherwise, the other + * constructor {@link #AccountsDbAdapter(SQLiteDatabase, TransactionsDbAdapter)} + * should be used whenever possible + * @param db Database to create an adapter for + */ + public AccountsDbAdapter(SQLiteDatabase db){ + super(db, AccountEntry.TABLE_NAME, new String[]{ + AccountEntry.COLUMN_NAME , + AccountEntry.COLUMN_DESCRIPTION , + AccountEntry.COLUMN_TYPE , + AccountEntry.COLUMN_CURRENCY , + AccountEntry.COLUMN_COLOR_CODE , + AccountEntry.COLUMN_FAVORITE , + AccountEntry.COLUMN_FULL_NAME , + AccountEntry.COLUMN_PLACEHOLDER , + AccountEntry.COLUMN_CREATED_AT , + AccountEntry.COLUMN_HIDDEN , + AccountEntry.COLUMN_COMMODITY_UID, + AccountEntry.COLUMN_PARENT_ACCOUNT_UID, + AccountEntry.COLUMN_DEFAULT_TRANSFER_ACCOUNT_UID + }); + + mTransactionsAdapter = new TransactionsDbAdapter(db, new SplitsDbAdapter(db)); + mCommoditiesDbAdapter = new CommoditiesDbAdapter(db); } /** @@ -100,11 +148,11 @@ public static AccountsDbAdapter getInstance(){ * @param account {@link Account} to be inserted to database */ @Override - public void addRecord(@NonNull Account account){ + public void addRecord(@NonNull Account account, UpdateMethod updateMethod){ Log.d(LOG_TAG, "Replace account to db"); //in-case the account already existed, we want to update the templates based on it as well List templateTransactions = mTransactionsAdapter.getScheduledTransactionsForAccount(account.getUID()); - super.addRecord(account); + super.addRecord(account, updateMethod); String accountUID = account.getUID(); //now add transactions if there are any if (account.getAccountType() != AccountType.ROOT){ @@ -112,10 +160,10 @@ public void addRecord(@NonNull Account account){ updateRecord(accountUID, AccountEntry.COLUMN_FULL_NAME, getFullyQualifiedAccountName(accountUID)); for (Transaction t : account.getTransactions()) { t.setCommodity(account.getCommodity()); - mTransactionsAdapter.addRecord(t); + mTransactionsAdapter.addRecord(t, updateMethod); } for (Transaction transaction : templateTransactions) { - mTransactionsAdapter.addRecord(transaction); + mTransactionsAdapter.addRecord(transaction, UpdateMethod.update); } } } @@ -130,79 +178,57 @@ public void addRecord(@NonNull Account account){ * @return number of rows inserted */ @Override - public long bulkAddRecords(@NonNull List accountList){ + public long bulkAddRecords(@NonNull List accountList, UpdateMethod updateMethod){ //scheduled transactions are not fetched from the database when getting account transactions //so we retrieve those which affect this account and then re-save them later //this is necessary because the database has ON DELETE CASCADE between accounts and splits //and all accounts are editing via SQL REPLACE + //// TODO: 20.04.2016 Investigate if we can safely remove updating the transactions when bulk updating accounts List transactionList = new ArrayList<>(accountList.size()*2); for (Account account : accountList) { transactionList.addAll(account.getTransactions()); transactionList.addAll(mTransactionsAdapter.getScheduledTransactionsForAccount(account.getUID())); } - long nRow = super.bulkAddRecords(accountList); + long nRow = super.bulkAddRecords(accountList, updateMethod); if (nRow > 0 && !transactionList.isEmpty()){ - mTransactionsAdapter.bulkAddRecords(transactionList); + mTransactionsAdapter.bulkAddRecords(transactionList, updateMethod); } return nRow; } @Override - protected SQLiteStatement compileReplaceStatement(@NonNull final Account account) { - if (mReplaceStatement == null){ - mReplaceStatement = mDb.compileStatement("REPLACE INTO " + AccountEntry.TABLE_NAME + " ( " - + AccountEntry.COLUMN_UID + " , " - + AccountEntry.COLUMN_NAME + " , " - + AccountEntry.COLUMN_DESCRIPTION + " , " - + AccountEntry.COLUMN_TYPE + " , " - + AccountEntry.COLUMN_CURRENCY + " , " - + AccountEntry.COLUMN_COLOR_CODE + " , " - + AccountEntry.COLUMN_FAVORITE + " , " - + AccountEntry.COLUMN_FULL_NAME + " , " - + AccountEntry.COLUMN_PLACEHOLDER + " , " - + AccountEntry.COLUMN_CREATED_AT + " , " - + AccountEntry.COLUMN_HIDDEN + " , " - + AccountEntry.COLUMN_COMMODITY_UID + " , " - + AccountEntry.COLUMN_PARENT_ACCOUNT_UID + " , " - + AccountEntry.COLUMN_DEFAULT_TRANSFER_ACCOUNT_UID + " ) VALUES ( ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ?, ?)"); - //commodity_uid is not forgotten. It will be inserted by a database trigger - } - - mReplaceStatement.clearBindings(); - mReplaceStatement.bindString(1, account.getUID()); - mReplaceStatement.bindString(2, account.getName()); - mReplaceStatement.bindString(3, account.getDescription()); - mReplaceStatement.bindString(4, account.getAccountType().name()); - mReplaceStatement.bindString(5, account.getCurrency().getCurrencyCode()); + protected @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull final Account account) { + stmt.clearBindings(); + stmt.bindString(1, account.getName()); + if (account.getDescription() != null) + stmt.bindString(2, account.getDescription()); + stmt.bindString(3, account.getAccountType().name()); + stmt.bindString(4, account.getCommodity().getCurrencyCode()); if (account.getColor() != Account.DEFAULT_COLOR) { - mReplaceStatement.bindString(6, convertToRGBHexString(account.getColor())); + stmt.bindString(5, convertToRGBHexString(account.getColor())); } - mReplaceStatement.bindLong(7, account.isFavorite() ? 1 : 0); - mReplaceStatement.bindString(8, account.getFullName()); - mReplaceStatement.bindLong(9, account.isPlaceholderAccount() ? 1 : 0); - mReplaceStatement.bindString(10, TimestampHelper.getUtcStringFromTimestamp(account.getCreatedTimestamp())); - mReplaceStatement.bindLong(11, account.isHidden() ? 1 : 0); - Commodity commodity = account.getCommodity(); - if (commodity == null) - commodity = CommoditiesDbAdapter.getInstance().getCommodity(account.getCurrency().getCurrencyCode()); - - mReplaceStatement.bindString(12, commodity.getUID()); + stmt.bindLong(6, account.isFavorite() ? 1 : 0); + stmt.bindString(7, account.getFullName()); + stmt.bindLong(8, account.isPlaceholderAccount() ? 1 : 0); + stmt.bindString(9, TimestampHelper.getUtcStringFromTimestamp(account.getCreatedTimestamp())); + stmt.bindLong(10, account.isHidden() ? 1 : 0); + stmt.bindString(11, account.getCommodity().getUID()); String parentAccountUID = account.getParentUID(); if (parentAccountUID == null && account.getAccountType() != AccountType.ROOT) { parentAccountUID = getOrCreateGnuCashRootAccountUID(); } if (parentAccountUID != null) { - mReplaceStatement.bindString(13, parentAccountUID); + stmt.bindString(12, parentAccountUID); } if (account.getDefaultTransferAccountUID() != null) { - mReplaceStatement.bindString(14, account.getDefaultTransferAccountUID()); - } else - mReplaceStatement.bindNull(14); + stmt.bindString(13, account.getDefaultTransferAccountUID()); + } + stmt.bindString(14, account.getUID()); - return mReplaceStatement; + return stmt; } private String convertToRGBHexString(int color) { @@ -281,7 +307,7 @@ public void reassignDescendantAccounts(@NonNull String accountUID, @NonNull Stri for (Account account : descendantAccounts) mapAccounts.put(account.getUID(), account); String parentAccountFullName; - if (newParentAccountUID == null || getAccountType(newParentAccountUID) == AccountType.ROOT) { + if (getAccountType(newParentAccountUID) == AccountType.ROOT) { parentAccountFullName = ""; } else { parentAccountFullName = getAccountFullName(newParentAccountUID); @@ -415,7 +441,7 @@ private Account buildSimpleAccountInstance(Cursor c) { account.setParentUID(c.getString(c.getColumnIndexOrThrow(AccountEntry.COLUMN_PARENT_ACCOUNT_UID))); account.setAccountType(AccountType.valueOf(c.getString(c.getColumnIndexOrThrow(AccountEntry.COLUMN_TYPE)))); Currency currency = Currency.getInstance(c.getString(c.getColumnIndexOrThrow(AccountEntry.COLUMN_CURRENCY))); - account.setCommodity(CommoditiesDbAdapter.getInstance().getCommodity(currency.getCurrencyCode())); + account.setCommodity(mCommoditiesDbAdapter.getCommodity(currency.getCurrencyCode())); account.setPlaceHolderFlag(c.getInt(c.getColumnIndexOrThrow(AccountEntry.COLUMN_PLACEHOLDER)) == 1); account.setDefaultTransferAccountUID(c.getString(c.getColumnIndexOrThrow(AccountEntry.COLUMN_DEFAULT_TRANSFER_ACCOUNT_UID))); String color = c.getString(c.getColumnIndexOrThrow(AccountEntry.COLUMN_COLOR_CODE)); @@ -560,7 +586,7 @@ public List getExportableAccounts(Timestamp lastExportTimeStamp){ */ public String getOrCreateImbalanceAccountUID(Currency currency){ String imbalanceAccountName = getImbalanceAccountName(currency); - Commodity commodity = CommoditiesDbAdapter.getInstance().getCommodity(currency.getCurrencyCode()); + Commodity commodity = mCommoditiesDbAdapter.getCommodity(currency.getCurrencyCode()); String uid = findAccountUidByFullName(imbalanceAccountName); if (uid == null){ Account account = new Account(imbalanceAccountName, commodity); @@ -568,7 +594,7 @@ public String getOrCreateImbalanceAccountUID(Currency currency){ account.setParentUID(getOrCreateGnuCashRootAccountUID()); account.setHidden(!GnuCashApplication.isDoubleEntryEnabled()); account.setColor("#964B00"); - addRecord(account); + addRecord(account, UpdateMethod.insert); uid = account.getUID(); } return uid; @@ -618,7 +644,7 @@ public String createAccountHierarchy(String fullName, AccountType accountType) { parentName += ACCOUNT_NAME_SEPARATOR; } if (accountsList.size() > 0) { - bulkAddRecords(accountsList); + bulkAddRecords(accountsList, UpdateMethod.insert); } // if fullName is not empty, loop will be entered and then uid will never be null //noinspection ConstantConditions @@ -763,12 +789,11 @@ public Money getAccountBalance(AccountType accountType, long startTimestamp, lon String currencyCode = GnuCashApplication.getDefaultCurrencyCode(); Log.d(LOG_TAG, "all account list : " + accountUidList.size()); - SplitsDbAdapter splitsDbAdapter = SplitsDbAdapter.getInstance(); - Money splitSum = (startTimestamp == -1 && endTimestamp == -1) + SplitsDbAdapter splitsDbAdapter = mTransactionsAdapter.getSplitDbAdapter(); + + return (startTimestamp == -1 && endTimestamp == -1) ? splitsDbAdapter.computeSplitBalance(accountUidList, currencyCode, hasDebitNormalBalance) : splitsDbAdapter.computeSplitBalance(accountUidList, currencyCode, hasDebitNormalBalance, startTimestamp, endTimestamp); - - return splitSum; } /** @@ -797,7 +822,7 @@ private Money computeBalance(String accountUID, long startTimestamp, long endTim accountsList.add(0, accountUID); Log.d(LOG_TAG, "all account list : " + accountsList.size()); - SplitsDbAdapter splitsDbAdapter = SplitsDbAdapter.getInstance(); + SplitsDbAdapter splitsDbAdapter = mTransactionsAdapter.getSplitDbAdapter(); return (startTimestamp == -1 && endTimestamp == -1) ? splitsDbAdapter.computeSplitBalance(accountsList, currencyCode, hasDebitNormalBalance) : splitsDbAdapter.computeSplitBalance(accountsList, currencyCode, hasDebitNormalBalance, startTimestamp, endTimestamp); @@ -812,7 +837,7 @@ private Money computeBalance(String accountUID, long startTimestamp, long endTim * @param endTimestamp the end timestamp of the time range * @return Money balance of account list */ - public Money getAccountsBalance(@NonNull List accountUIDList, long startTimestamp, long endTimestamp) { + public Money getAccountsBalance(@NonNull List accountUIDList, long startTimestamp, long endTimestamp) { String currencyCode = GnuCashApplication.getDefaultCurrencyCode(); Money balance = Money.createZeroInstance(currencyCode); @@ -821,7 +846,7 @@ public Money getAccountsBalance(@NonNull List accountUIDList, long start boolean hasDebitNormalBalance = getAccountType(accountUIDList.get(0)).hasDebitNormalBalance(); - SplitsDbAdapter splitsDbAdapter = SplitsDbAdapter.getInstance(); + SplitsDbAdapter splitsDbAdapter = mTransactionsAdapter.getSplitDbAdapter(); Money splitSum = (startTimestamp == -1 && endTimestamp == -1) ? splitsDbAdapter.computeSplitBalance(accountUIDList, currencyCode, hasDebitNormalBalance) : splitsDbAdapter.computeSplitBalance(accountUIDList, currencyCode, hasDebitNormalBalance, startTimestamp, endTimestamp); @@ -950,10 +975,11 @@ public String getOrCreateGnuCashRootAccountUID() { cursor.close(); } // No ROOT exits, create a new one - Account rootAccount = new Account("ROOT Account"); + Account rootAccount = new Account("ROOT Account", new CommoditiesDbAdapter(mDb).getCommodity("USD")); rootAccount.setAccountType(AccountType.ROOT); rootAccount.setFullName(ROOT_ACCOUNT_FULL_NAME); rootAccount.setHidden(true); + rootAccount.setPlaceHolderFlag(true); ContentValues contentValues = new ContentValues(); contentValues.put(AccountEntry.COLUMN_UID, rootAccount.getUID()); contentValues.put(AccountEntry.COLUMN_NAME, rootAccount.getName()); @@ -1124,7 +1150,7 @@ public List getAllOpeningBalanceTransactions(){ transaction.setCurrencyCode(currencyCode); TransactionType transactionType = Transaction.getTypeForBalance(getAccountType(accountUID), balance.isNegative()); - Split split = new Split(balance.absolute(), accountUID); + Split split = new Split(balance.abs(), accountUID); split.setType(transactionType); transaction.addSplit(split); transaction.addSplit(split.createPair(getOrCreateOpeningBalanceAccountUID())); @@ -1223,14 +1249,18 @@ public List getCurrenciesInUse(){ */ @Override public int deleteAllRecords() { - mDb.delete(DatabaseSchema.PriceEntry.TABLE_NAME, null, null); // Relies "ON DELETE CASCADE" takes too much time // It take more than 300s to complete the deletion on my dataset without // clearing the split table first, but only needs a little more that 1s // if the split table is cleared first. + mDb.delete(DatabaseSchema.PriceEntry.TABLE_NAME, null, null); mDb.delete(SplitEntry.TABLE_NAME, null, null); mDb.delete(TransactionEntry.TABLE_NAME, null, null); mDb.delete(DatabaseSchema.ScheduledActionEntry.TABLE_NAME, null, null); + mDb.delete(DatabaseSchema.BudgetAmountEntry.TABLE_NAME, null, null); + mDb.delete(DatabaseSchema.BudgetEntry.TABLE_NAME, null, null); + mDb.delete(DatabaseSchema.RecurrenceEntry.TABLE_NAME, null, null); + return mDb.delete(AccountEntry.TABLE_NAME, null, null); } diff --git a/app/src/main/java/org/gnucash/android/db/adapter/BooksDbAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/BooksDbAdapter.java new file mode 100644 index 000000000..65e06c950 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/db/adapter/BooksDbAdapter.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.db.adapter; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteStatement; +import android.net.Uri; +import android.support.annotation.NonNull; + +import org.gnucash.android.R; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseSchema.BookEntry; +import org.gnucash.android.model.Book; +import org.gnucash.android.ui.settings.PreferenceActivity; +import org.gnucash.android.util.TimestampHelper; + +/** + * Database adapter for creating/modifying book entries + */ +public class BooksDbAdapter extends DatabaseAdapter { + + /** + * Opens the database adapter with an existing database + * @param db SQLiteDatabase object + */ + public BooksDbAdapter(SQLiteDatabase db) { + super(db, BookEntry.TABLE_NAME, new String[] { + BookEntry.COLUMN_DISPLAY_NAME, + BookEntry.COLUMN_ROOT_GUID, + BookEntry.COLUMN_TEMPLATE_GUID, + BookEntry.COLUMN_SOURCE_URI, + BookEntry.COLUMN_ACTIVE, + BookEntry.COLUMN_UID, + BookEntry.COLUMN_LAST_SYNC + }); + } + + /** + * Return the application instance of the books database adapter + * @return Books database adapter + */ + public static BooksDbAdapter getInstance(){ + return GnuCashApplication.getBooksDbAdapter(); + } + + @Override + public Book buildModelInstance(@NonNull Cursor cursor) { + String rootAccountGUID = cursor.getString(cursor.getColumnIndexOrThrow(BookEntry.COLUMN_ROOT_GUID)); + String rootTemplateGUID = cursor.getString(cursor.getColumnIndexOrThrow(BookEntry.COLUMN_TEMPLATE_GUID)); + String uriString = cursor.getString(cursor.getColumnIndexOrThrow(BookEntry.COLUMN_SOURCE_URI)); + String displayName = cursor.getString(cursor.getColumnIndexOrThrow(BookEntry.COLUMN_DISPLAY_NAME)); + int active = cursor.getInt(cursor.getColumnIndexOrThrow(BookEntry.COLUMN_ACTIVE)); + String lastSync = cursor.getString(cursor.getColumnIndexOrThrow(BookEntry.COLUMN_LAST_SYNC)); + + Book book = new Book(rootAccountGUID); + book.setDisplayName(displayName); + book.setRootTemplateUID(rootTemplateGUID); + book.setSourceUri(uriString == null ? null : Uri.parse(uriString)); + book.setActive(active > 0); + book.setLastSync(TimestampHelper.getTimestampFromUtcString(lastSync)); + + populateBaseModelAttributes(cursor, book); + return book; + } + + @Override + protected @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull final Book book) { + stmt.clearBindings(); + String displayName = book.getDisplayName() == null ? generateDefaultBookName() : book.getDisplayName(); + stmt.bindString(1, displayName); + stmt.bindString(2, book.getRootAccountUID()); + stmt.bindString(3, book.getRootTemplateUID()); + if (book.getSourceUri() != null) + stmt.bindString(4, book.getSourceUri().toString()); + stmt.bindLong(5, book.isActive() ? 1L : 0L); + stmt.bindString(6, book.getUID()); + stmt.bindString(7, TimestampHelper.getUtcStringFromTimestamp(book.getLastSync())); + return stmt; + } + + + /** + * Deletes a book - removes the book record from the database and deletes the database file from the disk + * @param bookUID GUID of the book + * @return true if deletion was successful, false otherwise + * @see #deleteRecord(String) + */ + public boolean deleteBook(@NonNull String bookUID){ + Context context = GnuCashApplication.getAppContext(); + boolean result = context.deleteDatabase(bookUID); + if (result) //delete the db entry only if the file deletion was successful + result &= deleteRecord(bookUID); + + PreferenceActivity.getBookSharedPreferences(bookUID).edit().clear().apply(); + + return result; + } + + /** + * Sets the book with unique identifier {@code uid} as active and all others as inactive + *

If the parameter is null, then the currently active book is not changed

+ * @param bookUID Unique identifier of the book + * @return GUID of the currently active book + */ + public String setActive(@NonNull String bookUID){ + if (bookUID == null) + return getActiveBookUID(); + + ContentValues contentValues = new ContentValues(); + contentValues.put(BookEntry.COLUMN_ACTIVE, 0); + mDb.update(mTableName, contentValues, null, null); //disable all + + contentValues.clear(); + contentValues.put(BookEntry.COLUMN_ACTIVE, 1); + mDb.update(mTableName, contentValues, BookEntry.COLUMN_UID + " = ?", new String[]{bookUID}); + + return bookUID; + } + + /** + * Checks if the book is active or not + * @param bookUID GUID of the book + * @return {@code true} if the book is active, {@code false} otherwise + */ + public boolean isActive(String bookUID){ + String isActive = getAttribute(bookUID, BookEntry.COLUMN_ACTIVE); + return Integer.parseInt(isActive) > 0; + } + + /** + * Returns the GUID of the current active book + * @return GUID of the active book + */ + public @NonNull String getActiveBookUID(){ + Cursor cursor = mDb.query(mTableName, new String[]{BookEntry.COLUMN_UID}, + BookEntry.COLUMN_ACTIVE + "= 1", null, null, null, null, "1"); + try{ + if (cursor.getCount() == 0) + throw new RuntimeException("There is no active book in the app. This should NEVER happen, fix your bugs!"); + cursor.moveToFirst(); + return cursor.getString(cursor.getColumnIndexOrThrow(BookEntry.COLUMN_UID)); + } finally { + cursor.close(); + } + } + + + /** + * Return the name of the currently active book. + * Or a generic name if there is no active book (should never happen) + * @return Display name of the book + */ + public @NonNull String getActiveBookDisplayName(){ + Cursor cursor = mDb.query(mTableName, + new String[]{BookEntry.COLUMN_DISPLAY_NAME}, BookEntry.COLUMN_ACTIVE + " = 1", + null, null, null, null); + try { + if (cursor.moveToFirst()){ + return cursor.getString(cursor.getColumnIndexOrThrow(BookEntry.COLUMN_DISPLAY_NAME)); + } + } finally { + cursor.close(); + } + return "Book1"; + } + + /** + * Generates a new default name for a new book + * @return String with default name + */ + public @NonNull String generateDefaultBookName() { + long bookCount = getRecordsCount() + 1; + + String sql = "SELECT COUNT(*) FROM " + mTableName + " WHERE " + BookEntry.COLUMN_DISPLAY_NAME + " = ?"; + SQLiteStatement statement = mDb.compileStatement(sql); + + while (true) { + Context context = GnuCashApplication.getAppContext(); + String name = context.getString(R.string.book_default_name, bookCount); + //String name = "Book" + " " + bookCount; + + statement.clearBindings(); + statement.bindString(1, name); + long nameCount = statement.simpleQueryForLong(); + + if (nameCount == 0) { + return name; + } + + bookCount++; + } + + } + + + +} diff --git a/app/src/main/java/org/gnucash/android/db/adapter/BudgetAmountsDbAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/BudgetAmountsDbAdapter.java new file mode 100644 index 000000000..069168d5b --- /dev/null +++ b/app/src/main/java/org/gnucash/android/db/adapter/BudgetAmountsDbAdapter.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.gnucash.android.db.adapter; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteStatement; +import android.support.annotation.NonNull; + +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.model.BudgetAmount; +import org.gnucash.android.model.Money; + +import java.util.ArrayList; +import java.util.List; + +import static org.gnucash.android.db.DatabaseSchema.BudgetAmountEntry; + +/** + * Database adapter for {@link BudgetAmount}s + */ +public class BudgetAmountsDbAdapter extends DatabaseAdapter { + + + /** + * Opens the database adapter with an existing database + * + * @param db SQLiteDatabase object + */ + public BudgetAmountsDbAdapter(SQLiteDatabase db) { + super(db, BudgetAmountEntry.TABLE_NAME, new String[] { + BudgetAmountEntry.COLUMN_BUDGET_UID , + BudgetAmountEntry.COLUMN_ACCOUNT_UID , + BudgetAmountEntry.COLUMN_AMOUNT_NUM , + BudgetAmountEntry.COLUMN_AMOUNT_DENOM , + BudgetAmountEntry.COLUMN_PERIOD_NUM + }); + } + + public static BudgetAmountsDbAdapter getInstance(){ + return GnuCashApplication.getBudgetAmountsDbAdapter(); + } + + @Override + public BudgetAmount buildModelInstance(@NonNull Cursor cursor) { + String budgetUID = cursor.getString(cursor.getColumnIndexOrThrow(BudgetAmountEntry.COLUMN_BUDGET_UID)); + String accountUID = cursor.getString(cursor.getColumnIndexOrThrow(BudgetAmountEntry.COLUMN_ACCOUNT_UID)); + long amountNum = cursor.getLong(cursor.getColumnIndexOrThrow(BudgetAmountEntry.COLUMN_AMOUNT_NUM)); + long amountDenom = cursor.getLong(cursor.getColumnIndexOrThrow(BudgetAmountEntry.COLUMN_AMOUNT_DENOM)); + long periodNum = cursor.getLong(cursor.getColumnIndexOrThrow(BudgetAmountEntry.COLUMN_PERIOD_NUM)); + + BudgetAmount budgetAmount = new BudgetAmount(budgetUID, accountUID); + budgetAmount.setAmount(new Money(amountNum, amountDenom, getAccountCurrencyCode(accountUID))); + budgetAmount.setPeriodNum(periodNum); + populateBaseModelAttributes(cursor, budgetAmount); + + return budgetAmount; + } + + @Override + protected @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull final BudgetAmount budgetAmount) { + stmt.clearBindings(); + stmt.bindString(1, budgetAmount.getBudgetUID()); + stmt.bindString(2, budgetAmount.getAccountUID()); + stmt.bindLong(3, budgetAmount.getAmount().getNumerator()); + stmt.bindLong(4, budgetAmount.getAmount().getDenominator()); + stmt.bindLong(5, budgetAmount.getPeriodNum()); + stmt.bindString(6, budgetAmount.getUID()); + + return stmt; + } + + /** + * Return budget amounts for the specific budget + * @param budgetUID GUID of the budget + * @return List of budget amounts + */ + public List getBudgetAmountsForBudget(String budgetUID){ + Cursor cursor = fetchAllRecords(BudgetAmountEntry.COLUMN_BUDGET_UID + "=?", + new String[]{budgetUID}, null); + + List budgetAmounts = new ArrayList<>(); + while (cursor.moveToNext()){ + budgetAmounts.add(buildModelInstance(cursor)); + } + cursor.close(); + return budgetAmounts; + } + + /** + * Delete all the budget amounts for a budget + * @param budgetUID GUID of the budget + * @return Number of records deleted + */ + public int deleteBudgetAmountsForBudget(String budgetUID){ + return mDb.delete(mTableName, BudgetAmountEntry.COLUMN_BUDGET_UID + "=?", + new String[]{budgetUID}); + } + + /** + * Returns the budgets associated with a specific account + * @param accountUID GUID of the account + * @return List of {@link BudgetAmount}s for the account + */ + public List getBudgetAmounts(String accountUID) { + Cursor cursor = fetchAllRecords(BudgetAmountEntry.COLUMN_ACCOUNT_UID + " = ?", new String[]{accountUID}, null); + List budgetAmounts = new ArrayList<>(); + while(cursor.moveToNext()){ + budgetAmounts.add(buildModelInstance(cursor)); + } + cursor.close(); + return budgetAmounts; + } + + /** + * Returns the sum of the budget amounts for a particular account + * @param accountUID GUID of the account + * @return Sum of the budget amounts + */ + public Money getBudgetAmountSum(String accountUID){ + List budgetAmounts = getBudgetAmounts(accountUID); + Money sum = Money.createZeroInstance(getAccountCurrencyCode(accountUID)); + for (BudgetAmount budgetAmount : budgetAmounts) { + sum = sum.add(budgetAmount.getAmount()); + } + return sum; + } +} diff --git a/app/src/main/java/org/gnucash/android/db/adapter/BudgetsDbAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/BudgetsDbAdapter.java new file mode 100644 index 000000000..c0365d969 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/db/adapter/BudgetsDbAdapter.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.db.adapter; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteQueryBuilder; +import android.database.sqlite.SQLiteStatement; +import android.support.annotation.NonNull; + +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseSchema.BudgetAmountEntry; +import org.gnucash.android.db.DatabaseSchema.BudgetEntry; +import org.gnucash.android.model.Budget; +import org.gnucash.android.model.BudgetAmount; +import org.gnucash.android.model.Money; +import org.gnucash.android.model.Recurrence; + +import java.util.ArrayList; +import java.util.List; + + +/** + * Database adapter for accessing {@link org.gnucash.android.model.Budget} records + */ +public class BudgetsDbAdapter extends DatabaseAdapter{ + + private RecurrenceDbAdapter mRecurrenceDbAdapter; + private BudgetAmountsDbAdapter mBudgetAmountsDbAdapter; + + /** + * Opens the database adapter with an existing database + * + * @param db SQLiteDatabase object + */ + public BudgetsDbAdapter(SQLiteDatabase db, BudgetAmountsDbAdapter budgetAmountsDbAdapter, + RecurrenceDbAdapter recurrenceDbAdapter) { + super(db, BudgetEntry.TABLE_NAME, new String[]{ + BudgetEntry.COLUMN_NAME, + BudgetEntry.COLUMN_DESCRIPTION, + BudgetEntry.COLUMN_RECURRENCE_UID, + BudgetEntry.COLUMN_NUM_PERIODS + }); + mRecurrenceDbAdapter = recurrenceDbAdapter; + mBudgetAmountsDbAdapter = budgetAmountsDbAdapter; + } + + /** + * Returns an instance of the budget database adapter + * @return BudgetsDbAdapter instance + */ + public static BudgetsDbAdapter getInstance(){ + return GnuCashApplication.getBudgetDbAdapter(); + } + + @Override + public void addRecord(@NonNull Budget budget, UpdateMethod updateMethod) { + if (budget.getBudgetAmounts().size() == 0) + throw new IllegalArgumentException("Budgets must have budget amounts"); + + mRecurrenceDbAdapter.addRecord(budget.getRecurrence(), updateMethod); + super.addRecord(budget, updateMethod); + mBudgetAmountsDbAdapter.deleteBudgetAmountsForBudget(budget.getUID()); + for (BudgetAmount budgetAmount : budget.getBudgetAmounts()) { + mBudgetAmountsDbAdapter.addRecord(budgetAmount, updateMethod); + } + } + + @Override + public long bulkAddRecords(@NonNull List budgetList, UpdateMethod updateMethod) { + List budgetAmountList = new ArrayList<>(budgetList.size()*2); + for (Budget budget : budgetList) { + budgetAmountList.addAll(budget.getBudgetAmounts()); + } + + //first add the recurrences, they have no dependencies (foreign key constraints) + List recurrenceList = new ArrayList<>(budgetList.size()); + for (Budget budget : budgetList) { + recurrenceList.add(budget.getRecurrence()); + } + mRecurrenceDbAdapter.bulkAddRecords(recurrenceList, updateMethod); + + //now add the budgets themselves + long nRow = super.bulkAddRecords(budgetList, updateMethod); + + //then add the budget amounts, they require the budgets to exist + if (nRow > 0 && !budgetAmountList.isEmpty()){ + mBudgetAmountsDbAdapter.bulkAddRecords(budgetAmountList, updateMethod); + } + + return nRow; + } + + @Override + public Budget buildModelInstance(@NonNull Cursor cursor) { + String name = cursor.getString(cursor.getColumnIndexOrThrow(BudgetEntry.COLUMN_NAME)); + String description = cursor.getString(cursor.getColumnIndexOrThrow(BudgetEntry.COLUMN_DESCRIPTION)); + String recurrenceUID = cursor.getString(cursor.getColumnIndexOrThrow(BudgetEntry.COLUMN_RECURRENCE_UID)); + long numPeriods = cursor.getLong(cursor.getColumnIndexOrThrow(BudgetEntry.COLUMN_NUM_PERIODS)); + + + Budget budget = new Budget(name); + budget.setDescription(description); + budget.setRecurrence(mRecurrenceDbAdapter.getRecord(recurrenceUID)); + budget.setNumberOfPeriods(numPeriods); + populateBaseModelAttributes(cursor, budget); + budget.setBudgetAmounts(mBudgetAmountsDbAdapter.getBudgetAmountsForBudget(budget.getUID())); + + return budget; + } + + @Override + protected @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull final Budget budget) { + stmt.clearBindings(); + stmt.bindString(1, budget.getName()); + if (budget.getDescription() != null) + stmt.bindString(2, budget.getDescription()); + stmt.bindString(3, budget.getRecurrence().getUID()); + stmt.bindLong(4, budget.getNumberOfPeriods()); + stmt.bindString(5, budget.getUID()); + + return stmt; + } + + /** + * Fetch all budgets which have an amount specified for the account + * @param accountUID GUID of account + * @return Cursor with budgets data + */ + public Cursor fetchBudgetsForAccount(String accountUID){ + SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + queryBuilder.setTables(BudgetEntry.TABLE_NAME + "," + BudgetAmountEntry.TABLE_NAME + + " ON " + BudgetEntry.TABLE_NAME + "." + BudgetEntry.COLUMN_UID + " = " + + BudgetAmountEntry.TABLE_NAME + "." + BudgetAmountEntry.COLUMN_BUDGET_UID); + + queryBuilder.setDistinct(true); + String[] projectionIn = new String[]{BudgetEntry.TABLE_NAME + ".*"}; + String selection = BudgetAmountEntry.TABLE_NAME + "." + BudgetAmountEntry.COLUMN_ACCOUNT_UID + " = ?"; + String[] selectionArgs = new String[]{accountUID}; + String sortOrder = BudgetEntry.TABLE_NAME + "." + BudgetEntry.COLUMN_NAME + " ASC"; + + return queryBuilder.query(mDb, projectionIn, selection, selectionArgs, null, null, sortOrder); + } + + /** + * Returns the budgets associated with a specific account + * @param accountUID GUID of the account + * @return List of budgets for the account + */ + public List getAccountBudgets(String accountUID) { + Cursor cursor = fetchBudgetsForAccount(accountUID); + List budgets = new ArrayList<>(); + while(cursor.moveToNext()){ + budgets.add(buildModelInstance(cursor)); + } + cursor.close(); + return budgets; + } + + /** + * Returns the sum of the account balances for all accounts in a budget for a specified time period + *

This represents the total amount spent within the account of this budget in a given period

+ * @param budgetUID GUID of budget + * @param periodStart Start of the budgeting period in millis + * @param periodEnd End of the budgeting period in millis + * @return Balance of all the accounts + */ + public Money getAccountSum(String budgetUID, long periodStart, long periodEnd){ + List budgetAmounts = mBudgetAmountsDbAdapter.getBudgetAmountsForBudget(budgetUID); + List accountUIDs = new ArrayList<>(); + for (BudgetAmount budgetAmount : budgetAmounts) { + accountUIDs.add(budgetAmount.getAccountUID()); + } + + return new AccountsDbAdapter(mDb).getAccountsBalance(accountUIDs, periodStart, periodEnd); + } +} diff --git a/app/src/main/java/org/gnucash/android/db/CommoditiesDbAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/CommoditiesDbAdapter.java similarity index 72% rename from app/src/main/java/org/gnucash/android/db/CommoditiesDbAdapter.java rename to app/src/main/java/org/gnucash/android/db/adapter/CommoditiesDbAdapter.java index b4e6d7583..84b5f56b4 100644 --- a/app/src/main/java/org/gnucash/android/db/CommoditiesDbAdapter.java +++ b/app/src/main/java/org/gnucash/android/db/adapter/CommoditiesDbAdapter.java @@ -1,11 +1,15 @@ -package org.gnucash.android.db; +package org.gnucash.android.db.adapter; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteStatement; import android.support.annotation.NonNull; +import android.util.Log; + +import com.crashlytics.android.Crashlytics; import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseSchema; import org.gnucash.android.model.Commodity; import static org.gnucash.android.db.DatabaseSchema.CommodityEntry; @@ -20,7 +24,15 @@ public class CommoditiesDbAdapter extends DatabaseAdapter { * @param db SQLiteDatabase object */ public CommoditiesDbAdapter(SQLiteDatabase db) { - super(db, CommodityEntry.TABLE_NAME); + super(db, CommodityEntry.TABLE_NAME, new String[]{ + CommodityEntry.COLUMN_FULLNAME, + CommodityEntry.COLUMN_NAMESPACE, + CommodityEntry.COLUMN_MNEMONIC, + CommodityEntry.COLUMN_LOCAL_SYMBOL, + CommodityEntry.COLUMN_CUSIP, + CommodityEntry.COLUMN_SMALLEST_FRACTION, + CommodityEntry.COLUMN_QUOTE_FLAG + }); /** * initialize commonly used commodities */ @@ -31,6 +43,8 @@ public CommoditiesDbAdapter(SQLiteDatabase db) { Commodity.CAD = getCommodity("CAD"); Commodity.JPY = getCommodity("JPY"); Commodity.AUD = getCommodity("AUD"); + + Commodity.DEFAULT_COMMODITY = getCommodity(GnuCashApplication.getDefaultCurrencyCode()); } public static CommoditiesDbAdapter getInstance(){ @@ -38,30 +52,18 @@ public static CommoditiesDbAdapter getInstance(){ } @Override - protected SQLiteStatement compileReplaceStatement(@NonNull final Commodity commodity) { - if (mReplaceStatement == null) { - mReplaceStatement = mDb.compileStatement("REPLACE INTO " + CommodityEntry.TABLE_NAME + " ( " - + CommodityEntry.COLUMN_UID + " , " - + CommodityEntry.COLUMN_FULLNAME + " , " - + CommodityEntry.COLUMN_NAMESPACE + " , " - + CommodityEntry.COLUMN_MNEMONIC + " , " - + CommodityEntry.COLUMN_LOCAL_SYMBOL + " , " - + CommodityEntry.COLUMN_CUSIP + " , " - + CommodityEntry.COLUMN_SMALLEST_FRACTION + " , " - + CommodityEntry.COLUMN_QUOTE_FLAG + " ) VALUES ( ? , ? , ? , ? , ? , ? , ? , ? ) "); - } - - mReplaceStatement.clearBindings(); - mReplaceStatement.bindString(1, commodity.getUID()); - mReplaceStatement.bindString(2, commodity.getFullname()); - mReplaceStatement.bindString(3, commodity.getNamespace().name()); - mReplaceStatement.bindString(4, commodity.getMnemonic()); - mReplaceStatement.bindString(5, commodity.getLocalSymbol()); - mReplaceStatement.bindString(6, commodity.getCusip()); - mReplaceStatement.bindLong(7, commodity.getSmallestFraction()); - mReplaceStatement.bindLong(8, commodity.getQuoteFlag()); - - return mReplaceStatement; + protected @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull final Commodity commodity) { + stmt.clearBindings(); + stmt.bindString(1, commodity.getFullname()); + stmt.bindString(2, commodity.getNamespace().name()); + stmt.bindString(3, commodity.getMnemonic()); + stmt.bindString(4, commodity.getLocalSymbol()); + stmt.bindString(5, commodity.getCusip()); + stmt.bindLong(6, commodity.getSmallestFraction()); + stmt.bindLong(7, commodity.getQuoteFlag()); + stmt.bindString(8, commodity.getUID()); + + return stmt; } @Override @@ -107,10 +109,14 @@ public Cursor fetchAllRecords(String orderBy) { * @return Commodity associated with code or null if none is found */ public Commodity getCommodity(String currencyCode){ - Cursor cursor = fetchAllRecords(CommodityEntry.COLUMN_MNEMONIC + "=?", new String[]{currencyCode}); + Cursor cursor = fetchAllRecords(CommodityEntry.COLUMN_MNEMONIC + "=?", new String[]{currencyCode}, null); Commodity commodity = null; if (cursor.moveToNext()){ commodity = buildModelInstance(cursor); + } else { + String msg = "Commodity not found in the database: " + currencyCode; + Log.e(LOG_TAG, msg); + Crashlytics.log(msg); } cursor.close(); return commodity; diff --git a/app/src/main/java/org/gnucash/android/db/DatabaseAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/DatabaseAdapter.java similarity index 77% rename from app/src/main/java/org/gnucash/android/db/DatabaseAdapter.java rename to app/src/main/java/org/gnucash/android/db/adapter/DatabaseAdapter.java index 705474ae7..d6de72871 100644 --- a/app/src/main/java/org/gnucash/android/db/DatabaseAdapter.java +++ b/app/src/main/java/org/gnucash/android/db/adapter/DatabaseAdapter.java @@ -14,21 +14,27 @@ * limitations under the License. */ -package org.gnucash.android.db; +package org.gnucash.android.db.adapter; import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteStatement; import android.support.annotation.NonNull; +import android.text.TextUtils; import android.util.Log; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseSchema; import org.gnucash.android.db.DatabaseSchema.AccountEntry; import org.gnucash.android.db.DatabaseSchema.CommonColumns; import org.gnucash.android.db.DatabaseSchema.SplitEntry; import org.gnucash.android.db.DatabaseSchema.TransactionEntry; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.BaseModel; +import org.gnucash.android.ui.settings.PreferenceActivity; import org.gnucash.android.util.TimestampHelper; import java.util.ArrayList; @@ -54,21 +60,33 @@ public abstract class DatabaseAdapter { protected final String mTableName; - protected SQLiteStatement mReplaceStatement; + protected final String[] mColumns; + + protected volatile SQLiteStatement mReplaceStatement; + + protected volatile SQLiteStatement mUpdateStatement; + + protected volatile SQLiteStatement mInsertStatement; + + public enum UpdateMethod { + insert, update, replace + }; /** * Opens the database adapter with an existing database * @param db SQLiteDatabase object */ - public DatabaseAdapter(SQLiteDatabase db, @NonNull String tableName) { + public DatabaseAdapter(SQLiteDatabase db, @NonNull String tableName, @NonNull String[] columns) { this.mTableName = tableName; this.mDb = db; + this.mColumns = columns; if (!db.isOpen() || db.isReadOnly()) throw new IllegalArgumentException("Database not open or is read-only. Require writeable database"); if (mDb.getVersion() >= 9) { createTempView(); } + LOG_TAG = getClass().getSimpleName(); } private void createTempView() { @@ -81,6 +99,8 @@ private void createTempView() { // // create a temporary view, combining accounts, transactions and splits, as this is often used // in the queries + + //todo: would it be useful to add the split reconciled_state and reconciled_date to this view? mDb.execSQL("CREATE TEMP VIEW IF NOT EXISTS trans_split_acct AS SELECT " + TransactionEntry.TABLE_NAME + "." + CommonColumns.COLUMN_MODIFIED_AT + " AS " + TransactionEntry.TABLE_NAME + "_" + CommonColumns.COLUMN_MODIFIED_AT + " , " @@ -191,8 +211,71 @@ public boolean isOpen(){ * @param model Model to be saved to the database */ public void addRecord(@NonNull final Model model){ + addRecord(model, UpdateMethod.replace); + } + + /** + * Add a model record to the database. + *

If unsure about which {@code updateMethod} to use, use {@link UpdateMethod#replace}

+ * @param model Subclass of {@link BaseModel} to be added + * @param updateMethod Method to use for adding the record + */ + public void addRecord(@NonNull final Model model, UpdateMethod updateMethod){ Log.d(LOG_TAG, String.format("Adding %s record to database: ", model.getClass().getSimpleName())); - compileReplaceStatement(model).execute(); + switch(updateMethod){ + case insert: + synchronized(getInsertStatement()) { + setBindings(getInsertStatement(), model).execute(); + } + break; + case update: + synchronized(getUpdateStatement()) { + setBindings(getUpdateStatement(), model).execute(); + } + break; + default: + synchronized(getReplaceStatement()) { + setBindings(getReplaceStatement(), model).execute(); + } + break; + } + } + + /** + * Persist the model object to the database as records using the {@code updateMethod} + * @param modelList List of records + * @param updateMethod Method to use when persisting them + * @return Number of rows affected in the database + */ + private long doAddModels(@NonNull final List modelList, UpdateMethod updateMethod) { + long nRow = 0; + switch (updateMethod) { + case update: + synchronized(getUpdateStatement()) { + for (Model model : modelList) { + setBindings(getUpdateStatement(), model).execute(); + nRow++; + } + } + break; + case insert: + synchronized(getInsertStatement()) { + for (Model model : modelList) { + setBindings(getInsertStatement(), model).execute(); + nRow++; + } + } + break; + default: + synchronized(getReplaceStatement()) { + for (Model model : modelList) { + setBindings(getReplaceStatement(), model).execute(); + nRow++; + } + } + break; + } + return nRow; } /** @@ -201,7 +284,11 @@ public void addRecord(@NonNull final Model model){ * @param modelList List of model records * @return Number of rows inserted */ - public long bulkAddRecords(@NonNull List modelList) { + public long bulkAddRecords(@NonNull List modelList){ + return bulkAddRecords(modelList, UpdateMethod.replace); + } + + public long bulkAddRecords(@NonNull List modelList, UpdateMethod updateMethod) { if (modelList.isEmpty()) { Log.d(LOG_TAG, "Empty model list. Cannot bulk add records, returning 0"); return 0; @@ -212,10 +299,7 @@ public long bulkAddRecords(@NonNull List modelList) { long nRow = 0; try { mDb.beginTransaction(); - for (Model model : modelList) { - compileReplaceStatement(model).execute(); - nRow++; - } + nRow = doAddModels(modelList, updateMethod); mDb.setTransactionSuccessful(); } finally { @@ -227,21 +311,81 @@ public long bulkAddRecords(@NonNull List modelList) { /** * Builds an instance of the model from the database record entry - *

This method should not modify the cursor in any way

+ *

When implementing this method, remember to call {@link #populateBaseModelAttributes(Cursor, BaseModel)}

* @param cursor Cursor pointing to the record - * @return + * @return New instance of the model from database record */ - protected abstract Model buildModelInstance(@NonNull final Cursor cursor); + public abstract Model buildModelInstance(@NonNull final Cursor cursor); /** * Generates an {@link SQLiteStatement} with values from the {@code model}. * This statement can be executed to replace a record in the database. *

If the {@link #mReplaceStatement} is null, subclasses should create a new statement and return.
* If it is not null, the previous bindings will be cleared and replaced with those from the model

- * @param model Model whose attributes will be used as bindings * @return SQLiteStatement for replacing a record in the database */ - protected abstract SQLiteStatement compileReplaceStatement(@NonNull final Model model); + protected final @NonNull SQLiteStatement getReplaceStatement() { + SQLiteStatement stmt = mReplaceStatement; + if (stmt == null) { + synchronized (this) { + stmt = mReplaceStatement; + if (stmt == null) { + mReplaceStatement = stmt + = mDb.compileStatement("REPLACE INTO " + mTableName + " ( " + + TextUtils.join(" , ", mColumns) + " , " + + CommonColumns.COLUMN_UID + + " ) VALUES ( " + + (new String(new char[mColumns.length]).replace("\0", "? , ")) + + "?)"); + } + } + } + return stmt; + } + + protected final @NonNull SQLiteStatement getUpdateStatement() { + SQLiteStatement stmt = mUpdateStatement; + if (stmt == null) { + synchronized (this) { + stmt = mUpdateStatement; + if (stmt == null) { + mUpdateStatement = stmt + = mDb.compileStatement("UPDATE " + mTableName + " SET " + + TextUtils.join(" = ? , ", mColumns) + " = ? WHERE " + + CommonColumns.COLUMN_UID + + " = ?"); + } + } + } + return stmt; + } + + protected final @NonNull SQLiteStatement getInsertStatement() { + SQLiteStatement stmt = mInsertStatement; + if (stmt == null) { + synchronized (this) { + stmt = mInsertStatement; + if (stmt == null) { + mInsertStatement = stmt + = mDb.compileStatement("INSERT INTO " + mTableName + " ( " + + TextUtils.join(" , ", mColumns) + " , " + + CommonColumns.COLUMN_UID + + " ) VALUES ( " + + (new String(new char[mColumns.length]).replace("\0", "? , ")) + + "?)"); + } + } + } + return stmt; + } + + /** + * Binds the values from the model the the SQL statement + * @param stmt SQL statement with placeholders + * @param model Model from which to read bind attributes + * @return SQL statement ready for execution + */ + protected abstract @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull final Model model); /** * Returns a model instance populated with data from the record with GUID {@code uid} @@ -259,7 +403,7 @@ public Model getRecord(@NonNull String uid){ return buildModelInstance(cursor); } else { - throw new IllegalArgumentException("Record with " + uid + " does not exist"); + throw new IllegalArgumentException(LOG_TAG + ": Record with " + uid + " does not exist"); } } finally { cursor.close(); @@ -294,12 +438,12 @@ public List getAllRecords(){ } /** - * Adds the attributes of the base model to the ContentValues object provided + * Extracts the attributes of the base model and adds them to the ContentValues object provided * @param contentValues Content values to which to add attributes * @param model {@link org.gnucash.android.model.BaseModel} from which to extract values * @return {@link android.content.ContentValues} with the data to be inserted into the db */ - protected ContentValues populateBaseModelAttributes(@NonNull ContentValues contentValues, @NonNull Model model){ + protected ContentValues extractBaseModelAttributes(@NonNull ContentValues contentValues, @NonNull Model model){ contentValues.put(CommonColumns.COLUMN_UID, model.getUID()); contentValues.put(CommonColumns.COLUMN_CREATED_AT, TimestampHelper.getUtcStringFromTimestamp(model.getCreatedTimestamp())); //there is a trigger in the database for updated the modified_at column @@ -350,17 +494,18 @@ public Cursor fetchRecord(@NonNull String uid){ * @return {@link Cursor} to all records in table tableName */ public Cursor fetchAllRecords(){ - return fetchAllRecords(null, null); + return fetchAllRecords(null, null, null); } /** * Fetch all records from database matching conditions * @param where SQL where clause * @param whereArgs String arguments for where clause + * @param orderBy SQL orderby clause * @return Cursor to records matching conditions */ - public Cursor fetchAllRecords(String where, String[] whereArgs){ - return mDb.query(mTableName, null, where, whereArgs, null, null, null); + public Cursor fetchAllRecords(String where, String[] whereArgs, String orderBy){ + return mDb.query(mTableName, null, where, whereArgs, null, null, orderBy); } /** @@ -398,7 +543,7 @@ public long getID(@NonNull String uid){ if (cursor.moveToFirst()) { result = cursor.getLong(cursor.getColumnIndexOrThrow(DatabaseSchema.CommonColumns._ID)); } else { - throw new IllegalArgumentException("GUID " + uid + " does not exist in the db"); + throw new IllegalArgumentException(mTableName + " with GUID " + uid + " does not exist in the db"); } } finally { cursor.close(); @@ -580,7 +725,24 @@ public boolean deleteRecord(@NonNull String uid){ * @throws IllegalArgumentException if either the {@code recordUID} or {@code columnName} do not exist in the database */ public String getAttribute(@NonNull String recordUID, @NonNull String columnName){ - Cursor cursor = mDb.query(mTableName, + return getAttribute(mTableName, recordUID, columnName); + } + + /** + * Returns an attribute from a specific column in the database for a specific record and specific table. + *

The attribute is returned as a string which can then be converted to another type if + * the caller was expecting something other type

+ *

This method is an override of {@link #getAttribute(String, String)} which allows to select a value from a + * different table than the one of current adapter instance + *

+ * @param tableName Database table name. See {@link DatabaseSchema} + * @param recordUID GUID of the record + * @param columnName Name of the column to be retrieved + * @return String value of the column entry + * @throws IllegalArgumentException if either the {@code recordUID} or {@code columnName} do not exist in the database + */ + protected String getAttribute(@NonNull String tableName, @NonNull String recordUID, @NonNull String columnName){ + Cursor cursor = mDb.query(tableName, new String[]{columnName}, AccountEntry.COLUMN_UID + " = ?", new String[]{recordUID}, null, null, null); @@ -628,9 +790,9 @@ public void setTransactionSuccessful() { /// that need not be deleted, then it can be disabled temporarily public void enableForeignKey(boolean enable) { if (enable){ - mDb.execSQL("PRAGMA foreign_keys=ON"); + mDb.execSQL("PRAGMA foreign_keys=ON;"); } else { - mDb.execSQL("PRAGMA foreign_keys=OFF"); + mDb.execSQL("PRAGMA foreign_keys=OFF;"); } } diff --git a/app/src/main/java/org/gnucash/android/db/PricesDbAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/PricesDbAdapter.java similarity index 71% rename from app/src/main/java/org/gnucash/android/db/PricesDbAdapter.java rename to app/src/main/java/org/gnucash/android/db/adapter/PricesDbAdapter.java index 5cfce3700..5f89148eb 100644 --- a/app/src/main/java/org/gnucash/android/db/PricesDbAdapter.java +++ b/app/src/main/java/org/gnucash/android/db/adapter/PricesDbAdapter.java @@ -1,4 +1,4 @@ -package org.gnucash.android.db; +package org.gnucash.android.db.adapter; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; @@ -21,7 +21,15 @@ public class PricesDbAdapter extends DatabaseAdapter { * @param db SQLiteDatabase object */ public PricesDbAdapter(SQLiteDatabase db) { - super(db, PriceEntry.TABLE_NAME); + super(db, PriceEntry.TABLE_NAME, new String[]{ + PriceEntry.COLUMN_COMMODITY_UID, + PriceEntry.COLUMN_CURRENCY_UID, + PriceEntry.COLUMN_DATE, + PriceEntry.COLUMN_SOURCE, + PriceEntry.COLUMN_TYPE, + PriceEntry.COLUMN_VALUE_NUM, + PriceEntry.COLUMN_VALUE_DENOM + }); } public static PricesDbAdapter getInstance(){ @@ -29,34 +37,22 @@ public static PricesDbAdapter getInstance(){ } @Override - protected SQLiteStatement compileReplaceStatement(@NonNull final Price price) { - if (mReplaceStatement == null) { - mReplaceStatement = mDb.compileStatement("REPLACE INTO " + PriceEntry.TABLE_NAME + " ( " - + PriceEntry.COLUMN_UID + " , " - + PriceEntry.COLUMN_COMMODITY_UID + " , " - + PriceEntry.COLUMN_CURRENCY_UID + " , " - + PriceEntry.COLUMN_DATE + " , " - + PriceEntry.COLUMN_SOURCE + " , " - + PriceEntry.COLUMN_TYPE + " , " - + PriceEntry.COLUMN_VALUE_NUM + " , " - + PriceEntry.COLUMN_VALUE_DENOM + " ) VALUES ( ? , ? , ? , ? , ? , ? , ? , ? ) "); - } - - mReplaceStatement.clearBindings(); - mReplaceStatement.bindString(1, price.getUID()); - mReplaceStatement.bindString(2, price.getCommodityUID()); - mReplaceStatement.bindString(3, price.getCurrencyUID()); - mReplaceStatement.bindString(4, TimestampHelper.getUtcStringFromTimestamp(price.getDate())); + protected @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull final Price price) { + stmt.clearBindings(); + stmt.bindString(1, price.getCommodityUID()); + stmt.bindString(2, price.getCurrencyUID()); + stmt.bindString(3, price.getDate().toString()); if (price.getSource() != null) { - mReplaceStatement.bindString(5, price.getSource()); + stmt.bindString(4, price.getSource()); } if (price.getType() != null) { - mReplaceStatement.bindString(6, price.getType()); + stmt.bindString(5, price.getType()); } - mReplaceStatement.bindLong(7, price.getValueNum()); - mReplaceStatement.bindLong(8, price.getValueDenom()); + stmt.bindLong(6, price.getValueNum()); + stmt.bindLong(7, price.getValueDenom()); + stmt.bindString(8, price.getUID()); - return mReplaceStatement; + return stmt; } @Override @@ -81,10 +77,14 @@ public Price buildModelInstance(@NonNull final Cursor cursor) { } /** - * get the price for commodity / currency pair + * Get the price for commodity / currency pair. + * The price can be used to convert from one commodity to another. The 'commodity' is the origin and the 'currency' is the target for the conversion. + * + *

Pair is used instead of Price object because we must sometimes invert the commodity/currency in DB, + * rendering the Price UID invalid.

* - * Pair is used instead of Price because we must sometimes invert the commodity/currency in DB, - * rendering the Price UID invalid. + * @param commodityUID GUID of the commodity which is starting point for conversion + * @param currencyUID GUID of target commodity for the conversion * * @return The numerator/denominator pair for commodity / currency pair */ diff --git a/app/src/main/java/org/gnucash/android/db/adapter/RecurrenceDbAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/RecurrenceDbAdapter.java new file mode 100644 index 000000000..90ca5dcfe --- /dev/null +++ b/app/src/main/java/org/gnucash/android/db/adapter/RecurrenceDbAdapter.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.db.adapter; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteStatement; +import android.support.annotation.NonNull; + +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.model.PeriodType; +import org.gnucash.android.model.Recurrence; + +import java.sql.Timestamp; + +import static org.gnucash.android.db.DatabaseSchema.RecurrenceEntry; + +/** + * Database adapter for {@link Recurrence} entries + */ +public class RecurrenceDbAdapter extends DatabaseAdapter { + /** + * Opens the database adapter with an existing database + * + * @param db SQLiteDatabase object + */ + public RecurrenceDbAdapter(SQLiteDatabase db) { + super(db, RecurrenceEntry.TABLE_NAME, new String[]{ + RecurrenceEntry.COLUMN_MULTIPLIER, + RecurrenceEntry.COLUMN_PERIOD_TYPE, + RecurrenceEntry.COLUMN_BYDAY, + RecurrenceEntry.COLUMN_PERIOD_START, + RecurrenceEntry.COLUMN_PERIOD_END + }); + } + + public static RecurrenceDbAdapter getInstance(){ + return GnuCashApplication.getRecurrenceDbAdapter(); + } + + @Override + public Recurrence buildModelInstance(@NonNull Cursor cursor) { + String type = cursor.getString(cursor.getColumnIndexOrThrow(RecurrenceEntry.COLUMN_PERIOD_TYPE)); + long multiplier = cursor.getLong(cursor.getColumnIndexOrThrow(RecurrenceEntry.COLUMN_MULTIPLIER)); + String periodStart = cursor.getString(cursor.getColumnIndexOrThrow(RecurrenceEntry.COLUMN_PERIOD_START)); + String periodEnd = cursor.getString(cursor.getColumnIndexOrThrow(RecurrenceEntry.COLUMN_PERIOD_END)); + String byDay = cursor.getString(cursor.getColumnIndexOrThrow(RecurrenceEntry.COLUMN_BYDAY)); + + PeriodType periodType = PeriodType.valueOf(type); + periodType.setMultiplier((int) multiplier); + + Recurrence recurrence = new Recurrence(periodType); + recurrence.setPeriodStart(Timestamp.valueOf(periodStart)); + if (periodEnd != null) + recurrence.setPeriodEnd(Timestamp.valueOf(periodEnd)); + recurrence.setByDay(byDay); + + populateBaseModelAttributes(cursor, recurrence); + + return recurrence; + } + + @Override + protected @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull final Recurrence recurrence) { + stmt.clearBindings(); + stmt.bindLong(1, recurrence.getPeriodType().getMultiplier()); + stmt.bindString(2, recurrence.getPeriodType().name()); + if (recurrence.getByDay() != null) + stmt.bindString(3, recurrence.getByDay()); + //recurrence should always have a start date + stmt.bindString(4, recurrence.getPeriodStart().toString()); + + if (recurrence.getPeriodEnd() != null) + stmt.bindString(5, recurrence.getPeriodEnd().toString()); + stmt.bindString(6, recurrence.getUID()); + + return stmt; + } +} diff --git a/app/src/main/java/org/gnucash/android/db/ScheduledActionDbAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/ScheduledActionDbAdapter.java similarity index 54% rename from app/src/main/java/org/gnucash/android/db/ScheduledActionDbAdapter.java rename to app/src/main/java/org/gnucash/android/db/adapter/ScheduledActionDbAdapter.java index add6015df..9e6ddea96 100644 --- a/app/src/main/java/org/gnucash/android/db/ScheduledActionDbAdapter.java +++ b/app/src/main/java/org/gnucash/android/db/adapter/ScheduledActionDbAdapter.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.gnucash.android.db; +package org.gnucash.android.db.adapter; import android.content.ContentValues; import android.database.Cursor; @@ -23,8 +23,9 @@ import android.util.Log; import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.model.Recurrence; import org.gnucash.android.model.ScheduledAction; -import org.gnucash.android.util.TimestampHelper; import java.util.ArrayList; import java.util.List; @@ -38,8 +39,28 @@ */ public class ScheduledActionDbAdapter extends DatabaseAdapter { - public ScheduledActionDbAdapter(SQLiteDatabase db){ - super(db, ScheduledActionEntry.TABLE_NAME); + private RecurrenceDbAdapter mRecurrenceDbAdapter; + + public ScheduledActionDbAdapter(SQLiteDatabase db, RecurrenceDbAdapter recurrenceDbAdapter){ + super(db, ScheduledActionEntry.TABLE_NAME, new String[]{ + ScheduledActionEntry.COLUMN_ACTION_UID , + ScheduledActionEntry.COLUMN_TYPE , + ScheduledActionEntry.COLUMN_START_TIME , + ScheduledActionEntry.COLUMN_END_TIME , + ScheduledActionEntry.COLUMN_LAST_RUN , + ScheduledActionEntry.COLUMN_ENABLED , + ScheduledActionEntry.COLUMN_CREATED_AT , + ScheduledActionEntry.COLUMN_TAG , + ScheduledActionEntry.COLUMN_TOTAL_FREQUENCY , + ScheduledActionEntry.COLUMN_RECURRENCE_UID , + ScheduledActionEntry.COLUMN_AUTO_CREATE , + ScheduledActionEntry.COLUMN_AUTO_NOTIFY , + ScheduledActionEntry.COLUMN_ADVANCE_CREATION , + ScheduledActionEntry.COLUMN_ADVANCE_NOTIFY , + ScheduledActionEntry.COLUMN_TEMPLATE_ACCT_UID , + ScheduledActionEntry.COLUMN_EXECUTION_COUNT + }); + mRecurrenceDbAdapter = recurrenceDbAdapter; LOG_TAG = "ScheduledActionDbAdapter"; } @@ -51,23 +72,51 @@ public static ScheduledActionDbAdapter getInstance(){ return GnuCashApplication.getScheduledEventDbAdapter(); } + @Override + public void addRecord(@NonNull ScheduledAction scheduledAction, UpdateMethod updateMethod) { + mRecurrenceDbAdapter.addRecord(scheduledAction.getRecurrence(), updateMethod); + super.addRecord(scheduledAction, updateMethod); + } + + @Override + public long bulkAddRecords(@NonNull List scheduledActions, UpdateMethod updateMethod) { + List recurrenceList = new ArrayList<>(scheduledActions.size()); + for (ScheduledAction scheduledAction : scheduledActions) { + recurrenceList.add(scheduledAction.getRecurrence()); + } + + //first add the recurrences, they have no dependencies (foreign key constraints) + long nRecurrences = mRecurrenceDbAdapter.bulkAddRecords(recurrenceList, updateMethod); + Log.d(LOG_TAG, String.format("Added %d recurrences for scheduled actions", nRecurrences)); + + return super.bulkAddRecords(scheduledActions, updateMethod); + } + /** * Updates only the recurrence attributes of the scheduled action. * The recurrence attributes are the period, start time, end time and/or total frequency. - * All other properties of a scheduled event are only used for interal database tracking and are + * All other properties of a scheduled event are only used for internal database tracking and are * not central to the recurrence schedule. *

The GUID of the scheduled action should already exist in the database

* @param scheduledAction Scheduled action * @return Database record ID of the edited scheduled action */ public long updateRecurrenceAttributes(ScheduledAction scheduledAction){ + //since we are updating, first fetch the existing recurrence UID and set it to the object + //so that it will be updated and not a new one created + RecurrenceDbAdapter recurrenceDbAdapter = new RecurrenceDbAdapter(mDb); + String recurrenceUID = recurrenceDbAdapter.getAttribute(scheduledAction.getUID(), ScheduledActionEntry.COLUMN_RECURRENCE_UID); + + Recurrence recurrence = scheduledAction.getRecurrence(); + recurrence.setUID(recurrenceUID); + recurrenceDbAdapter.addRecord(recurrence, UpdateMethod.update); + ContentValues contentValues = new ContentValues(); - populateBaseModelAttributes(contentValues, scheduledAction); - contentValues.put(ScheduledActionEntry.COLUMN_PERIOD, scheduledAction.getPeriod()); + extractBaseModelAttributes(contentValues, scheduledAction); contentValues.put(ScheduledActionEntry.COLUMN_START_TIME, scheduledAction.getStartTime()); contentValues.put(ScheduledActionEntry.COLUMN_END_TIME, scheduledAction.getEndTime()); contentValues.put(ScheduledActionEntry.COLUMN_TAG, scheduledAction.getTag()); - contentValues.put(ScheduledActionEntry.COLUMN_TOTAL_FREQUENCY, scheduledAction.getTotalFrequency()); + contentValues.put(ScheduledActionEntry.COLUMN_TOTAL_FREQUENCY, scheduledAction.getTotalPlannedExecutionCount()); Log.d(LOG_TAG, "Updating scheduled event recurrence attributes"); String where = ScheduledActionEntry.COLUMN_UID + "=?"; @@ -75,45 +124,32 @@ public long updateRecurrenceAttributes(ScheduledAction scheduledAction){ return mDb.update(ScheduledActionEntry.TABLE_NAME, contentValues, where, whereArgs); } - @Override - protected SQLiteStatement compileReplaceStatement(@NonNull final ScheduledAction schedxAction) { - if (mReplaceStatement == null) { - mReplaceStatement = mDb.compileStatement("REPLACE INTO " + ScheduledActionEntry.TABLE_NAME + " ( " - + ScheduledActionEntry.COLUMN_UID + " , " - + ScheduledActionEntry.COLUMN_ACTION_UID + " , " - + ScheduledActionEntry.COLUMN_TYPE + " , " - + ScheduledActionEntry.COLUMN_START_TIME + " , " - + ScheduledActionEntry.COLUMN_END_TIME + " , " - + ScheduledActionEntry.COLUMN_LAST_RUN + " , " - + ScheduledActionEntry.COLUMN_PERIOD + " , " - + ScheduledActionEntry.COLUMN_ENABLED + " , " - + ScheduledActionEntry.COLUMN_CREATED_AT + " , " - + ScheduledActionEntry.COLUMN_TAG + " , " - + ScheduledActionEntry.COLUMN_TOTAL_FREQUENCY + " , " - + ScheduledActionEntry.COLUMN_EXECUTION_COUNT + " ) VALUES ( ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? )"); - } - - mReplaceStatement.clearBindings(); - mReplaceStatement.bindString(1, schedxAction.getUID()); - mReplaceStatement.bindString(2, schedxAction.getActionUID()); - mReplaceStatement.bindString(3, schedxAction.getActionType().name()); - mReplaceStatement.bindLong(4, schedxAction.getStartTime()); - mReplaceStatement.bindLong(5, schedxAction.getEndTime()); - mReplaceStatement.bindLong(6, schedxAction.getLastRun()); - mReplaceStatement.bindLong(7, schedxAction.getPeriod()); - mReplaceStatement.bindLong(8, schedxAction.isEnabled() ? 1 : 0); - mReplaceStatement.bindString(9, TimestampHelper.getUtcStringFromTimestamp(schedxAction.getCreatedTimestamp())); + protected @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull final ScheduledAction schedxAction) { + stmt.clearBindings(); + stmt.bindString(1, schedxAction.getActionUID()); + stmt.bindString(2, schedxAction.getActionType().name()); + stmt.bindLong(3, schedxAction.getStartTime()); + stmt.bindLong(4, schedxAction.getEndTime()); + stmt.bindLong(5, schedxAction.getLastRunTime()); + stmt.bindLong(6, schedxAction.isEnabled() ? 1 : 0); + stmt.bindString(7, schedxAction.getCreatedTimestamp().toString()); if (schedxAction.getTag() == null) - mReplaceStatement.bindNull(10); + stmt.bindNull(8); else - mReplaceStatement.bindString(10, schedxAction.getTag()); - mReplaceStatement.bindString(11, Integer.toString(schedxAction.getTotalFrequency())); - mReplaceStatement.bindString(12, Integer.toString(schedxAction.getExecutionCount())); - - return mReplaceStatement; + stmt.bindString(8, schedxAction.getTag()); + stmt.bindString(9, Integer.toString(schedxAction.getTotalPlannedExecutionCount())); + stmt.bindString(10, schedxAction.getRecurrence().getUID()); + stmt.bindLong(11, schedxAction.shouldAutoCreate() ? 1 : 0); + stmt.bindLong(12, schedxAction.shouldAutoNotify() ? 1 : 0); + stmt.bindLong(13, schedxAction.getAdvanceCreateDays()); + stmt.bindLong(14, schedxAction.getAdvanceNotifyDays()); + stmt.bindString(15, schedxAction.getTemplateAccountUID()); + + stmt.bindString(16, Integer.toString(schedxAction.getExecutionCount())); + stmt.bindString(17, schedxAction.getUID()); + return stmt; } - /** * Builds a {@link org.gnucash.android.model.ScheduledAction} instance from a row to cursor in the database. * The cursor should be already pointing to the right entry in the data set. It will not be modified in any way @@ -123,7 +159,6 @@ protected SQLiteStatement compileReplaceStatement(@NonNull final ScheduledAction @Override public ScheduledAction buildModelInstance(@NonNull final Cursor cursor){ String actionUid = cursor.getString(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_ACTION_UID)); - long period = cursor.getLong(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_PERIOD)); long startTime = cursor.getLong(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_START_TIME)); long endTime = cursor.getLong(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_END_TIME)); long lastRun = cursor.getLong(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_LAST_RUN)); @@ -132,18 +167,30 @@ public ScheduledAction buildModelInstance(@NonNull final Cursor cursor){ boolean enabled = cursor.getInt(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_ENABLED)) > 0; int numOccurrences = cursor.getInt(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_TOTAL_FREQUENCY)); int execCount = cursor.getInt(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_EXECUTION_COUNT)); + int autoCreate = cursor.getInt(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_AUTO_CREATE)); + int autoNotify = cursor.getInt(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_AUTO_NOTIFY)); + int advanceCreate = cursor.getInt(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_ADVANCE_CREATION)); + int advanceNotify = cursor.getInt(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_ADVANCE_NOTIFY)); + String recurrenceUID = cursor.getString(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_RECURRENCE_UID)); + String templateActUID = cursor.getString(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_TEMPLATE_ACCT_UID)); ScheduledAction event = new ScheduledAction(ScheduledAction.ActionType.valueOf(typeString)); populateBaseModelAttributes(cursor, event); - event.setPeriod(period); event.setStartTime(startTime); event.setEndTime(endTime); event.setActionUID(actionUid); event.setLastRun(lastRun); event.setTag(tag); event.setEnabled(enabled); - event.setTotalFrequency(numOccurrences); + event.setTotalPlannedExecutionCount(numOccurrences); event.setExecutionCount(execCount); + event.setAutoCreate(autoCreate == 1); + event.setAutoNotify(autoNotify == 1); + event.setAdvanceCreateDays(advanceCreate); + event.setAdvanceNotifyDays(advanceNotify); + //TODO: optimize by doing overriding fetchRecord(String) and join the two tables + event.setRecurrence(mRecurrenceDbAdapter.getRecord(recurrenceUID)); + event.setTemplateAccountUID(templateActUID); return event; } @@ -159,7 +206,7 @@ public List getScheduledActionsWithUID(@NonNull String actionUI ScheduledActionEntry.COLUMN_ACTION_UID + "= ?", new String[]{actionUID}, null, null, null); - List scheduledActions = new ArrayList(); + List scheduledActions = new ArrayList<>(); try { while (cursor.moveToNext()) { scheduledActions.add(buildModelInstance(cursor)); @@ -172,12 +219,12 @@ public List getScheduledActionsWithUID(@NonNull String actionUI /** * Returns all enabled scheduled actions in the database - * @return List of enalbed scheduled actions + * @return List of enabled scheduled actions */ public List getAllEnabledScheduledActions(){ Cursor cursor = mDb.query(mTableName, null, ScheduledActionEntry.COLUMN_ENABLED + "=1", null, null, null, null); - List scheduledActions = new ArrayList(); + List scheduledActions = new ArrayList<>(); while (cursor.moveToNext()){ scheduledActions.add(buildModelInstance(cursor)); } diff --git a/app/src/main/java/org/gnucash/android/db/SplitsDbAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/SplitsDbAdapter.java similarity index 89% rename from app/src/main/java/org/gnucash/android/db/SplitsDbAdapter.java rename to app/src/main/java/org/gnucash/android/db/adapter/SplitsDbAdapter.java index 41fda7487..9750504de 100644 --- a/app/src/main/java/org/gnucash/android/db/SplitsDbAdapter.java +++ b/app/src/main/java/org/gnucash/android/db/adapter/SplitsDbAdapter.java @@ -15,7 +15,7 @@ * limitations under the License. */ -package org.gnucash.android.db; +package org.gnucash.android.db.adapter; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; @@ -27,6 +27,7 @@ import android.util.Pair; import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseSchema; import org.gnucash.android.model.Commodity; import org.gnucash.android.model.Money; import org.gnucash.android.model.Split; @@ -50,8 +51,19 @@ public class SplitsDbAdapter extends DatabaseAdapter { public SplitsDbAdapter(SQLiteDatabase db) { - super(db, SplitEntry.TABLE_NAME); - LOG_TAG = "SplitsDbAdapter"; + super(db, SplitEntry.TABLE_NAME, new String[]{ + SplitEntry.COLUMN_MEMO, + SplitEntry.COLUMN_TYPE, + SplitEntry.COLUMN_VALUE_NUM, + SplitEntry.COLUMN_VALUE_DENOM, + SplitEntry.COLUMN_QUANTITY_NUM, + SplitEntry.COLUMN_QUANTITY_DENOM, + SplitEntry.COLUMN_CREATED_AT, + SplitEntry.COLUMN_RECONCILE_STATE, + SplitEntry.COLUMN_RECONCILE_DATE, + SplitEntry.COLUMN_ACCOUNT_UID, + SplitEntry.COLUMN_TRANSACTION_UID + }); } /** @@ -67,9 +79,9 @@ public static SplitsDbAdapter getInstance(){ * The transactions belonging to the split are marked as exported * @param split {@link org.gnucash.android.model.Split} to be recorded in DB */ - public void addRecord(@NonNull final Split split){ + public void addRecord(@NonNull final Split split, UpdateMethod updateMethod){ Log.d(LOG_TAG, "Replace transaction split in db"); - super.addRecord(split); + super.addRecord(split, updateMethod); long transactionId = getTransactionID(split.getTransactionUID()); //when a split is updated, we want mark the transaction as not exported @@ -82,38 +94,25 @@ public void addRecord(@NonNull final Split split){ } @Override - protected SQLiteStatement compileReplaceStatement(@NonNull final Split split) { - if (mReplaceStatement == null) { - mReplaceStatement = mDb.compileStatement("REPLACE INTO " + SplitEntry.TABLE_NAME + " ( " - + SplitEntry.COLUMN_UID + " , " - + SplitEntry.COLUMN_MEMO + " , " - + SplitEntry.COLUMN_TYPE + " , " - + SplitEntry.COLUMN_VALUE_NUM + " , " - + SplitEntry.COLUMN_VALUE_DENOM + " , " - + SplitEntry.COLUMN_QUANTITY_NUM + " , " - + SplitEntry.COLUMN_QUANTITY_DENOM + " , " - + SplitEntry.COLUMN_CREATED_AT + " , " - + SplitEntry.COLUMN_ACCOUNT_UID + " , " - + SplitEntry.COLUMN_TRANSACTION_UID + " ) VALUES ( ? , ? , ? , ? , ? , ? , ? , ? , ? , ? ) "); - } - - mReplaceStatement.clearBindings(); - mReplaceStatement.bindString(1, split.getUID()); + protected @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull final Split split) { + stmt.clearBindings(); if (split.getMemo() != null) { - mReplaceStatement.bindString(2, split.getMemo()); + stmt.bindString(1, split.getMemo()); } - mReplaceStatement.bindString(3, split.getType().name()); - mReplaceStatement.bindLong(4, split.getValue().getNumerator()); - mReplaceStatement.bindLong(5, split.getValue().getDenominator()); - mReplaceStatement.bindLong(6, split.getQuantity().getNumerator()); - mReplaceStatement.bindLong(7, split.getQuantity().getDenominator()); - mReplaceStatement.bindString(8, TimestampHelper.getUtcStringFromTimestamp(split.getCreatedTimestamp())); - mReplaceStatement.bindString(9, split.getAccountUID()); - mReplaceStatement.bindString(10, split.getTransactionUID()); - - return mReplaceStatement; + stmt.bindString(2, split.getType().name()); + stmt.bindLong(3, split.getValue().getNumerator()); + stmt.bindLong(4, split.getValue().getDenominator()); + stmt.bindLong(5, split.getQuantity().getNumerator()); + stmt.bindLong(6, split.getQuantity().getDenominator()); + stmt.bindString(7, split.getCreatedTimestamp().toString()); + stmt.bindString(8, String.valueOf(split.getReconcileState())); + stmt.bindString(9, split.getReconcileDate().toString()); + stmt.bindString(10, split.getAccountUID()); + stmt.bindString(11, split.getTransactionUID()); + stmt.bindString(12, split.getUID()); + + return stmt; } - /** * Builds a split instance from the data pointed to by the cursor provided *

This method will not move the cursor in any way. So the cursor should already by pointing to the correct entry

@@ -129,8 +128,10 @@ public Split buildModelInstance(@NonNull final Cursor cursor){ String accountUID = cursor.getString(cursor.getColumnIndexOrThrow(SplitEntry.COLUMN_ACCOUNT_UID)); String transxUID = cursor.getString(cursor.getColumnIndexOrThrow(SplitEntry.COLUMN_TRANSACTION_UID)); String memo = cursor.getString(cursor.getColumnIndexOrThrow(SplitEntry.COLUMN_MEMO)); + String reconcileState = cursor.getString(cursor.getColumnIndexOrThrow(SplitEntry.COLUMN_RECONCILE_STATE)); + String reconcileDate = cursor.getString(cursor.getColumnIndexOrThrow(SplitEntry.COLUMN_RECONCILE_DATE)); - String transactionCurrency = TransactionsDbAdapter.getInstance().getAttribute(transxUID, TransactionEntry.COLUMN_CURRENCY); + String transactionCurrency = getAttribute(TransactionEntry.TABLE_NAME, transxUID, TransactionEntry.COLUMN_CURRENCY); Money value = new Money(valueNum, valueDenom, transactionCurrency); String currencyCode = getAccountCurrencyCode(accountUID); Money quantity = new Money(quantityNum, quantityDenom, currencyCode); @@ -141,6 +142,9 @@ public Split buildModelInstance(@NonNull final Cursor cursor){ split.setTransactionUID(transxUID); split.setType(TransactionType.valueOf(typeName)); split.setMemo(memo); + split.setReconcileState(reconcileState.charAt(0)); + if (reconcileDate != null && !reconcileDate.isEmpty()) + split.setReconcileDate(TimestampHelper.getTimestampFromUtcString(reconcileDate)); return split; } diff --git a/app/src/main/java/org/gnucash/android/db/TransactionsDbAdapter.java b/app/src/main/java/org/gnucash/android/db/adapter/TransactionsDbAdapter.java similarity index 86% rename from app/src/main/java/org/gnucash/android/db/TransactionsDbAdapter.java rename to app/src/main/java/org/gnucash/android/db/adapter/TransactionsDbAdapter.java index 31fafe0b5..4facc734c 100644 --- a/app/src/main/java/org/gnucash/android/db/TransactionsDbAdapter.java +++ b/app/src/main/java/org/gnucash/android/db/adapter/TransactionsDbAdapter.java @@ -15,7 +15,7 @@ * limitations under the License. */ -package org.gnucash.android.db; +package org.gnucash.android.db.adapter; import android.content.ContentValues; import android.database.Cursor; @@ -58,14 +58,26 @@ public class TransactionsDbAdapter extends DatabaseAdapter { private final SplitsDbAdapter mSplitsDbAdapter; + private final CommoditiesDbAdapter mCommoditiesDbAdapter; + /** * Overloaded constructor. Creates adapter for already open db * @param db SQlite db instance */ public TransactionsDbAdapter(SQLiteDatabase db, SplitsDbAdapter splitsDbAdapter) { - super(db, TransactionEntry.TABLE_NAME); + super(db, TransactionEntry.TABLE_NAME, new String[]{ + TransactionEntry.COLUMN_DESCRIPTION, + TransactionEntry.COLUMN_NOTES, + TransactionEntry.COLUMN_TIMESTAMP, + TransactionEntry.COLUMN_EXPORTED, + TransactionEntry.COLUMN_CURRENCY, + TransactionEntry.COLUMN_COMMODITY_UID, + TransactionEntry.COLUMN_CREATED_AT, + TransactionEntry.COLUMN_SCHEDX_ACTION_UID, + TransactionEntry.COLUMN_TEMPLATE + }); mSplitsDbAdapter = splitsDbAdapter; - LOG_TAG = "TransactionsDbAdapter"; + mCommoditiesDbAdapter = new CommoditiesDbAdapter(db); } /** @@ -87,22 +99,26 @@ public SplitsDbAdapter getSplitDbAdapter() { * @param transaction {@link Transaction} to be inserted to database */ @Override - public void addRecord(@NonNull Transaction transaction){ - Log.d(LOG_TAG, "Replacing transaction in db"); + public void addRecord(@NonNull Transaction transaction, UpdateMethod updateMethod){ + Log.d(LOG_TAG, "Adding transaction to the db via " + updateMethod.name()); mDb.beginTransaction(); try { - Split imbalanceSplit = transaction.getAutoBalanceSplit(); + Split imbalanceSplit = transaction.createAutoBalanceSplit(); if (imbalanceSplit != null){ - String imbalanceAccountUID = AccountsDbAdapter.getInstance().getOrCreateImbalanceAccountUID(transaction.getCurrency()); + String imbalanceAccountUID = new AccountsDbAdapter(mDb, this).getOrCreateImbalanceAccountUID(transaction.getCurrency()); imbalanceSplit.setAccountUID(imbalanceAccountUID); } - super.addRecord(transaction); + super.addRecord(transaction, updateMethod); Log.d(LOG_TAG, "Adding splits for transaction"); ArrayList splitUIDs = new ArrayList<>(transaction.getSplits().size()); for (Split split : transaction.getSplits()) { Log.d(LOG_TAG, "Replace transaction split in db"); - mSplitsDbAdapter.addRecord(split); + if (imbalanceSplit == split) { + mSplitsDbAdapter.addRecord(split, UpdateMethod.insert); + } else { + mSplitsDbAdapter.addRecord(split, updateMethod); + } splitUIDs.add(split.getUID()); } Log.d(LOG_TAG, transaction.getSplits().size() + " splits added"); @@ -132,9 +148,9 @@ public void addRecord(@NonNull Transaction transaction){ * @return Number of transactions inserted */ @Override - public long bulkAddRecords(@NonNull List transactionList){ + public long bulkAddRecords(@NonNull List transactionList, UpdateMethod updateMethod){ long start = System.nanoTime(); - long rowInserted = super.bulkAddRecords(transactionList); + long rowInserted = super.bulkAddRecords(transactionList, updateMethod); long end = System.nanoTime(); Log.d(getClass().getSimpleName(), String.format("bulk add transaction time %d ", end - start)); List splitList = new ArrayList<>(transactionList.size()*3); @@ -144,8 +160,8 @@ public long bulkAddRecords(@NonNull List transactionList){ if (rowInserted != 0 && !splitList.isEmpty()) { try { start = System.nanoTime(); - long nSplits = mSplitsDbAdapter.bulkAddRecords(splitList); - Log.d(LOG_TAG, String.format("%d splits inserted in %d ns", splitList.size(), System.nanoTime()-start)); + long nSplits = mSplitsDbAdapter.bulkAddRecords(splitList, updateMethod); + Log.d(LOG_TAG, String.format("%d splits inserted in %d ns", nSplits, System.nanoTime()-start)); } finally { SQLiteStatement deleteEmptyTransaction = mDb.compileStatement("DELETE FROM " + @@ -160,43 +176,29 @@ public long bulkAddRecords(@NonNull List transactionList){ } @Override - protected SQLiteStatement compileReplaceStatement(@NonNull final Transaction transaction) { - if (mReplaceStatement == null) { - mReplaceStatement = mDb.compileStatement("REPLACE INTO " + TransactionEntry.TABLE_NAME + " ( " - + TransactionEntry.COLUMN_UID + " , " - + TransactionEntry.COLUMN_DESCRIPTION + " , " - + TransactionEntry.COLUMN_NOTES + " , " - + TransactionEntry.COLUMN_TIMESTAMP + " , " - + TransactionEntry.COLUMN_EXPORTED + " , " - + TransactionEntry.COLUMN_CURRENCY + " , " - + TransactionEntry.COLUMN_COMMODITY_UID + " , " - + TransactionEntry.COLUMN_CREATED_AT + " , " - + TransactionEntry.COLUMN_SCHEDX_ACTION_UID + " , " - + TransactionEntry.COLUMN_TEMPLATE + " ) VALUES ( ? , ? , ? , ?, ? , ? , ? , ?, ? , ?)"); - } - - mReplaceStatement.clearBindings(); - mReplaceStatement.bindString(1, transaction.getUID()); - mReplaceStatement.bindString(2, transaction.getDescription()); - mReplaceStatement.bindString(3, transaction.getNote()); - mReplaceStatement.bindLong(4, transaction.getTimeMillis()); - mReplaceStatement.bindLong(5, transaction.isExported() ? 1 : 0); - mReplaceStatement.bindString(6, transaction.getCurrencyCode()); + protected @NonNull SQLiteStatement setBindings(@NonNull SQLiteStatement stmt, @NonNull Transaction transaction) { + stmt.clearBindings(); + stmt.bindString(1, transaction.getDescription()); + stmt.bindString(2, transaction.getNote()); + stmt.bindLong(3, transaction.getTimeMillis()); + stmt.bindLong(4, transaction.isExported() ? 1 : 0); + stmt.bindString(5, transaction.getCurrencyCode()); Commodity commodity = transaction.getCommodity(); if (commodity == null) - commodity = CommoditiesDbAdapter.getInstance().getCommodity(transaction.getCurrencyCode()); + commodity = mCommoditiesDbAdapter.getCommodity(transaction.getCurrencyCode()); - mReplaceStatement.bindString(7, commodity.getUID()); - mReplaceStatement.bindString(8, TimestampHelper.getUtcStringFromTimestamp(transaction.getCreatedTimestamp())); + stmt.bindString(6, commodity.getUID()); + stmt.bindString(7, TimestampHelper.getUtcStringFromTimestamp(transaction.getCreatedTimestamp())); if (transaction.getScheduledActionUID() == null) - mReplaceStatement.bindNull(9); + stmt.bindNull(8); else - mReplaceStatement.bindString(9, transaction.getScheduledActionUID()); - mReplaceStatement.bindLong(10, transaction.isTemplate() ? 1 : 0); + stmt.bindString(8, transaction.getScheduledActionUID()); + stmt.bindLong(9, transaction.isTemplate() ? 1 : 0); + stmt.bindString(10, transaction.getUID()); - return mReplaceStatement; + return stmt; } /** @@ -290,17 +292,6 @@ public Cursor fetchAllScheduledTransactions(){ return queryBuilder.query(mDb, projectionIn, null, null, null, null, sortOrder); } - /** - * Returns a cursor to a set of all transactions for the account with ID accountID - * or for which this account is the origin account in a double entry - * @param accountID ID of the account whose transactions are to be retrieved - * @return Cursor holding set of transactions for particular account - */ - public Cursor fetchAllTransactionsForAccount(long accountID){ - String accountUID = AccountsDbAdapter.getInstance().getUID(accountID); - return fetchAllTransactionsForAccount(accountUID); - } - /** * Returns list of all transactions for account with UID accountUID * @param accountUID UID of account whose transactions are to be retrieved @@ -365,7 +356,7 @@ public Cursor fetchTransactionsWithSplitsWithTransactionAccount(String [] column } /** - * Return number of transactions in the database which are non recurring + * Return number of transactions in the database (excluding templates) * @return Number of transactions */ public long getRecordsCount() { @@ -380,6 +371,12 @@ public long getRecordsCount() { } } + /** + * Returns the number of transactions in the database which fulfill the conditions + * @param where SQL WHERE clause without the "WHERE" itself + * @param whereArgs Arguments to substitute question marks for + * @return Number of records in the databases + */ public long getRecordsCount(@Nullable String where, @Nullable String[] whereArgs) { Cursor cursor = mDb.query(true, TransactionEntry.TABLE_NAME + " , trans_extra_info ON " + TransactionEntry.TABLE_NAME + "." + TransactionEntry.COLUMN_UID @@ -417,7 +414,7 @@ public Transaction buildModelInstance(@NonNull final Cursor c){ transaction.setTemplate(c.getInt(c.getColumnIndexOrThrow(TransactionEntry.COLUMN_TEMPLATE)) == 1); String currencyCode = c.getString(c.getColumnIndexOrThrow(TransactionEntry.COLUMN_CURRENCY)); transaction.setCurrencyCode(currencyCode); - transaction.setCommodity(CommoditiesDbAdapter.getInstance().getCommodity(currencyCode)); + transaction.setCommodity(mCommoditiesDbAdapter.getCommodity(currencyCode)); transaction.setScheduledActionUID(c.getString(c.getColumnIndexOrThrow(TransactionEntry.COLUMN_SCHEDX_ACTION_UID))); long transactionID = c.getLong(c.getColumnIndexOrThrow(TransactionEntry._ID)); transaction.setSplits(mSplitsDbAdapter.getSplitsForTransaction(transactionID)); @@ -425,18 +422,6 @@ public Transaction buildModelInstance(@NonNull final Cursor c){ return transaction; } - /** - * Returns the currency code (ISO 4217) used by the account with id accountId - * If you do not have the database record Id, you can call {@link #getID(String)} instead. - * @param accountId Database record id of the account - * @return Currency code of the account with Id accountId - * @see #getAccountCurrencyCode(String) - */ - public String getAccountCurrencyCode(long accountId){ - String accountUID = AccountsDbAdapter.getInstance().getUID(accountId); - return getAccountCurrencyCode(accountUID); - } - /** * Returns the transaction balance for the transaction for the specified account. *

We consider only those splits which belong to this account

@@ -466,23 +451,9 @@ public int moveTransaction(String transactionUID, String srcAccountUID, String d for (Split split : splits) { split.setAccountUID(dstAccountUID); } - mSplitsDbAdapter.bulkAddRecords(splits); + mSplitsDbAdapter.bulkAddRecords(splits, UpdateMethod.update); return splits.size(); } - - /** - * Returns the number of transactions belonging to account with id accountId - * @param accountId Long ID of account - * @return Number of transactions assigned to account with id accountId - */ - public int getTransactionsCount(long accountId){ - Cursor cursor = fetchAllTransactionsForAccount(accountId); - try { - return cursor.getCount(); - } finally { - cursor.close(); - } - } /** * Returns the number of transactions belonging to an account diff --git a/app/src/main/java/org/gnucash/android/export/ExportAsyncTask.java b/app/src/main/java/org/gnucash/android/export/ExportAsyncTask.java index 0e17ef283..cf7626818 100644 --- a/app/src/main/java/org/gnucash/android/export/ExportAsyncTask.java +++ b/app/src/main/java/org/gnucash/android/export/ExportAsyncTask.java @@ -24,6 +24,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ResolveInfo; +import android.database.sqlite.SQLiteDatabase; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; @@ -46,18 +47,27 @@ import com.google.android.gms.drive.DriveFolder; import com.google.android.gms.drive.DriveId; import com.google.android.gms.drive.MetadataChangeSet; +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientFactory; +import com.owncloud.android.lib.common.OwnCloudCredentialsFactory; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.files.CreateRemoteFolderOperation; +import com.owncloud.android.lib.resources.files.FileUtils; +import com.owncloud.android.lib.resources.files.UploadRemoteFileOperation; import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.export.ofx.OfxExporter; import org.gnucash.android.export.qif.QifExporter; import org.gnucash.android.export.xml.GncXmlExporter; import org.gnucash.android.model.Transaction; import org.gnucash.android.ui.account.AccountsActivity; import org.gnucash.android.ui.account.AccountsListFragment; -import org.gnucash.android.ui.settings.SettingsActivity; +import org.gnucash.android.ui.settings.BackupPreferenceFragment; import org.gnucash.android.ui.transaction.TransactionsActivity; import java.io.File; @@ -85,6 +95,8 @@ public class ExportAsyncTask extends AsyncTask { private ProgressDialog mProgressDialog; + private SQLiteDatabase mDb; + /** * Log tag */ @@ -100,8 +112,9 @@ public class ExportAsyncTask extends AsyncTask { private Exporter mExporter; - public ExportAsyncTask(Context context){ + public ExportAsyncTask(Context context, SQLiteDatabase db){ this.mContext = context; + this.mDb = db; } @Override @@ -132,16 +145,16 @@ protected Boolean doInBackground(ExportParams... params) { switch (mExportParams.getExportFormat()) { case QIF: - mExporter = new QifExporter(mExportParams); + mExporter = new QifExporter(mExportParams, mDb); break; case OFX: - mExporter = new OfxExporter(mExportParams); + mExporter = new OfxExporter(mExportParams, mDb); break; case XML: default: - mExporter = new GncXmlExporter(mExportParams); + mExporter = new GncXmlExporter(mExportParams, mDb); break; } @@ -151,7 +164,7 @@ protected Boolean doInBackground(ExportParams... params) { } catch (final Exception e) { Log.e(TAG, "Error exporting: " + e.getMessage()); Crashlytics.logException(e); - + e.printStackTrace(); if (mContext instanceof Activity) { ((Activity)mContext).runOnUiThread(new Runnable() { @Override @@ -180,6 +193,10 @@ public void run() { moveExportToGoogleDrive(); return true; + case OWNCLOUD: + moveExportToOwnCloud(); + return true; + case SD_CARD: moveExportToSDCard(); return true; @@ -213,8 +230,21 @@ protected void onPostExecute(Boolean exportResult) { case GOOGLE_DRIVE: targetLocation = "Google Drive -> " + mContext.getString(R.string.app_name); break; + case OWNCLOUD: + targetLocation = mContext.getSharedPreferences( + mContext.getString(R.string.owncloud_pref), + Context.MODE_PRIVATE).getBoolean( + mContext.getString(R.string.owncloud_sync), false) ? + + "ownCloud -> " + + mContext.getSharedPreferences( + mContext.getString(R.string.owncloud_pref), + Context.MODE_PRIVATE).getString( + mContext.getString(R.string.key_owncloud_dir), null) : + "ownCloud sync not enabled"; + break; default: - targetLocation = "external service"; + targetLocation = mContext.getString(R.string.label_export_target_external_service); } Toast.makeText(mContext, String.format(mContext.getString(R.string.toast_exported_to), targetLocation), @@ -246,7 +276,7 @@ protected void onPostExecute(Boolean exportResult) { private void moveExportToGoogleDrive(){ Log.i(TAG, "Moving exported file to Google Drive"); - final GoogleApiClient googleApiClient = SettingsActivity.getGoogleApiClient(GnuCashApplication.getAppContext()); + final GoogleApiClient googleApiClient = BackupPreferenceFragment.getGoogleApiClient(GnuCashApplication.getAppContext()); googleApiClient.blockingConnect(); final ResultCallback fileCallback = new ResultCallback() { @@ -306,8 +336,8 @@ public void onResult(DriveApi.DriveContentsResult result) { private void moveExportToDropbox() { Log.i(TAG, "Copying exported file to DropBox"); - String dropboxAppKey = mContext.getString(R.string.dropbox_app_key, SettingsActivity.DROPBOX_APP_KEY); - String dropboxAppSecret = mContext.getString(R.string.dropbox_app_secret, SettingsActivity.DROPBOX_APP_SECRET); + String dropboxAppKey = mContext.getString(R.string.dropbox_app_key, BackupPreferenceFragment.DROPBOX_APP_KEY); + String dropboxAppSecret = mContext.getString(R.string.dropbox_app_secret, BackupPreferenceFragment.DROPBOX_APP_SECRET); DbxAccountManager mDbxAcctMgr = DbxAccountManager.getInstance(mContext.getApplicationContext(), dropboxAppKey, dropboxAppSecret); DbxFile dbExportFile = null; @@ -333,6 +363,49 @@ private void moveExportToDropbox() { } } + private void moveExportToOwnCloud() { + Log.i(TAG, "Copying exported file to ownCloud"); + + SharedPreferences mPrefs = mContext.getSharedPreferences(mContext.getString(R.string.owncloud_pref), Context.MODE_PRIVATE); + + Boolean mOC_sync = mPrefs.getBoolean(mContext.getString(R.string.owncloud_sync), false); + + if(!mOC_sync){ + Log.e(TAG, "ownCloud not enabled."); + return; + } + + String mOC_server = mPrefs.getString(mContext.getString(R.string.key_owncloud_server), null); + String mOC_username = mPrefs.getString(mContext.getString(R.string.key_owncloud_username), null); + String mOC_password = mPrefs.getString(mContext.getString(R.string.key_owncloud_password), null); + String mOC_dir = mPrefs.getString(mContext.getString(R.string.key_owncloud_dir), null); + + Uri serverUri = Uri.parse(mOC_server); + OwnCloudClient mClient = OwnCloudClientFactory.createOwnCloudClient(serverUri, this.mContext, true); + mClient.setCredentials( + OwnCloudCredentialsFactory.newBasicCredentials(mOC_username, mOC_password) + ); + + if (mOC_dir.length() != 0) { + RemoteOperationResult dirResult = new CreateRemoteFolderOperation( + mOC_dir, true).execute(mClient); + if (!dirResult.isSuccess()) + Log.e(TAG, dirResult.getLogMessage(), dirResult.getException()); + } + for (String exportedFilePath : mExportedFiles) { + String remotePath = mOC_dir + FileUtils.PATH_SEPARATOR + stripPathPart(exportedFilePath); + String mimeType = mExporter.getExportMimeType(); + + RemoteOperationResult result = new UploadRemoteFileOperation( + exportedFilePath, remotePath, mimeType).execute(mClient); + + if (!result.isSuccess()) + Log.e(TAG, result.getLogMessage(), result.getException()); + else { + new File(exportedFilePath).delete(); + } + } + } /** * Moves the exported files from the internal storage where they are generated to @@ -341,11 +414,11 @@ private void moveExportToDropbox() { */ private List moveExportToSDCard() { Log.i(TAG, "Moving exported file to external storage"); - new File(Exporter.EXPORT_FOLDER_PATH).mkdirs(); + new File(Exporter.getExportFolderPath(mExporter.mBookUID)); List dstFiles = new ArrayList<>(); for (String src: mExportedFiles) { - String dst = Exporter.EXPORT_FOLDER_PATH + stripPathPart(src); + String dst = Exporter.getExportFolderPath(mExporter.mBookUID) + stripPathPart(src); try { moveFile(src, dst); dstFiles.add(dst); @@ -372,15 +445,15 @@ private void backupAndDeleteTransactions(){ GncXmlExporter.createBackup(); //create backup before deleting everything List openingBalances = new ArrayList<>(); boolean preserveOpeningBalances = GnuCashApplication.shouldSaveOpeningBalances(false); + + TransactionsDbAdapter transactionsDbAdapter = new TransactionsDbAdapter(mDb, new SplitsDbAdapter(mDb)); if (preserveOpeningBalances) { - openingBalances = AccountsDbAdapter.getInstance().getAllOpeningBalanceTransactions(); + openingBalances = new AccountsDbAdapter(mDb, transactionsDbAdapter).getAllOpeningBalanceTransactions(); } - - TransactionsDbAdapter transactionsDbAdapter = TransactionsDbAdapter.getInstance(); transactionsDbAdapter.deleteAllNonTemplateTransactions(); if (preserveOpeningBalances) { - transactionsDbAdapter.bulkAddRecords(openingBalances); + transactionsDbAdapter.bulkAddRecords(openingBalances, DatabaseAdapter.UpdateMethod.insert); } } @@ -423,8 +496,6 @@ private void shareFiles(List paths) { } } - // - /** * Convert file paths to URIs by adding the file// prefix *

e.g. /some/path/file.ext --> file:///some/path/file.ext

@@ -451,7 +522,6 @@ private ArrayList convertFilePathsToUris(List paths) { * @throws IOException if the file could not be moved. */ public void moveFile(String src, String dst) throws IOException { - //TODO: Make this asynchronous at some time, t in the future. File srcFile = new File(src); File dstFile = new File(dst); FileChannel inChannel = new FileInputStream(srcFile).getChannel(); diff --git a/app/src/main/java/org/gnucash/android/export/ExportParams.java b/app/src/main/java/org/gnucash/android/export/ExportParams.java index f4a7d0fa1..d751cac69 100644 --- a/app/src/main/java/org/gnucash/android/export/ExportParams.java +++ b/app/src/main/java/org/gnucash/android/export/ExportParams.java @@ -35,7 +35,7 @@ public class ExportParams { * Options for the destination of the exported transctions file. * It could be stored on the {@link #SD_CARD} or exported through another program via {@link #SHARING} */ - public enum ExportTarget {SD_CARD, SHARING, DROPBOX, GOOGLE_DRIVE } + public enum ExportTarget {SD_CARD, SHARING, DROPBOX, GOOGLE_DRIVE, OWNCLOUD } /** * Format to use for the exported transactions diff --git a/app/src/main/java/org/gnucash/android/export/Exporter.java b/app/src/main/java/org/gnucash/android/export/Exporter.java index 8d585531e..88afbabac 100644 --- a/app/src/main/java/org/gnucash/android/export/Exporter.java +++ b/app/src/main/java/org/gnucash/android/export/Exporter.java @@ -27,12 +27,18 @@ import org.gnucash.android.BuildConfig; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.CommoditiesDbAdapter; -import org.gnucash.android.db.PricesDbAdapter; -import org.gnucash.android.db.ScheduledActionDbAdapter; -import org.gnucash.android.db.SplitsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.BudgetAmountsDbAdapter; +import org.gnucash.android.db.adapter.BudgetsDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.PricesDbAdapter; +import org.gnucash.android.db.adapter.RecurrenceDbAdapter; +import org.gnucash.android.db.adapter.ScheduledActionDbAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; +import org.gnucash.android.model.Book; import java.io.File; import java.text.ParseException; @@ -57,17 +63,7 @@ public abstract class Exporter { /** * Application folder on external storage */ - private static final String BASE_FOLDER_PATH = Environment.getExternalStorageDirectory() + "/" + BuildConfig.APPLICATION_ID; - - /** - * Folder where exports like QIF and OFX will be saved for access by external programs - */ - public static final String EXPORT_FOLDER_PATH = BASE_FOLDER_PATH + "/exports/"; - - /** - * Folder where XML backups will be saved - */ - public static final String BACKUP_FOLDER_PATH = BASE_FOLDER_PATH + "/backups/"; + public static final String BASE_FOLDER_PATH = Environment.getExternalStorageDirectory() + "/" + BuildConfig.APPLICATION_ID; /** * Export options @@ -95,46 +91,75 @@ public abstract class Exporter { protected final ScheduledActionDbAdapter mScheduledActionDbAdapter; protected final PricesDbAdapter mPricesDbAdapter; protected final CommoditiesDbAdapter mCommoditiesDbAdapter; + protected final BudgetsDbAdapter mBudgetsDbAdapter; protected final Context mContext; private String mExportCacheFilePath; + /** + * Database being currently exported + */ + protected final SQLiteDatabase mDb; + + /** + * GUID of the book being exported + */ + protected String mBookUID; + public Exporter(ExportParams params, SQLiteDatabase db) { this.mExportParams = params; mContext = GnuCashApplication.getAppContext(); if (db == null) { - mAccountsDbAdapter = AccountsDbAdapter.getInstance(); - mTransactionsDbAdapter = TransactionsDbAdapter.getInstance(); - mSplitsDbAdapter = SplitsDbAdapter.getInstance(); + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); + mTransactionsDbAdapter = TransactionsDbAdapter.getInstance(); + mSplitsDbAdapter = SplitsDbAdapter.getInstance(); + mPricesDbAdapter = PricesDbAdapter.getInstance(); + mCommoditiesDbAdapter = CommoditiesDbAdapter.getInstance(); + mBudgetsDbAdapter = BudgetsDbAdapter.getInstance(); mScheduledActionDbAdapter = ScheduledActionDbAdapter.getInstance(); - mPricesDbAdapter = PricesDbAdapter.getInstance(); - mCommoditiesDbAdapter = CommoditiesDbAdapter.getInstance(); + mDb = GnuCashApplication.getActiveDb(); } else { - mSplitsDbAdapter = new SplitsDbAdapter(db); - mTransactionsDbAdapter = new TransactionsDbAdapter(db, mSplitsDbAdapter); - mAccountsDbAdapter = new AccountsDbAdapter(db, mTransactionsDbAdapter); - mScheduledActionDbAdapter = new ScheduledActionDbAdapter(db); - mPricesDbAdapter = new PricesDbAdapter(db); - mCommoditiesDbAdapter = new CommoditiesDbAdapter(db); + mDb = db; + mSplitsDbAdapter = new SplitsDbAdapter(db); + mTransactionsDbAdapter = new TransactionsDbAdapter(db, mSplitsDbAdapter); + mAccountsDbAdapter = new AccountsDbAdapter(db, mTransactionsDbAdapter); + mPricesDbAdapter = new PricesDbAdapter(db); + mCommoditiesDbAdapter = new CommoditiesDbAdapter(db); + RecurrenceDbAdapter recurrenceDbAdapter = new RecurrenceDbAdapter(db); + mBudgetsDbAdapter = new BudgetsDbAdapter(db, new BudgetAmountsDbAdapter(db), recurrenceDbAdapter); + mScheduledActionDbAdapter = new ScheduledActionDbAdapter(db, recurrenceDbAdapter); } + mBookUID = new File(mDb.getPath()).getName(); //this depends on the database file always having the name of the book GUID mExportCacheFilePath = null; mCacheDir = new File(mContext.getCacheDir(), params.getExportFormat().name()); mCacheDir.mkdir(); purgeDirectory(mCacheDir); } + /** + * Strings a string of any characters not allowed in a file name. + * All unallowed characters are replaced with an underscore + * @param inputName Raw file name input + * @return Sanitized file name + */ + public static String sanitizeFilename(String inputName) { + return inputName.replaceAll("[^a-zA-Z0-9-_\\.]", "_"); + } + /** * Builds a file name based on the current time stamp for the exported file + * @param format Format to use when exporting + * @param bookName Name of the book being exported. This name will be included in the generated file name * @return String containing the file name */ - public static String buildExportFilename(ExportFormat format) { + public static String buildExportFilename(ExportFormat format, String bookName) { return EXPORT_FILENAME_DATE_FORMAT.format(new Date(System.currentTimeMillis())) - + "_gnucash_export" + format.getExtension(); + + "_gnucash_export_" + sanitizeFilename(bookName) + format.getExtension(); } /** * Parses the name of an export file and returns the date of export - * @param filename Export file name generated by {@link #buildExportFilename(ExportFormat)} + * @param filename Export file name generated by {@link #buildExportFilename(ExportFormat,String)} * @return Date in milliseconds */ public static long getExportTime(String filename){ @@ -185,12 +210,42 @@ protected String getExportCacheFilePath(){ String cachePath = mCacheDir.getAbsolutePath(); if (!cachePath.endsWith("/")) cachePath += "/"; - mExportCacheFilePath = cachePath + buildExportFilename(mExportParams.getExportFormat()); + String bookName = BooksDbAdapter.getInstance().getAttribute(mBookUID, DatabaseSchema.BookEntry.COLUMN_DISPLAY_NAME); + mExportCacheFilePath = cachePath + buildExportFilename(mExportParams.getExportFormat(), bookName); } return mExportCacheFilePath; } + /** + * Returns that path to the export folder for the book with GUID {@code bookUID}. + * This is the folder where exports like QIF and OFX will be saved for access by external programs + * @param bookUID GUID of the book being exported. Each book has its own export path + * @return Absolute path to export folder for active book + */ + public static String getExportFolderPath(String bookUID){ + String path = BASE_FOLDER_PATH + "/" + bookUID + "/exports/"; + File file = new File(path); + if (!file.exists()) + file.mkdirs(); + return path; + } + + /** + * Returns the path to the backups folder for the book with GUID {@code bookUID} + * Each book has its own backup path + * + * @return Absolute path to backup folder for the book + */ + public static String getBackupFolderPath(String bookUID){ + String path = BASE_FOLDER_PATH + "/" + bookUID + "/backups/"; + File file = new File(path); + if (!file.exists()) + file.mkdirs(); + return path; + } + + /** * Returns the MIME type for this exporter. * @return MIME type as string diff --git a/app/src/main/java/org/gnucash/android/export/ofx/OfxExporter.java b/app/src/main/java/org/gnucash/android/export/ofx/OfxExporter.java index 850e8bfba..c7babfdb5 100644 --- a/app/src/main/java/org/gnucash/android/export/ofx/OfxExporter.java +++ b/app/src/main/java/org/gnucash/android/export/ofx/OfxExporter.java @@ -17,6 +17,7 @@ package org.gnucash.android.export.ofx; +import android.database.sqlite.SQLiteDatabase; import android.preference.PreferenceManager; import android.util.Log; @@ -24,7 +25,7 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.export.ExportParams; import org.gnucash.android.export.Exporter; import org.gnucash.android.model.Account; @@ -76,6 +77,16 @@ public OfxExporter(ExportParams params) { LOG_TAG = "OfxExporter"; } + /** + * Overloaded constructor. Initializes the export parameters and the database to export + * @param params Export options + * @param db SQLiteDatabase to export + */ + public OfxExporter(ExportParams params, SQLiteDatabase db){ + super(params, db); + LOG_TAG = "OfxExporter"; + } + /** * Converts all expenses into OFX XML format and adds them to the XML document * @param doc DOM document of the OFX expenses. @@ -113,7 +124,11 @@ private void generateOfx(Document doc, Element parent){ } } - // FIXME: Move code to generateExport() + /** + * Generate OFX export file from the transactions in the database + * @return String containing OFX export + * @throws ExporterException + */ private String generateOfxExport() throws ExporterException { mAccountsList = mAccountsDbAdapter.getExportableAccounts(mExportParams.getExportStartTime()); diff --git a/app/src/main/java/org/gnucash/android/export/qif/QifExporter.java b/app/src/main/java/org/gnucash/android/export/qif/QifExporter.java index f26c43f06..7b25d0cb4 100644 --- a/app/src/main/java/org/gnucash/android/export/qif/QifExporter.java +++ b/app/src/main/java/org/gnucash/android/export/qif/QifExporter.java @@ -18,9 +18,10 @@ import android.content.ContentValues; import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.export.ExportParams; import org.gnucash.android.export.Exporter; import org.gnucash.android.util.PreferencesHelper; @@ -50,9 +51,23 @@ * @author Yongxin Wang */ public class QifExporter extends Exporter{ + /** + * Initialize the exporter + * @param params Export options + */ public QifExporter(ExportParams params){ super(params, null); - LOG_TAG = "QifExporter"; + LOG_TAG = "OfxExporter"; + } + + /** + * Initialize the exporter + * @param params Options for export + * @param db SQLiteDatabase to export + */ + public QifExporter(ExportParams params, SQLiteDatabase db){ + super(params, db); + LOG_TAG = "OfxExporter"; } @Override diff --git a/app/src/main/java/org/gnucash/android/export/xml/GncXmlExporter.java b/app/src/main/java/org/gnucash/android/export/xml/GncXmlExporter.java index 4e259b5e4..7a3105ab4 100644 --- a/app/src/main/java/org/gnucash/android/export/xml/GncXmlExporter.java +++ b/app/src/main/java/org/gnucash/android/export/xml/GncXmlExporter.java @@ -23,18 +23,25 @@ import com.crashlytics.android.Crashlytics; -import org.gnucash.android.db.CommoditiesDbAdapter; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.RecurrenceDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.export.ExportFormat; import org.gnucash.android.export.ExportParams; import org.gnucash.android.export.Exporter; import org.gnucash.android.model.Account; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.BaseModel; +import org.gnucash.android.model.Book; import org.gnucash.android.model.Commodity; +import org.gnucash.android.model.Budget; +import org.gnucash.android.model.BudgetAmount; import org.gnucash.android.model.Money; import org.gnucash.android.model.PeriodType; +import org.gnucash.android.model.Recurrence; import org.gnucash.android.model.ScheduledAction; import org.gnucash.android.model.TransactionType; import org.gnucash.android.util.TimestampHelper; @@ -126,21 +133,21 @@ private void exportAccounts(XmlSerializer xmlSerializer) throws IOException { xmlSerializer.startTag(null, GncXmlHelper.TAG_ACCOUNT); xmlSerializer.attribute(null, GncXmlHelper.ATTR_KEY_VERSION, GncXmlHelper.BOOK_VERSION); // account name - xmlSerializer.startTag(null, GncXmlHelper.TAG_NAME); + xmlSerializer.startTag(null, GncXmlHelper.TAG_ACCT_NAME); xmlSerializer.text(cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.AccountEntry.COLUMN_NAME))); - xmlSerializer.endTag(null, GncXmlHelper.TAG_NAME); + xmlSerializer.endTag(null, GncXmlHelper.TAG_ACCT_NAME); // account guid xmlSerializer.startTag(null, GncXmlHelper.TAG_ACCT_ID); xmlSerializer.attribute(null, GncXmlHelper.ATTR_KEY_TYPE, GncXmlHelper.ATTR_VALUE_GUID); xmlSerializer.text(cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.AccountEntry.COLUMN_UID))); xmlSerializer.endTag(null, GncXmlHelper.TAG_ACCT_ID); // account type - xmlSerializer.startTag(null, GncXmlHelper.TAG_TYPE); + xmlSerializer.startTag(null, GncXmlHelper.TAG_ACCT_TYPE); String acct_type = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.AccountEntry.COLUMN_TYPE)); xmlSerializer.text(acct_type); - xmlSerializer.endTag(null, GncXmlHelper.TAG_TYPE); + xmlSerializer.endTag(null, GncXmlHelper.TAG_ACCT_TYPE); // commodity - xmlSerializer.startTag(null, GncXmlHelper.TAG_ACCOUNT_COMMODITY); + xmlSerializer.startTag(null, GncXmlHelper.TAG_ACCT_COMMODITY); xmlSerializer.startTag(null, GncXmlHelper.TAG_COMMODITY_SPACE); xmlSerializer.text("ISO4217"); xmlSerializer.endTag(null, GncXmlHelper.TAG_COMMODITY_SPACE); @@ -148,7 +155,7 @@ private void exportAccounts(XmlSerializer xmlSerializer) throws IOException { String acctCurrencyCode = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.AccountEntry.COLUMN_CURRENCY)); xmlSerializer.text(acctCurrencyCode); xmlSerializer.endTag(null, GncXmlHelper.TAG_COMMODITY_ID); - xmlSerializer.endTag(null, GncXmlHelper.TAG_ACCOUNT_COMMODITY); + xmlSerializer.endTag(null, GncXmlHelper.TAG_ACCT_COMMODITY); // commodity scu Commodity commodity = CommoditiesDbAdapter.getInstance().getCommodity(acctCurrencyCode); xmlSerializer.startTag(null, GncXmlHelper.TAG_COMMODITY_SCU); @@ -187,9 +194,9 @@ private void exportAccounts(XmlSerializer xmlSerializer) throws IOException { slotType.add(GncXmlHelper.ATTR_VALUE_STRING); slotValue.add(Boolean.toString(cursor.getInt(cursor.getColumnIndexOrThrow(DatabaseSchema.AccountEntry.COLUMN_FAVORITE)) != 0)); - xmlSerializer.startTag(null, GncXmlHelper.TAG_ACT_SLOTS); + xmlSerializer.startTag(null, GncXmlHelper.TAG_ACCT_SLOTS); exportSlots(xmlSerializer, slotKey, slotType, slotValue); - xmlSerializer.endTag(null, GncXmlHelper.TAG_ACT_SLOTS); + xmlSerializer.endTag(null, GncXmlHelper.TAG_ACCT_SLOTS); // parent uid String parentUID = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.AccountEntry.COLUMN_PARENT_ACCOUNT_UID)); @@ -218,20 +225,20 @@ private void exportTemplateAccounts(XmlSerializer xmlSerializer, Collection 0; - xmlSerializer.text(enabled ? "y" : "n"); + xmlSerializer.text(scheduledAction.isEnabled() ? "y" : "n"); xmlSerializer.endTag(null, GncXmlHelper.TAG_SX_ENABLED); xmlSerializer.startTag(null, GncXmlHelper.TAG_SX_AUTO_CREATE); - xmlSerializer.text("n"); //we do not want transactions auto-created on the desktop. + xmlSerializer.text(scheduledAction.shouldAutoCreate() ? "y" : "n"); xmlSerializer.endTag(null, GncXmlHelper.TAG_SX_AUTO_CREATE); xmlSerializer.startTag(null, GncXmlHelper.TAG_SX_AUTO_CREATE_NOTIFY); - xmlSerializer.text("n"); //TODO: if we ever support notifying before creating a scheduled transaction, then update this + xmlSerializer.text(scheduledAction.shouldAutoNotify() ? "y" : "n"); xmlSerializer.endTag(null, GncXmlHelper.TAG_SX_AUTO_CREATE_NOTIFY); xmlSerializer.startTag(null, GncXmlHelper.TAG_SX_ADVANCE_CREATE_DAYS); - xmlSerializer.text("0"); + xmlSerializer.text(Integer.toString(scheduledAction.getAdvanceCreateDays())); xmlSerializer.endTag(null, GncXmlHelper.TAG_SX_ADVANCE_CREATE_DAYS); xmlSerializer.startTag(null, GncXmlHelper.TAG_SX_ADVANCE_REMIND_DAYS); - xmlSerializer.text("0"); + xmlSerializer.text(Integer.toString(scheduledAction.getAdvanceNotifyDays())); xmlSerializer.endTag(null, GncXmlHelper.TAG_SX_ADVANCE_REMIND_DAYS); xmlSerializer.startTag(null, GncXmlHelper.TAG_SX_INSTANCE_COUNT); String scheduledActionUID = cursor.getString(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_UID)); @@ -576,22 +588,15 @@ private void exportScheduledTransactions(XmlSerializer xmlSerializer) throws IOE xmlSerializer.text(accountUID.getUID()); xmlSerializer.endTag(null, GncXmlHelper.TAG_SX_TEMPL_ACCOUNT); + //// FIXME: 11.10.2015 Retrieve the information for this section from the recurrence table xmlSerializer.startTag(null, GncXmlHelper.TAG_SX_SCHEDULE); - xmlSerializer.startTag(null, GncXmlHelper.TAG_RECURRENCE); + xmlSerializer.startTag(null, GncXmlHelper.TAG_GNC_RECURRENCE); xmlSerializer.attribute(null, GncXmlHelper.ATTR_KEY_VERSION, GncXmlHelper.RECURRENCE_VERSION); - long period = cursor.getLong(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_PERIOD)); - PeriodType periodType = ScheduledAction.getPeriodType(period); - xmlSerializer.startTag(null, GncXmlHelper.TAG_RX_MULT); - xmlSerializer.text(String.valueOf(periodType.getMultiplier())); - xmlSerializer.endTag(null, GncXmlHelper.TAG_RX_MULT); - xmlSerializer.startTag(null, GncXmlHelper.TAG_RX_PERIOD_TYPE); - xmlSerializer.text(periodType.name().toLowerCase()); - xmlSerializer.endTag(null, GncXmlHelper.TAG_RX_PERIOD_TYPE); - - long recurrenceStartTime = cursor.getLong(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_START_TIME)); - serializeDate(xmlSerializer, GncXmlHelper.TAG_RX_START, recurrenceStartTime); - - xmlSerializer.endTag(null, GncXmlHelper.TAG_RECURRENCE); + + String recurrenceUID = cursor.getString(cursor.getColumnIndexOrThrow(ScheduledActionEntry.COLUMN_RECURRENCE_UID)); + Recurrence recurrence = RecurrenceDbAdapter.getInstance().getRecord(recurrenceUID); + exportRecurrence(xmlSerializer, recurrence); + xmlSerializer.endTag(null, GncXmlHelper.TAG_GNC_RECURRENCE); xmlSerializer.endTag(null, GncXmlHelper.TAG_SX_SCHEDULE); xmlSerializer.endTag(null, GncXmlHelper.TAG_SCHEDULED_ACTION); @@ -614,10 +619,10 @@ private void serializeDate(XmlSerializer xmlSerializer, String tag, long timeMil xmlSerializer.endTag(null, tag); } - private void exportCommodity(XmlSerializer xmlSerializer, List currencies) throws IOException { + private void exportCommodities(XmlSerializer xmlSerializer, List currencies) throws IOException { for (Currency currency : currencies) { xmlSerializer.startTag(null, GncXmlHelper.TAG_COMMODITY); - xmlSerializer.attribute(null, GncXmlHelper.ATTR_KEY_VERSION, "2.0.0"); + xmlSerializer.attribute(null, GncXmlHelper.ATTR_KEY_VERSION, GncXmlHelper.BOOK_VERSION); xmlSerializer.startTag(null, GncXmlHelper.TAG_COMMODITY_SPACE); xmlSerializer.text("ISO4217"); xmlSerializer.endTag(null, GncXmlHelper.TAG_COMMODITY_SPACE); @@ -689,6 +694,85 @@ private void exportPrices(XmlSerializer xmlSerializer) throws IOException { xmlSerializer.endTag(null, GncXmlHelper.TAG_PRICEDB); } + /** + * Exports the recurrence to GnuCash XML, except the recurrence tags itself i.e. the actual recurrence attributes only + *

This is because there are different recurrence start tags for transactions and budgets.
+ * So make sure to write the recurrence start/closing tags before/after calling this method.

+ * @param xmlSerializer XML serializer + * @param recurrence Recurrence object + */ + private void exportRecurrence(XmlSerializer xmlSerializer, Recurrence recurrence) throws IOException{ + PeriodType periodType = recurrence.getPeriodType(); + xmlSerializer.startTag(null, GncXmlHelper.TAG_RX_MULT); + xmlSerializer.text(String.valueOf(periodType.getMultiplier())); + xmlSerializer.endTag(null, GncXmlHelper.TAG_RX_MULT); + xmlSerializer.startTag(null, GncXmlHelper.TAG_RX_PERIOD_TYPE); + xmlSerializer.text(periodType.name().toLowerCase()); + xmlSerializer.endTag(null, GncXmlHelper.TAG_RX_PERIOD_TYPE); + + long recurrenceStartTime = recurrence.getPeriodStart().getTime(); + serializeDate(xmlSerializer, GncXmlHelper.TAG_RX_START, recurrenceStartTime); + } + + private void exportBudgets(XmlSerializer xmlSerializer) throws IOException { + Cursor cursor = mBudgetsDbAdapter.fetchAllRecords(); + while(cursor.moveToNext()) { + Budget budget = mBudgetsDbAdapter.buildModelInstance(cursor); + xmlSerializer.startTag(null, GncXmlHelper.TAG_BUDGET); + xmlSerializer.attribute(null, GncXmlHelper.ATTR_KEY_VERSION, GncXmlHelper.BOOK_VERSION); + xmlSerializer.startTag(null, GncXmlHelper.TAG_BUDGET_ID); + xmlSerializer.attribute(null, GncXmlHelper.ATTR_KEY_TYPE, GncXmlHelper.ATTR_VALUE_GUID); + xmlSerializer.text(budget.getUID()); + xmlSerializer.endTag(null, GncXmlHelper.TAG_BUDGET_ID); + xmlSerializer.startTag(null, GncXmlHelper.TAG_BUDGET_NAME); + xmlSerializer.text(budget.getName()); + xmlSerializer.endTag(null, GncXmlHelper.TAG_BUDGET_NAME); + xmlSerializer.startTag(null, GncXmlHelper.TAG_BUDGET_DESCRIPTION); + xmlSerializer.text(budget.getDescription() == null ? "" : budget.getDescription()); + xmlSerializer.endTag(null, GncXmlHelper.TAG_BUDGET_DESCRIPTION); + xmlSerializer.startTag(null, GncXmlHelper.TAG_BUDGET_NUM_PERIODS); + xmlSerializer.text(Long.toString(budget.getNumberOfPeriods())); + xmlSerializer.endTag(null, GncXmlHelper.TAG_BUDGET_NUM_PERIODS); + xmlSerializer.startTag(null, GncXmlHelper.TAG_BUDGET_RECURRENCE); + exportRecurrence(xmlSerializer, budget.getRecurrence()); + xmlSerializer.endTag(null, GncXmlHelper.TAG_BUDGET_RECURRENCE); + + //export budget slots + ArrayList slotKey = new ArrayList<>(); + ArrayList slotType = new ArrayList<>(); + ArrayList slotValue = new ArrayList<>(); + + xmlSerializer.startTag(null, GncXmlHelper.TAG_BUDGET_SLOTS); + for (BudgetAmount budgetAmount : budget.getExpandedBudgetAmounts()) { + xmlSerializer.startTag(null, GncXmlHelper.TAG_SLOT); + xmlSerializer.startTag(null, GncXmlHelper.TAG_SLOT_KEY); + xmlSerializer.text(budgetAmount.getAccountUID()); + xmlSerializer.endTag(null, GncXmlHelper.TAG_SLOT_KEY); + + Money amount = budgetAmount.getAmount(); + slotKey.clear(); + slotType.clear(); + slotValue.clear(); + for (int period = 0; period < budget.getNumberOfPeriods(); period++) { + slotKey.add(String.valueOf(period)); + slotType.add(GncXmlHelper.ATTR_VALUE_NUMERIC); + slotValue.add(amount.getNumerator() + "/" + amount.getDenominator()); + } + //budget slots + + xmlSerializer.startTag(null, GncXmlHelper.TAG_SLOT_VALUE); + xmlSerializer.attribute(null, GncXmlHelper.ATTR_KEY_TYPE, GncXmlHelper.ATTR_VALUE_FRAME); + exportSlots(xmlSerializer, slotKey, slotType, slotValue); + xmlSerializer.endTag(null, GncXmlHelper.TAG_SLOT_VALUE); + xmlSerializer.endTag(null, GncXmlHelper.TAG_SLOT); + } + + xmlSerializer.endTag(null, GncXmlHelper.TAG_BUDGET_SLOTS); + xmlSerializer.endTag(null, GncXmlHelper.TAG_BUDGET); + } + cursor.close(); + } + @Override public List generateExport() throws ExporterException { OutputStreamWriter writer = null; @@ -726,7 +810,7 @@ public List generateExport() throws ExporterException { public void generateExport(Writer writer) throws ExporterException { try { String[] namespaces = new String[]{"gnc", "act", "book", "cd", "cmdty", "price", "slot", - "split", "trn", "ts", "sx", "recurrence"}; + "split", "trn", "ts", "sx", "bgt", "recurrence"}; XmlSerializer xmlSerializer = XmlPullParserFactory.newInstance().newSerializer(); xmlSerializer.setOutput(writer); xmlSerializer.startDocument("utf-8", true); @@ -778,7 +862,7 @@ public void generateExport(Writer writer) throws ExporterException { xmlSerializer.endTag(null, GncXmlHelper.TAG_COUNT_DATA); } // export the commodities used in the DB - exportCommodity(xmlSerializer, currencies); + exportCommodities(xmlSerializer, currencies); // prices if (priceCount > 0) { exportPrices(xmlSerializer); @@ -797,6 +881,9 @@ public void generateExport(Writer writer) throws ExporterException { //scheduled actions exportScheduledTransactions(xmlSerializer); + //budgets + exportBudgets(xmlSerializer); + xmlSerializer.endTag(null, GncXmlHelper.TAG_BOOK); xmlSerializer.endTag(null, GncXmlHelper.TAG_ROOT); xmlSerializer.endDocument(); @@ -816,12 +903,13 @@ public String getExportMimeType(){ } /** - * Creates a backup of current database contents to the directory {@link Exporter#BACKUP_FOLDER_PATH} + * Creates a backup of current database contents to the directory {@link Exporter#getBackupFolderPath(String)} * @return {@code true} if backup was successful, {@code false} otherwise */ public static boolean createBackup(){ try { - FileOutputStream fileOutputStream = new FileOutputStream(getBackupFilePath()); + String bookUID = BooksDbAdapter.getInstance().getActiveBookUID(); + FileOutputStream fileOutputStream = new FileOutputStream(getBackupFilePath(bookUID)); BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream); GZIPOutputStream gzipOutputStream = new GZIPOutputStream(bufferedOutputStream); OutputStreamWriter writer = new OutputStreamWriter(gzipOutputStream); @@ -838,13 +926,14 @@ public static boolean createBackup(){ } /** - * Returns the full path of a file to make database backup. + * Returns the full path of a file to make database backup of the specified book * Backups are done in XML format and are zipped (with ".zip" extension). + * @param bookUID GUID of the book * @return the file path for backups of the database. - * @see #BACKUP_FOLDER_PATH + * @see #getBackupFolderPath(String) */ - private static String getBackupFilePath(){ - new File(BACKUP_FOLDER_PATH).mkdirs(); - return BACKUP_FOLDER_PATH + buildExportFilename(ExportFormat.XML) + ".zip"; + private static String getBackupFilePath(String bookUID){ + Book book = BooksDbAdapter.getInstance().getRecord(bookUID); + return Exporter.getBackupFolderPath(book.getUID()) + buildExportFilename(ExportFormat.XML, book.getDisplayName()) + ".zip"; } } diff --git a/app/src/main/java/org/gnucash/android/export/xml/GncXmlHelper.java b/app/src/main/java/org/gnucash/android/export/xml/GncXmlHelper.java index 6a13908ca..f1eee4af8 100644 --- a/app/src/main/java/org/gnucash/android/export/xml/GncXmlHelper.java +++ b/app/src/main/java/org/gnucash/android/export/xml/GncXmlHelper.java @@ -17,9 +17,6 @@ package org.gnucash.android.export.xml; -import android.support.annotation.NonNull; - -import org.gnucash.android.db.CommoditiesDbAdapter; import org.gnucash.android.model.Commodity; import org.gnucash.android.ui.transaction.TransactionFormFragment; @@ -28,11 +25,8 @@ import java.text.NumberFormat; import java.text.ParseException; import java.text.SimpleDateFormat; -import java.util.Currency; import java.util.Date; import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Collection of helper tags and methods for Gnc XML export @@ -50,6 +44,7 @@ public abstract class GncXmlHelper { public static final String ATTR_VALUE_NUMERIC = "numeric"; public static final String ATTR_VALUE_GUID = "guid"; public static final String ATTR_VALUE_BOOK = "book"; + public static final String ATTR_VALUE_FRAME = "frame"; public static final String TAG_GDATE = "gdate"; /* @@ -61,18 +56,20 @@ public abstract class GncXmlHelper { public static final String TAG_COUNT_DATA = "gnc:count-data"; public static final String TAG_COMMODITY = "gnc:commodity"; - public static final String TAG_NAME = "act:name"; - public static final String TAG_ACCT_ID = "act:id"; - public static final String TAG_TYPE = "act:type"; public static final String TAG_COMMODITY_ID = "cmdty:id"; public static final String TAG_COMMODITY_SPACE = "cmdty:space"; - public static final String TAG_ACCOUNT_COMMODITY = "act:commodity"; + + public static final String TAG_ACCOUNT = "gnc:account"; + public static final String TAG_ACCT_NAME = "act:name"; + public static final String TAG_ACCT_ID = "act:id"; + public static final String TAG_ACCT_TYPE = "act:type"; + public static final String TAG_ACCT_COMMODITY = "act:commodity"; public static final String TAG_COMMODITY_SCU = "act:commodity-scu"; public static final String TAG_PARENT_UID = "act:parent"; - public static final String TAG_ACCOUNT = "gnc:account"; + public static final String TAG_SLOT_KEY = "slot:key"; public static final String TAG_SLOT_VALUE = "slot:value"; - public static final String TAG_ACT_SLOTS = "act:slots"; + public static final String TAG_ACCT_SLOTS = "act:slots"; public static final String TAG_SLOT = "slot"; public static final String TAG_ACCT_DESCRIPTION = "act:description"; @@ -91,21 +88,27 @@ public abstract class GncXmlHelper { public static final String TAG_SPLIT_ID = "split:id"; public static final String TAG_SPLIT_MEMO = "split:memo"; public static final String TAG_RECONCILED_STATE = "split:reconciled-state"; + public static final String TAG_RECONCILED_DATE = "split:recondiled-date"; public static final String TAG_SPLIT_ACCOUNT = "split:account"; public static final String TAG_SPLIT_VALUE = "split:value"; public static final String TAG_SPLIT_QUANTITY = "split:quantity"; public static final String TAG_SPLIT_SLOTS = "split:slots"; - public static final String TAG_PRICEDB = "gnc:pricedb"; - public static final String TAG_PRICE = "price"; - public static final String TAG_PRICE_ID = "price:id"; - public static final String TAG_PRICE_COMMODITY = "price:commodity"; - public static final String TAG_PRICE_CURRENCY = "price:currency"; - public static final String TAG_PRICE_TIME = "price:time"; - public static final String TAG_PRICE_SOURCE = "price:source"; - public static final String TAG_PRICE_TYPE = "price:type"; - public static final String TAG_PRICE_VALUE = "price:value"; + public static final String TAG_PRICEDB = "gnc:pricedb"; + public static final String TAG_PRICE = "price"; + public static final String TAG_PRICE_ID = "price:id"; + public static final String TAG_PRICE_COMMODITY = "price:commodity"; + public static final String TAG_PRICE_CURRENCY = "price:currency"; + public static final String TAG_PRICE_TIME = "price:time"; + public static final String TAG_PRICE_SOURCE = "price:source"; + public static final String TAG_PRICE_TYPE = "price:type"; + public static final String TAG_PRICE_VALUE = "price:value"; + /** + * Periodicity of the recurrence. + *

Only currently used for reading old backup files. May be removed in the future.

+ * @deprecated Use {@link #TAG_GNC_RECURRENCE} instead + */ @Deprecated public static final String TAG_RECURRENCE_PERIOD = "trn:recurrence_period"; @@ -126,12 +129,22 @@ public abstract class GncXmlHelper { public static final String TAG_SX_TAG = "sx:tag"; public static final String TAG_SX_TEMPL_ACCOUNT = "sx:templ-acct"; public static final String TAG_SX_SCHEDULE = "sx:schedule"; - public static final String TAG_RECURRENCE = "gnc:recurrence"; + public static final String TAG_GNC_RECURRENCE = "gnc:recurrence"; + public static final String TAG_RX_MULT = "recurrence:mult"; public static final String TAG_RX_PERIOD_TYPE = "recurrence:period_type"; public static final String TAG_RX_START = "recurrence:start"; + public static final String TAG_BUDGET = "gnc:budget"; + public static final String TAG_BUDGET_ID = "bgt:id"; + public static final String TAG_BUDGET_NAME = "bgt:name"; + public static final String TAG_BUDGET_DESCRIPTION = "bgt:description"; + public static final String TAG_BUDGET_NUM_PERIODS = "bgt:num-periods"; + public static final String TAG_BUDGET_RECURRENCE = "bgt:recurrence"; + public static final String TAG_BUDGET_SLOTS = "bgt:slots"; + + public static final String RECURRENCE_VERSION = "1.0.0"; public static final String BOOK_VERSION = "2.0.0"; public static final SimpleDateFormat TIME_FORMATTER = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US); @@ -198,6 +211,7 @@ public static BigDecimal parseSplitAmount(String amountString) throws ParseExcep * @param amount Split amount as BigDecimal * @param commodity Commodity of the transaction * @return Formatted split amount + * @deprecated Just use the values for numerator and denominator which are saved in the database */ public static String formatSplitAmount(BigDecimal amount, Commodity commodity){ int denomInt = commodity.getSmallestFraction(); diff --git a/app/src/main/java/org/gnucash/android/importer/CommoditiesXmlHandler.java b/app/src/main/java/org/gnucash/android/importer/CommoditiesXmlHandler.java index 34e75e032..977328fa6 100644 --- a/app/src/main/java/org/gnucash/android/importer/CommoditiesXmlHandler.java +++ b/app/src/main/java/org/gnucash/android/importer/CommoditiesXmlHandler.java @@ -18,7 +18,8 @@ import android.database.sqlite.SQLiteDatabase; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; import org.gnucash.android.model.Commodity; import org.xml.sax.Attributes; import org.xml.sax.SAXException; @@ -81,6 +82,6 @@ public void startElement(String uri, String localName, String qName, Attributes @Override public void endDocument() throws SAXException { - mCommoditiesDbAdapter.bulkAddRecords(mCommodities); + mCommoditiesDbAdapter.bulkAddRecords(mCommodities, DatabaseAdapter.UpdateMethod.insert); } } diff --git a/app/src/main/java/org/gnucash/android/importer/GncXmlHandler.java b/app/src/main/java/org/gnucash/android/importer/GncXmlHandler.java index 3a03b7375..1d7938eb8 100644 --- a/app/src/main/java/org/gnucash/android/importer/GncXmlHandler.java +++ b/app/src/main/java/org/gnucash/android/importer/GncXmlHandler.java @@ -18,26 +18,36 @@ package org.gnucash.android.importer; import android.database.sqlite.SQLiteDatabase; -import android.support.annotation.Nullable; +import android.support.annotation.NonNull; import android.util.Log; import com.crashlytics.android.Crashlytics; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.CommoditiesDbAdapter; -import org.gnucash.android.db.PricesDbAdapter; -import org.gnucash.android.db.ScheduledActionDbAdapter; -import org.gnucash.android.db.SplitsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.DatabaseHelper; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.BudgetAmountsDbAdapter; +import org.gnucash.android.db.adapter.BudgetsDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.PricesDbAdapter; +import org.gnucash.android.db.adapter.RecurrenceDbAdapter; +import org.gnucash.android.db.adapter.ScheduledActionDbAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.export.xml.GncXmlHelper; import org.gnucash.android.model.Account; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.BaseModel; +import org.gnucash.android.model.Book; +import org.gnucash.android.model.Budget; +import org.gnucash.android.model.BudgetAmount; import org.gnucash.android.model.Commodity; import org.gnucash.android.model.Money; import org.gnucash.android.model.PeriodType; import org.gnucash.android.model.Price; +import org.gnucash.android.model.Recurrence; import org.gnucash.android.model.ScheduledAction; import org.gnucash.android.model.Split; import org.gnucash.android.model.Transaction; @@ -198,6 +208,13 @@ public class GncXmlHandler extends DefaultHandler { */ List mScheduledActionsList; + /** + * List of budgets which have been parsed from XML + */ + List mBudgetList; + Budget mBudget; + Recurrence mRecurrence; + BudgetAmount mBudgetAmount; boolean mInColorSlot = false; boolean mInPlaceHolderSlot = false; @@ -216,6 +233,15 @@ public class GncXmlHandler extends DefaultHandler { boolean mIsScheduledEnd = false; boolean mIsLastRun = false; boolean mIsRecurrenceStart = false; + boolean mInBudgetSlot = false; + + /** + * Saves the attribute of the slot tag + * Used for determining where we are in the budget amounts + */ + String mSlotTagAttribute = null; + + String mBudgetAmountAccountUID = null; /** * Multiplier for the recurrence period type. e.g. period type of week and multiplier of 2 means bi-weekly @@ -251,36 +277,33 @@ public class GncXmlHandler extends DefaultHandler { private Map mCurrencyCount; + private BudgetsDbAdapter mBudgetsDbAdapter; + private Book mBook; + private SQLiteDatabase mainDb; + /** * Creates a handler for handling XML stream events when parsing the XML backup file */ public GncXmlHandler() { - init(null); + init(); } /** - * Overloaded constructor. - * Useful when reading XML into an already open database connection e.g. during migration - * @param db SQLite database object + * Initialize the GnuCash XML handler */ - public GncXmlHandler(SQLiteDatabase db) { - init(db); - } + private void init() { + mBook = new Book(); + + DatabaseHelper databaseHelper = new DatabaseHelper(GnuCashApplication.getAppContext(), mBook.getUID()); + mainDb = databaseHelper.getWritableDatabase(); + mTransactionsDbAdapter = new TransactionsDbAdapter(mainDb, new SplitsDbAdapter(mainDb)); + mAccountsDbAdapter = new AccountsDbAdapter(mainDb, mTransactionsDbAdapter); + RecurrenceDbAdapter recurrenceDbAdapter = new RecurrenceDbAdapter(mainDb); + mScheduledActionsDbAdapter = new ScheduledActionDbAdapter(mainDb, recurrenceDbAdapter); + mCommoditiesDbAdapter = new CommoditiesDbAdapter(mainDb); + mPricesDbAdapter = new PricesDbAdapter(mainDb); + mBudgetsDbAdapter = new BudgetsDbAdapter(mainDb, new BudgetAmountsDbAdapter(mainDb), recurrenceDbAdapter); - private void init(@Nullable SQLiteDatabase db) { - if (db == null) { - mAccountsDbAdapter = AccountsDbAdapter.getInstance(); - mTransactionsDbAdapter = TransactionsDbAdapter.getInstance(); - mScheduledActionsDbAdapter = ScheduledActionDbAdapter.getInstance(); - mCommoditiesDbAdapter = CommoditiesDbAdapter.getInstance(); - mPricesDbAdapter = PricesDbAdapter.getInstance(); - } else { - mTransactionsDbAdapter = new TransactionsDbAdapter(db, new SplitsDbAdapter(db)); - mAccountsDbAdapter = new AccountsDbAdapter(db, mTransactionsDbAdapter); - mScheduledActionsDbAdapter = new ScheduledActionDbAdapter(db); - mCommoditiesDbAdapter = new CommoditiesDbAdapter(db); - mPricesDbAdapter = new PricesDbAdapter(db); - } mContent = new StringBuilder(); @@ -288,6 +311,7 @@ private void init(@Nullable SQLiteDatabase db) { mAccountMap = new HashMap<>(); mTransactionList = new ArrayList<>(); mScheduledActionsList = new ArrayList<>(); + mBudgetList = new ArrayList<>(); mTemplatAccountList = new ArrayList<>(); mTemplateTransactions = new ArrayList<>(); @@ -353,11 +377,33 @@ public void startElement(String uri, String localName, mPriceCommodity = true; mISO4217Currency = false; break; + + case GncXmlHelper.TAG_BUDGET: + mBudget = new Budget(); + break; + + case GncXmlHelper.TAG_GNC_RECURRENCE: + case GncXmlHelper.TAG_BUDGET_RECURRENCE: + mRecurrenceMultiplier = 1; + mRecurrence = new Recurrence(PeriodType.MONTH); + break; + case GncXmlHelper.TAG_BUDGET_SLOTS: + mInBudgetSlot = true; + break; + case GncXmlHelper.TAG_SLOT: + if (mInBudgetSlot){ + mBudgetAmount = new BudgetAmount(mBudget.getUID(), mBudgetAmountAccountUID); + } + break; + case GncXmlHelper.TAG_SLOT_VALUE: + mSlotTagAttribute = attributes.getValue(GncXmlHelper.ATTR_KEY_TYPE); + break; } } @Override public void endElement(String uri, String localName, String qualifiedName) throws SAXException { + // FIXME: 22.10.2015 First parse the number of accounts/transactions and use the numer to init the array lists String characterString = mContent.toString().trim(); if (mIgnoreElement != null) { @@ -370,14 +416,14 @@ public void endElement(String uri, String localName, String qualifiedName) throw } switch (qualifiedName) { - case GncXmlHelper.TAG_NAME: + case GncXmlHelper.TAG_ACCT_NAME: mAccount.setName(characterString); mAccount.setFullName(characterString); break; case GncXmlHelper.TAG_ACCT_ID: mAccount.setUID(characterString); break; - case GncXmlHelper.TAG_TYPE: + case GncXmlHelper.TAG_ACCT_TYPE: AccountType accountType = AccountType.valueOf(characterString); mAccount.setAccountType(accountType); mAccount.setHidden(accountType == AccountType.ROOT); //flag root account as hidden @@ -393,7 +439,13 @@ public void endElement(String uri, String localName, String qualifiedName) throw case GncXmlHelper.TAG_COMMODITY_ID: String currencyCode = mISO4217Currency ? characterString : NO_CURRENCY_CODE; if (mAccount != null) { - mAccount.setCurrencyCode(currencyCode); + Commodity commodity = mCommoditiesDbAdapter.getCommodity(currencyCode); + if (commodity != null) { + mAccount.setCommodity(commodity); + } else { + throw new SAXException("Commodity with '" + currencyCode + + "' currency code not found in the database"); + } if (mCurrencyCount.containsKey(currencyCode)) { mCurrencyCount.put(currencyCode, mCurrencyCount.get(currencyCode) + 1); } else { @@ -421,7 +473,7 @@ public void endElement(String uri, String localName, String qualifiedName) throw mAccount.setParentUID(characterString); break; case GncXmlHelper.TAG_ACCOUNT: - if (!mInTemplates) { //we ignore template accounts, we have no use for them + if (!mInTemplates) { //we ignore template accounts, we have no use for them. FIXME someday and import the templates too mAccountList.add(mAccount); mAccountMap.put(mAccount.getUID(), mAccount); // check ROOT account @@ -438,6 +490,8 @@ public void endElement(String uri, String localName, String qualifiedName) throw mISO4217Currency = false; } break; + case GncXmlHelper.TAG_SLOT: + break; case GncXmlHelper.TAG_SLOT_KEY: switch (characterString) { case GncXmlHelper.KEY_PLACEHOLDER: @@ -468,6 +522,12 @@ public void endElement(String uri, String localName, String qualifiedName) throw mInDebitNumericSlot = true; break; } + if (mInBudgetSlot && mBudgetAmountAccountUID == null){ + mBudgetAmountAccountUID = characterString; + mBudgetAmount.setAccountUID(characterString); + } else if (mInBudgetSlot){ + mBudgetAmount.setPeriodNum(Long.parseLong(characterString)); + } break; case GncXmlHelper.TAG_SLOT_VALUE: if (mInPlaceHolderSlot) { @@ -516,8 +576,29 @@ public void endElement(String uri, String localName, String qualifiedName) throw handleEndOfTemplateNumericSlot(characterString, TransactionType.CREDIT); } else if (mInTemplates && mInDebitNumericSlot) { handleEndOfTemplateNumericSlot(characterString, TransactionType.DEBIT); + } else if (mInBudgetSlot){ + if (mSlotTagAttribute.equals(GncXmlHelper.ATTR_VALUE_NUMERIC)) { + try { + BigDecimal bigDecimal = GncXmlHelper.parseSplitAmount(characterString); + //currency doesn't matter since we don't persist it in the budgets table + mBudgetAmount.setAmount(new Money(bigDecimal, Commodity.DEFAULT_COMMODITY)); + } catch (ParseException e) { + mBudgetAmount.setAmount(Money.getZeroInstance()); //just put zero, in case it was a formula we couldnt parse + e.printStackTrace(); + } finally { + mBudget.addBudgetAmount(mBudgetAmount); + } + mSlotTagAttribute = GncXmlHelper.ATTR_VALUE_FRAME; + } else { + mBudgetAmountAccountUID = null; + } } break; + + case GncXmlHelper.TAG_BUDGET_SLOTS: + mInBudgetSlot = false; + break; + //================ PROCESSING OF TRANSACTION TAGS ===================================== case GncXmlHelper.TAG_TRX_ID: mTransaction.setUID(characterString); @@ -594,19 +675,20 @@ public void endElement(String uri, String localName, String qualifiedName) throw //the split amount uses the account currency mSplit.setQuantity(new Money(mQuantity, getCommodityForAccount(characterString))); //the split value uses the transaction currency - mSplit.setValue(new Money(mValue, Commodity.getInstance(mTransaction.getCurrency().getCurrencyCode()))); + mSplit.setValue(new Money(mValue, mCommoditiesDbAdapter.getCommodity(mTransaction.getCurrency().getCurrencyCode()))); mSplit.setAccountUID(characterString); } else { if (!mIgnoreTemplateTransaction) mTemplateAccountToTransactionMap.put(characterString, mTransaction.getUID()); } break; + //todo: import split reconciled state and date case GncXmlHelper.TAG_TRN_SPLIT: mTransaction.addSplit(mSplit); break; case GncXmlHelper.TAG_TRANSACTION: mTransaction.setTemplate(mInTemplates); - Split imbSplit = mTransaction.getAutoBalanceSplit(); + Split imbSplit = mTransaction.createAutoBalanceSplit(); if (imbSplit != null) { mAutoBalanceSplits.add(imbSplit); } @@ -645,8 +727,9 @@ public void endElement(String uri, String localName, String qualifiedName) throw case GncXmlHelper.TAG_SX_AUTO_CREATE: mScheduledAction.setAutoCreate(characterString.equals("y")); break; + //todo: export auto_notify, advance_create, advance_notify case GncXmlHelper.TAG_SX_NUM_OCCUR: - mScheduledAction.setTotalFrequency(Integer.parseInt(characterString)); + mScheduledAction.setTotalPlannedExecutionCount(Integer.parseInt(characterString)); break; case GncXmlHelper.TAG_RX_MULT: mRecurrenceMultiplier = Integer.parseInt(characterString); @@ -655,8 +738,7 @@ public void endElement(String uri, String localName, String qualifiedName) throw try { PeriodType periodType = PeriodType.valueOf(characterString.toUpperCase()); periodType.setMultiplier(mRecurrenceMultiplier); - if (mScheduledAction != null) //there might be recurrence tags for bugdets and other stuff - mScheduledAction.setPeriod(periodType); + mRecurrence.setPeriodType(periodType); } catch (IllegalArgumentException ex){ //the period type constant is not supported String msg = "Unsupported period constant: " + characterString; Log.e(LOG_TAG, msg); @@ -683,7 +765,7 @@ public void endElement(String uri, String localName, String qualifiedName) throw } if (mIsRecurrenceStart && mScheduledAction != null){ - mScheduledAction.setStartTime(date); + mRecurrence.setPeriodStart(new Timestamp(date)); mIsRecurrenceStart = false; } } catch (ParseException e) { @@ -701,13 +783,18 @@ public void endElement(String uri, String localName, String qualifiedName) throw mScheduledAction.setActionUID(BaseModel.generateUID()); } break; + case GncXmlHelper.TAG_GNC_RECURRENCE: + if (mScheduledAction != null){ + mScheduledAction.setRecurrence(mRecurrence); + } + break; + case GncXmlHelper.TAG_SCHEDULED_ACTION: if (mScheduledAction.getActionUID() != null && !mIgnoreScheduledAction) { mScheduledActionsList.add(mScheduledAction); int count = generateMissedScheduledTransactions(mScheduledAction); Log.i(LOG_TAG, String.format("Generated %d transactions from scheduled action", count)); } - mRecurrenceMultiplier = 1; //reset it, even though it will be parsed from XML each time mIgnoreScheduledAction = false; break; // price table @@ -746,6 +833,28 @@ public void endElement(String uri, String localName, String qualifiedName) throw mPrice = null; } break; + + case GncXmlHelper.TAG_BUDGET: + if (mBudget.getBudgetAmounts().size() > 0) //ignore if no budget amounts exist for the budget + mBudgetList.add(mBudget); + break; + + case GncXmlHelper.TAG_BUDGET_NAME: + mBudget.setName(characterString); + break; + + case GncXmlHelper.TAG_BUDGET_DESCRIPTION: + mBudget.setDescription(characterString); + break; + + case GncXmlHelper.TAG_BUDGET_NUM_PERIODS: + mBudget.setNumberOfPeriods(Long.parseLong(characterString)); + break; + + case GncXmlHelper.TAG_BUDGET_RECURRENCE: + mBudget.setRecurrence(mRecurrence); + break; + } //reset the accumulated characters @@ -790,10 +899,11 @@ public void endDocument() throws SAXException { // Set the account for created balancing splits to correct imbalance accounts for (Split split: mAutoBalanceSplits) { + // XXX: yes, getAccountUID() returns a currency code in this case (see Transaction.createAutoBalanceSplit()) String currencyCode = split.getAccountUID(); Account imbAccount = mapImbalanceAccount.get(currencyCode); if (imbAccount == null) { - imbAccount = new Account(imbalancePrefix + currencyCode, Commodity.getInstance(currencyCode)); + imbAccount = new Account(imbalancePrefix + currencyCode, mCommoditiesDbAdapter.getCommodity(currencyCode)); imbAccount.setParentUID(mRootAccount.getUID()); imbAccount.setAccountType(AccountType.BANK); mapImbalanceAccount.put(currencyCode, imbAccount); @@ -841,48 +951,81 @@ public void endDocument() throws SAXException { for (Account account:mAccountList){ account.setFullName(mapFullName.get(account.getUID())); } + + String mostAppearedCurrency = ""; + int mostCurrencyAppearance = 0; + for (Map.Entry entry : mCurrencyCount.entrySet()) { + if (entry.getValue() > mostCurrencyAppearance) { + mostCurrencyAppearance = entry.getValue(); + mostAppearedCurrency = entry.getKey(); + } + } + if (mostCurrencyAppearance > 0) { + GnuCashApplication.setDefaultCurrencyCode(mostAppearedCurrency); + } + + saveToDatabase(); + } + + /** + * Saves the imported data to the database + * @return GUID of the newly created book, or null if not successful + */ + private void saveToDatabase() { + BooksDbAdapter booksDbAdapter = BooksDbAdapter.getInstance(); + mBook.setRootAccountUID(mRootAccount.getUID()); + mBook.setDisplayName(booksDbAdapter.generateDefaultBookName()); + //we on purpose do not set the book active. Only import. Caller should handle activation + long startTime = System.nanoTime(); mAccountsDbAdapter.beginTransaction(); Log.d(getClass().getSimpleName(), "bulk insert starts"); try { + // disable foreign key. The database structure should be ensured by the data inserted. + // it will make insertion much faster. + mAccountsDbAdapter.enableForeignKey(false); Log.d(getClass().getSimpleName(), "before clean up db"); mAccountsDbAdapter.deleteAllRecords(); Log.d(getClass().getSimpleName(), String.format("deb clean up done %d ns", System.nanoTime()-startTime)); - long nAccounts = mAccountsDbAdapter.bulkAddRecords(mAccountList); + long nAccounts = mAccountsDbAdapter.bulkAddRecords(mAccountList, DatabaseAdapter.UpdateMethod.insert); Log.d("Handler:", String.format("%d accounts inserted", nAccounts)); //We need to add scheduled actions first because there is a foreign key constraint on transactions //which are generated from scheduled actions (we do auto-create some transactions during import) - long nSchedActions = mScheduledActionsDbAdapter.bulkAddRecords(mScheduledActionsList); + long nSchedActions = mScheduledActionsDbAdapter.bulkAddRecords(mScheduledActionsList, DatabaseAdapter.UpdateMethod.insert); Log.d("Handler:", String.format("%d scheduled actions inserted", nSchedActions)); - long nTempTransactions = mTransactionsDbAdapter.bulkAddRecords(mTemplateTransactions); + long nTempTransactions = mTransactionsDbAdapter.bulkAddRecords(mTemplateTransactions, DatabaseAdapter.UpdateMethod.insert); Log.d("Handler:", String.format("%d template transactions inserted", nTempTransactions)); - long nTransactions = mTransactionsDbAdapter.bulkAddRecords(mTransactionList); + long nTransactions = mTransactionsDbAdapter.bulkAddRecords(mTransactionList, DatabaseAdapter.UpdateMethod.insert); Log.d("Handler:", String.format("%d transactions inserted", nTransactions)); - long nPrices = mPricesDbAdapter.bulkAddRecords(mPriceList); + long nPrices = mPricesDbAdapter.bulkAddRecords(mPriceList, DatabaseAdapter.UpdateMethod.insert); Log.d(getClass().getSimpleName(), String.format("%d prices inserted", nPrices)); + //// TODO: 01.06.2016 Re-enable import of Budget stuff when the UI is complete +// long nBudgets = mBudgetsDbAdapter.bulkAddRecords(mBudgetList, DatabaseAdapter.UpdateMethod.insert); +// Log.d(getClass().getSimpleName(), String.format("%d budgets inserted", nBudgets)); + long endTime = System.nanoTime(); Log.d(getClass().getSimpleName(), String.format("bulk insert time: %d", endTime - startTime)); + //if all of the import went smoothly, then add the book to the book db + booksDbAdapter.addRecord(mBook, DatabaseAdapter.UpdateMethod.insert); mAccountsDbAdapter.setTransactionSuccessful(); } finally { + mAccountsDbAdapter.enableForeignKey(true); mAccountsDbAdapter.endTransaction(); + mainDb.close(); //close it after import } + } - String mostAppearedCurrency = ""; - int mostCurrencyAppearance = 0; - for (Map.Entry entry : mCurrencyCount.entrySet()) { - if (entry.getValue() > mostCurrencyAppearance) { - mostCurrencyAppearance = entry.getValue(); - mostAppearedCurrency = entry.getKey(); - } - } - if (mostCurrencyAppearance > 0) { - GnuCashApplication.setDefaultCurrencyCode(mostAppearedCurrency); - } + /** + * Returns the unique identifier of the just-imported book + * @return GUID of the newly imported book + */ + public @NonNull String getBookUID(){ + return mBook.getUID(); } /** @@ -909,7 +1052,7 @@ private void handleEndOfTemplateNumericSlot(String characterString, TransactionT try { BigDecimal amountBigD = GncXmlHelper.parseSplitAmount(characterString); Money amount = new Money(amountBigD, getCommodityForAccount(mSplit.getAccountUID())); - mSplit.setValue(amount.absolute()); + mSplit.setValue(amount.abs()); mSplit.setType(splitType); mIgnoreTemplateTransaction = false; //we have successfully parsed an amount } catch (NumberFormatException | ParseException e) { @@ -935,13 +1078,13 @@ private int generateMissedScheduledTransactions(ScheduledAction scheduledAction) if (scheduledAction.getActionType() != ScheduledAction.ActionType.TRANSACTION || !scheduledAction.isEnabled() || !scheduledAction.shouldAutoCreate() || (scheduledAction.getEndTime() > 0 && scheduledAction.getEndTime() > System.currentTimeMillis()) - || (scheduledAction.getTotalFrequency() > 0 && scheduledAction.getExecutionCount() >= scheduledAction.getTotalFrequency())){ + || (scheduledAction.getTotalPlannedExecutionCount() > 0 && scheduledAction.getExecutionCount() >= scheduledAction.getTotalPlannedExecutionCount())){ return 0; } long lastRuntime = scheduledAction.getStartTime(); - if (scheduledAction.getLastRun() > 0){ - lastRuntime = scheduledAction.getLastRun(); + if (scheduledAction.getLastRunTime() > 0){ + lastRuntime = scheduledAction.getLastRunTime(); } int generatedTransactionCount = 0; @@ -954,6 +1097,10 @@ private int generateMissedScheduledTransactions(ScheduledAction scheduledAction) transaction.setTime(lastRuntime); transaction.setScheduledActionUID(scheduledAction.getUID()); mTransactionList.add(transaction); + //autobalance splits are generated with the currency of the transactions as the GUID + //so we add them to the mAutoBalanceSplits which will be updated to real GUIDs before saving + List autoBalanceSplits = transaction.getSplits(transaction.getCurrencyCode()); + mAutoBalanceSplits.addAll(autoBalanceSplits); scheduledAction.setExecutionCount(scheduledAction.getExecutionCount() + 1); ++generatedTransactionCount; break; diff --git a/app/src/main/java/org/gnucash/android/importer/GncXmlImporter.java b/app/src/main/java/org/gnucash/android/importer/GncXmlImporter.java index a256ab8cc..5d774aa4a 100644 --- a/app/src/main/java/org/gnucash/android/importer/GncXmlImporter.java +++ b/app/src/main/java/org/gnucash/android/importer/GncXmlImporter.java @@ -16,10 +16,9 @@ */ package org.gnucash.android.importer; -import android.database.sqlite.SQLiteDatabase; import android.util.Log; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.util.PreferencesHelper; import org.xml.sax.InputSource; import org.xml.sax.SAXException; @@ -42,33 +41,12 @@ */ public class GncXmlImporter { - /** - * Parses XML into an already open database. - *

This method is used mainly by the {@link org.gnucash.android.db.DatabaseHelper} for database migrations.
- * You should probably use {@link #parse(java.io.InputStream)} instead

- * @param db SQLite Database - * @param gncXmlInputStream Input stream of GnuCash XML - */ - public static void parse(SQLiteDatabase db, InputStream gncXmlInputStream) throws Exception { - SAXParserFactory spf = SAXParserFactory.newInstance(); - SAXParser sp = spf.newSAXParser(); - XMLReader xr = sp.getXMLReader(); - - BufferedInputStream bos = new BufferedInputStream(gncXmlInputStream); - - /** Create handler to handle XML Tags ( extends DefaultHandler ) */ - - GncXmlHandler handler = new GncXmlHandler(db); - - xr.setContentHandler(handler); - xr.parse(new InputSource(bos)); - } - /** * Parse GnuCash XML input and populates the database * @param gncXmlInputStream InputStream source of the GnuCash XML file + * @return GUID of the book into which the XML was imported */ - public static void parse(InputStream gncXmlInputStream) throws ParserConfigurationException, SAXException, IOException { + public static String parse(InputStream gncXmlInputStream) throws ParserConfigurationException, SAXException, IOException { SAXParserFactory spf = SAXParserFactory.newInstance(); SAXParser sp = spf.newSAXParser(); XMLReader xr = sp.getXMLReader(); @@ -90,11 +68,14 @@ public static void parse(InputStream gncXmlInputStream) throws ParserConfigurati long startTime = System.nanoTime(); xr.parse(new InputSource(bos)); long endTime = System.nanoTime(); + Log.d(GncXmlImporter.class.getSimpleName(), String.format("%d ns spent on importing the file", endTime-startTime)); + String bookUID = handler.getBookUID(); PreferencesHelper.setLastExportTime( - TransactionsDbAdapter.getInstance().getTimestampOfLastModification() + TransactionsDbAdapter.getInstance().getTimestampOfLastModification(), + bookUID ); - Log.d(GncXmlImporter.class.getSimpleName(), String.format("%d ns spent on importing the file", endTime-startTime)); + return bookUID; } } diff --git a/app/src/main/java/org/gnucash/android/importer/ImportAsyncTask.java b/app/src/main/java/org/gnucash/android/importer/ImportAsyncTask.java index 7f01f7d9e..b8fdd6022 100644 --- a/app/src/main/java/org/gnucash/android/importer/ImportAsyncTask.java +++ b/app/src/main/java/org/gnucash/android/importer/ImportAsyncTask.java @@ -18,16 +18,22 @@ import android.annotation.TargetApi; import android.app.Activity; import android.app.ProgressDialog; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; +import android.provider.OpenableColumns; import android.util.Log; import android.widget.Toast; import com.crashlytics.android.Crashlytics; import org.gnucash.android.R; -import org.gnucash.android.ui.account.AccountsActivity; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.BooksDbAdapter; import org.gnucash.android.ui.util.TaskDelegate; import java.io.InputStream; @@ -41,6 +47,8 @@ public class ImportAsyncTask extends AsyncTask { private TaskDelegate mDelegate; private ProgressDialog mProgressDialog; + private String mImportedBookUID; + public ImportAsyncTask(Activity context){ this.mContext = context; } @@ -71,7 +79,8 @@ protected void onPreExecute() { protected Boolean doInBackground(Uri... uris) { try { InputStream accountInputStream = mContext.getContentResolver().openInputStream(uris[0]); - GncXmlImporter.parse(accountInputStream); + mImportedBookUID = GncXmlImporter.parse(accountInputStream); + } catch (Exception exception){ Log.e(ImportAsyncTask.class.getName(), "" + exception.getMessage()); Crashlytics.log("Could not open: " + uris[0].toString()); @@ -91,14 +100,30 @@ public void run() { return false; } + + Cursor cursor = mContext.getContentResolver().query(uris[0], null, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + String displayName = cursor.getString(nameIndex); + ContentValues contentValues = new ContentValues(); + contentValues.put(DatabaseSchema.BookEntry.COLUMN_DISPLAY_NAME, displayName); + contentValues.put(DatabaseSchema.BookEntry.COLUMN_SOURCE_URI, uris[0].toString()); + BooksDbAdapter.getInstance().updateRecord(mImportedBookUID, contentValues); + + cursor.close(); + } + + //set the preferences to their default values + mContext.getSharedPreferences(mImportedBookUID, Context.MODE_PRIVATE) + .edit() + .putBoolean(mContext.getString(R.string.key_use_double_entry), true) + .apply(); + return true; } @Override protected void onPostExecute(Boolean importSuccess) { - if (mDelegate != null) - mDelegate.onTaskComplete(); - try { if (mProgressDialog != null && mProgressDialog.isShowing()) mProgressDialog.dismiss(); @@ -112,6 +137,10 @@ protected void onPostExecute(Boolean importSuccess) { int message = importSuccess ? R.string.toast_success_importing_accounts : R.string.toast_error_importing_accounts; Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show(); - AccountsActivity.start(mContext); + if (mImportedBookUID != null) + GnuCashApplication.loadBook(mImportedBookUID); + + if (mDelegate != null) + mDelegate.onTaskComplete(); } } diff --git a/app/src/main/java/org/gnucash/android/model/Account.java b/app/src/main/java/org/gnucash/android/model/Account.java index 53fa57aab..57545686d 100644 --- a/app/src/main/java/org/gnucash/android/model/Account.java +++ b/app/src/main/java/org/gnucash/android/model/Account.java @@ -27,7 +27,6 @@ import java.sql.Timestamp; import java.util.ArrayList; -import java.util.Currency; import java.util.List; /** @@ -37,33 +36,35 @@ * By default, an account is made an {@link AccountType#CASH} and the default currency is * the currency of the Locale of the device on which the software is running. US Dollars is used * if the platform locale cannot be determined. - * + * * @author Ngewi Fet * @see AccountType */ -public class Account extends BaseModel{ +public class Account extends BaseModel { - /** - * The MIME type for accounts in GnucashMobile - * This is used when sending intents from third-party applications - */ - public static final String MIME_TYPE = "vnd.android.cursor.item/vnd." + BuildConfig.APPLICATION_ID + ".account"; + /** + * The MIME type for accounts in GnucashMobile + * This is used when sending intents from third-party applications + */ + public static final String MIME_TYPE = "vnd.android.cursor.item/vnd." + BuildConfig.APPLICATION_ID + ".account"; - /** - * Default color, if not set explicitly through {@link #setColor(String)}. - */ - // TODO: get it from a theme value? - public static final int DEFAULT_COLOR = Color.LTGRAY; + /** + * Default color, if not set explicitly through {@link #setColor(String)}. + */ + // TODO: get it from a theme value? + public static final int DEFAULT_COLOR = Color.LTGRAY; - /** + /** * Accounts types which are used by the OFX standard */ - public enum OfxAccountType {CHECKING, SAVINGS, MONEYMRKT, CREDITLINE } + public enum OfxAccountType { + CHECKING, SAVINGS, MONEYMRKT, CREDITLINE + } - /** - * Name of this account - */ - private String mName; + /** + * Name of this account + */ + private String mName; /** * Fully qualified name of this account including the parent hierarchy. @@ -71,37 +72,32 @@ public enum OfxAccountType {CHECKING, SAVINGS, MONEYMRKT, CREDITLINE } */ private String mFullName; - /** - * Account description - */ - private String mDescription = ""; - - /** - * Currency used by transactions in this account - */ - private String mCurrencyCode; + /** + * Account description + */ + private String mDescription = ""; - /** - * Commodity used by this account - */ - private Commodity mCommodity; + /** + * Commodity used by this account + */ + private Commodity mCommodity; - /** - * Type of account - * Defaults to {@link AccountType#CASH} - */ - private AccountType mAccountType = AccountType.CASH; + /** + * Type of account + * Defaults to {@link AccountType#CASH} + */ + private AccountType mAccountType = AccountType.CASH; - /** - * List of transactions in this account - */ - private List mTransactionsList = new ArrayList<>(); + /** + * List of transactions in this account + */ + private List mTransactionsList = new ArrayList<>(); - /** - * Account UID of the parent account. Can be null - */ - private String mParentAccountUID; + /** + * Account UID of the parent account. Can be null + */ + private String mParentAccountUID; /** * Save UID of a default account for transfers. @@ -130,54 +126,54 @@ public enum OfxAccountType {CHECKING, SAVINGS, MONEYMRKT, CREDITLINE } */ private boolean mIsHidden; - /** - * An extra key for passing the currency code (according ISO 4217) in an intent - */ - public static final String EXTRA_CURRENCY_CODE = "org.gnucash.android.extra.currency_code"; - - /** - * Extra key for passing the unique ID of the parent account when creating a - * new account using Intents - */ - public static final String EXTRA_PARENT_UID = "org.gnucash.android.extra.parent_uid"; - - /** - * Constructor - * Creates a new account with the default currency and a generated unique ID - * @param name Name of the account - */ - public Account(String name) { - setName(name); - this.mFullName = mName; - setCommodity(Commodity.DEFAULT_COMMODITY); - } - - /** - * Overloaded constructor - * @param name Name of the account - * @param commodity {@link Commodity} to be used by transactions in this account - */ - public Account(String name, Commodity commodity){ - setName(name); - this.mFullName = mName; - setCommodity(commodity); - } - - /** - * Sets the name of the account - * @param name String name of the account - */ - public void setName(String name) { - this.mName = name.trim(); - } - - /** - * Returns the name of the account - * @return String containing name of the account - */ - public String getName() { - return mName; - } + /** + * An extra key for passing the currency code (according ISO 4217) in an intent + */ + public static final String EXTRA_CURRENCY_CODE = "org.gnucash.android.extra.currency_code"; + + /** + * Extra key for passing the unique ID of the parent account when creating a + * new account using Intents + */ + public static final String EXTRA_PARENT_UID = "org.gnucash.android.extra.parent_uid"; + + /** + * Constructor + * Creates a new account with the default currency and a generated unique ID + * @param name Name of the account + */ + public Account(String name) { + setName(name); + this.mFullName = mName; + setCommodity(Commodity.DEFAULT_COMMODITY); + } + + /** + * Overloaded constructor + * @param name Name of the account + * @param commodity {@link Commodity} to be used by transactions in this account + */ + public Account(String name, @NonNull Commodity commodity) { + setName(name); + this.mFullName = mName; + setCommodity(commodity); + } + + /** + * Sets the name of the account + * @param name String name of the account + */ + public void setName(String name) { + this.mName = name.trim(); + } + + /** + * Returns the name of the account + * @return String containing name of the account + */ + public String getName() { + return mName; + } /** * Returns the full name of this account. @@ -196,87 +192,87 @@ public void setFullName(String fullName) { this.mFullName = fullName; } - /** - * Returns the account description - * @return String with description - */ - public String getDescription() { - return mDescription; - } - - /** - * Sets the account description - * @param description Account description - */ - public void setDescription(@NonNull String description) { - this.mDescription = description; - } - - /** - * Get the type of account - * @return {@link AccountType} type of account - */ - public AccountType getAccountType() { - return mAccountType; - } - - /** - * Sets the type of account - * @param mAccountType Type of account - * @see AccountType - */ - public void setAccountType(AccountType mAccountType) { - this.mAccountType = mAccountType; - } - - /** - * Adds a transaction to this account - * @param transaction {@link Transaction} to be added to the account - */ - public void addTransaction(Transaction transaction){ - transaction.setCommodity(mCommodity); - mTransactionsList.add(transaction); - } - - /** - * Sets a list of transactions for this account. - * Overrides any previous transactions with those in the list. - * The account UID and currency of the transactions will be set to the unique ID - * and currency of the account respectively - * @param transactionsList List of {@link Transaction}s to be set. - */ - public void setTransactions(List transactionsList){ - this.mTransactionsList = transactionsList; - } - - /** - * Returns a list of transactions for this account - * @return Array list of transactions for the account - */ - public List getTransactions(){ - return mTransactionsList; - } - - /** - * Returns the number of transactions in this account - * @return Number transactions in account - */ - public int getTransactionCount(){ - return mTransactionsList.size(); - } - - /** - * Returns the aggregate of all transactions in this account. - * It takes into account debit and credit amounts, it does not however consider sub-accounts - * @return {@link Money} aggregate amount of all transactions in account. - */ - public Money getBalance(){ - Money balance = Money.createZeroInstance(mCurrencyCode); + /** + * Returns the account description + * @return String with description + */ + public String getDescription() { + return mDescription; + } + + /** + * Sets the account description + * @param description Account description + */ + public void setDescription(@NonNull String description) { + this.mDescription = description; + } + + /** + * Get the type of account + * @return {@link AccountType} type of account + */ + public AccountType getAccountType() { + return mAccountType; + } + + /** + * Sets the type of account + * @param mAccountType Type of account + * @see AccountType + */ + public void setAccountType(AccountType mAccountType) { + this.mAccountType = mAccountType; + } + + /** + * Adds a transaction to this account + * @param transaction {@link Transaction} to be added to the account + */ + public void addTransaction(Transaction transaction) { + transaction.setCommodity(mCommodity); + mTransactionsList.add(transaction); + } + + /** + * Sets a list of transactions for this account. + * Overrides any previous transactions with those in the list. + * The account UID and currency of the transactions will be set to the unique ID + * and currency of the account respectively + * @param transactionsList List of {@link Transaction}s to be set. + */ + public void setTransactions(List transactionsList) { + this.mTransactionsList = transactionsList; + } + + /** + * Returns a list of transactions for this account + * @return Array list of transactions for the account + */ + public List getTransactions() { + return mTransactionsList; + } + + /** + * Returns the number of transactions in this account + * @return Number transactions in account + */ + public int getTransactionCount() { + return mTransactionsList.size(); + } + + /** + * Returns the aggregate of all transactions in this account. + * It takes into account debit and credit amounts, it does not however consider sub-accounts + * @return {@link Money} aggregate amount of all transactions in account. + */ + public Money getBalance() { + Money balance = Money.createZeroInstance(mCommodity.getCurrencyCode()); for (Transaction transaction : mTransactionsList) { balance.add(transaction.getBalance(getUID())); - } - return balance; - } + } + return balance; + } /** * Returns the color of the account. @@ -286,17 +282,17 @@ public int getColor() { return mColor; } - /** - * Sets the color of the account. - * @param color Color as an int as returned by {@link Color}. - * @throws java.lang.IllegalArgumentException if the color is transparent, - * which is not supported. - */ - public void setColor(int color) { - if (Color.alpha(color) < 255) - throw new IllegalArgumentException("Transparent colors are not supported: " + color); - mColor = color; - } + /** + * Sets the color of the account. + * @param color Color as an int as returned by {@link Color}. + * @throws java.lang.IllegalArgumentException if the color is transparent, + * which is not supported. + */ + public void setColor(int color) { + if (Color.alpha(color) < 255) + throw new IllegalArgumentException("Transparent colors are not supported: " + color); + mColor = color; + } /** * Sets the color of the account. @@ -304,7 +300,7 @@ public void setColor(int color) { * @throws java.lang.IllegalArgumentException if the color code is not properly formatted or * the color is transparent. */ - //TODO: Allow use of #aarrggbb format as well + //TODO: Allow use of #aarrggbb format as well public void setColor(@NonNull String colorCode) { setColor(Color.parseColor(colorCode)); } @@ -326,58 +322,43 @@ public void setFavorite(boolean isFavorite) { } /** - * Returns the currency for this account. - */ - public Currency getCurrency() { - return Currency.getInstance(mCurrencyCode); - } - - /** - * Sets the currency code of this account - * @param currencyCode ISO 4217 3-letter currency code - */ - public void setCurrencyCode(String currencyCode){ - this.mCurrencyCode = currencyCode; - } - - /** - * Return the commodity for this account - */ - public Commodity getCommodity(){ - return mCommodity; - } - - /** - * Sets the commodity of this account - * @param commodity Commodity of the account - */ - public void setCommodity(Commodity commodity){ - this.mCommodity = commodity; - this.mCurrencyCode = commodity.getCurrencyCode(); - //todo: should we also change commodity of transactions? Transactions can have splits from different accounts - } - - /** - * Sets the Unique Account Identifier of the parent account - * @param parentUID String Unique ID of parent account - */ - public void setParentUID(String parentUID){ - mParentAccountUID = parentUID; - } - - /** - * Returns the Unique Account Identifier of the parent account - * @return String Unique ID of parent account - */ - public String getParentUID() { - return mParentAccountUID; - } + * Return the commodity for this account + */ + @NonNull + public Commodity getCommodity() { + return mCommodity; + } + + /** + * Sets the commodity of this account + * @param commodity Commodity of the account + */ + public void setCommodity(@NonNull Commodity commodity) { + this.mCommodity = commodity; + //todo: should we also change commodity of transactions? Transactions can have splits from different accounts + } + + /** + * Sets the Unique Account Identifier of the parent account + * @param parentUID String Unique ID of parent account + */ + public void setParentUID(String parentUID) { + mParentAccountUID = parentUID; + } + + /** + * Returns the Unique Account Identifier of the parent account + * @return String Unique ID of parent account + */ + public String getParentUID() { + return mParentAccountUID; + } /** * Returns true if this account is a placeholder account, false otherwise. * @return true if this account is a placeholder account, false otherwise */ - public boolean isPlaceholderAccount(){ + public boolean isPlaceholderAccount() { return mIsPlaceholderAccount; } @@ -386,7 +367,7 @@ public boolean isPlaceholderAccount(){ *

Hidden accounts are not visible in the UI

* @return true if the account is hidden, false otherwise. */ - public boolean isHidden(){ + public boolean isHidden() { return mIsHidden; } @@ -395,7 +376,7 @@ public boolean isHidden(){ *

Hidden accounts are not visible in the UI

* @param hidden boolean specifying is hidden or not */ - public void setHidden(boolean hidden){ + public void setHidden(boolean hidden) { this.mIsHidden = hidden; } @@ -404,7 +385,7 @@ public void setHidden(boolean hidden){ * Placeholder accounts cannot have transactions * @param isPlaceholder Boolean flag indicating if the account is a placeholder account or not */ - public void setPlaceHolderFlag(boolean isPlaceholder){ + public void setPlaceHolderFlag(boolean isPlaceholder) { mIsPlaceholderAccount = isPlaceholder; } @@ -426,117 +407,117 @@ public void setDefaultTransferAccountUID(String defaultTransferAccountUID) { /** - * Maps the accountType to the corresponding account type. - * accountType have corresponding values to GnuCash desktop - * @param accountType {@link AccountType} of an account - * @return Corresponding {@link OfxAccountType} for the accountType - * @see AccountType - * @see OfxAccountType - */ - public static OfxAccountType convertToOfxAccountType(AccountType accountType){ - switch (accountType) { - case CREDIT: - case LIABILITY: - return OfxAccountType.CREDITLINE; - - case CASH: - case INCOME: - case EXPENSE: - case PAYABLE: - case RECEIVABLE: - return OfxAccountType.CHECKING; - - case BANK: - case ASSET: - return OfxAccountType.SAVINGS; - - case MUTUAL: - case STOCK: - case EQUITY: - case CURRENCY: - return OfxAccountType.MONEYMRKT; - - default: - return OfxAccountType.CHECKING; - } - } - - /** - * Converts this account's transactions into XML and adds them to the DOM document - * @param doc XML DOM document for the OFX data - * @param parent Parent node to which to add this account's transactions in XML - * @param exportStartTime Time from which to export transactions which are created/modified after - */ - public void toOfx(Document doc, Element parent, Timestamp exportStartTime){ - Element currency = doc.createElement(OfxHelper.TAG_CURRENCY_DEF); - currency.appendChild(doc.createTextNode(mCommodity.getCurrencyCode())); - - //================= BEGIN BANK ACCOUNT INFO (BANKACCTFROM) ================================= - - Element bankId = doc.createElement(OfxHelper.TAG_BANK_ID); - bankId.appendChild(doc.createTextNode(OfxHelper.APP_ID)); - - Element acctId = doc.createElement(OfxHelper.TAG_ACCOUNT_ID); - acctId.appendChild(doc.createTextNode(getUID())); - - Element accttype = doc.createElement(OfxHelper.TAG_ACCOUNT_TYPE); - String ofxAccountType = convertToOfxAccountType(mAccountType).toString(); - accttype.appendChild(doc.createTextNode(ofxAccountType)); - - Element bankFrom = doc.createElement(OfxHelper.TAG_BANK_ACCOUNT_FROM); - bankFrom.appendChild(bankId); - bankFrom.appendChild(acctId); - bankFrom.appendChild(accttype); - - //================= END BANK ACCOUNT INFO ============================================ - - - //================= BEGIN ACCOUNT BALANCE INFO ================================= - String balance = getBalance().toPlainString(); - String formattedCurrentTimeString = OfxHelper.getFormattedCurrentTime(); - - Element balanceAmount = doc.createElement(OfxHelper.TAG_BALANCE_AMOUNT); - balanceAmount.appendChild(doc.createTextNode(balance)); - Element dtasof = doc.createElement(OfxHelper.TAG_DATE_AS_OF); - dtasof.appendChild(doc.createTextNode(formattedCurrentTimeString)); - - Element ledgerBalance = doc.createElement(OfxHelper.TAG_LEDGER_BALANCE); - ledgerBalance.appendChild(balanceAmount); - ledgerBalance.appendChild(dtasof); - - //================= END ACCOUNT BALANCE INFO ================================= - - - //================= BEGIN TIME PERIOD INFO ================================= - - Element dtstart = doc.createElement(OfxHelper.TAG_DATE_START); - dtstart.appendChild(doc.createTextNode(formattedCurrentTimeString)); - - Element dtend = doc.createElement(OfxHelper.TAG_DATE_END); - dtend.appendChild(doc.createTextNode(formattedCurrentTimeString)); - - //================= END TIME PERIOD INFO ================================= - - - //================= BEGIN TRANSACTIONS LIST ================================= - Element bankTransactionsList = doc.createElement(OfxHelper.TAG_BANK_TRANSACTION_LIST); - bankTransactionsList.appendChild(dtstart); - bankTransactionsList.appendChild(dtend); - - for (Transaction transaction : mTransactionsList) { - if (transaction.getModifiedTimestamp().before(exportStartTime)) - continue; + * Maps the accountType to the corresponding account type. + * accountType have corresponding values to GnuCash desktop + * @param accountType {@link AccountType} of an account + * @return Corresponding {@link OfxAccountType} for the accountType + * @see AccountType + * @see OfxAccountType + */ + public static OfxAccountType convertToOfxAccountType(AccountType accountType) { + switch (accountType) { + case CREDIT: + case LIABILITY: + return OfxAccountType.CREDITLINE; + + case CASH: + case INCOME: + case EXPENSE: + case PAYABLE: + case RECEIVABLE: + return OfxAccountType.CHECKING; + + case BANK: + case ASSET: + return OfxAccountType.SAVINGS; + + case MUTUAL: + case STOCK: + case EQUITY: + case CURRENCY: + return OfxAccountType.MONEYMRKT; + + default: + return OfxAccountType.CHECKING; + } + } + + /** + * Converts this account's transactions into XML and adds them to the DOM document + * @param doc XML DOM document for the OFX data + * @param parent Parent node to which to add this account's transactions in XML + * @param exportStartTime Time from which to export transactions which are created/modified after + */ + public void toOfx(Document doc, Element parent, Timestamp exportStartTime) { + Element currency = doc.createElement(OfxHelper.TAG_CURRENCY_DEF); + currency.appendChild(doc.createTextNode(mCommodity.getCurrencyCode())); + + //================= BEGIN BANK ACCOUNT INFO (BANKACCTFROM) ================================= + + Element bankId = doc.createElement(OfxHelper.TAG_BANK_ID); + bankId.appendChild(doc.createTextNode(OfxHelper.APP_ID)); + + Element acctId = doc.createElement(OfxHelper.TAG_ACCOUNT_ID); + acctId.appendChild(doc.createTextNode(getUID())); + + Element accttype = doc.createElement(OfxHelper.TAG_ACCOUNT_TYPE); + String ofxAccountType = convertToOfxAccountType(mAccountType).toString(); + accttype.appendChild(doc.createTextNode(ofxAccountType)); + + Element bankFrom = doc.createElement(OfxHelper.TAG_BANK_ACCOUNT_FROM); + bankFrom.appendChild(bankId); + bankFrom.appendChild(acctId); + bankFrom.appendChild(accttype); + + //================= END BANK ACCOUNT INFO ============================================ + + + //================= BEGIN ACCOUNT BALANCE INFO ================================= + String balance = getBalance().toPlainString(); + String formattedCurrentTimeString = OfxHelper.getFormattedCurrentTime(); + + Element balanceAmount = doc.createElement(OfxHelper.TAG_BALANCE_AMOUNT); + balanceAmount.appendChild(doc.createTextNode(balance)); + Element dtasof = doc.createElement(OfxHelper.TAG_DATE_AS_OF); + dtasof.appendChild(doc.createTextNode(formattedCurrentTimeString)); + + Element ledgerBalance = doc.createElement(OfxHelper.TAG_LEDGER_BALANCE); + ledgerBalance.appendChild(balanceAmount); + ledgerBalance.appendChild(dtasof); + + //================= END ACCOUNT BALANCE INFO ================================= + + + //================= BEGIN TIME PERIOD INFO ================================= + + Element dtstart = doc.createElement(OfxHelper.TAG_DATE_START); + dtstart.appendChild(doc.createTextNode(formattedCurrentTimeString)); + + Element dtend = doc.createElement(OfxHelper.TAG_DATE_END); + dtend.appendChild(doc.createTextNode(formattedCurrentTimeString)); + + //================= END TIME PERIOD INFO ================================= + + + //================= BEGIN TRANSACTIONS LIST ================================= + Element bankTransactionsList = doc.createElement(OfxHelper.TAG_BANK_TRANSACTION_LIST); + bankTransactionsList.appendChild(dtstart); + bankTransactionsList.appendChild(dtend); + + for (Transaction transaction : mTransactionsList) { + if (transaction.getModifiedTimestamp().before(exportStartTime)) + continue; bankTransactionsList.appendChild(transaction.toOFX(doc, getUID())); - } - //================= END TRANSACTIONS LIST ================================= - - Element statementTransactions = doc.createElement(OfxHelper.TAG_STATEMENT_TRANSACTIONS); - statementTransactions.appendChild(currency); - statementTransactions.appendChild(bankFrom); - statementTransactions.appendChild(bankTransactionsList); - statementTransactions.appendChild(ledgerBalance); - - parent.appendChild(statementTransactions); - - } + } + //================= END TRANSACTIONS LIST ================================= + + Element statementTransactions = doc.createElement(OfxHelper.TAG_STATEMENT_TRANSACTIONS); + statementTransactions.appendChild(currency); + statementTransactions.appendChild(bankFrom); + statementTransactions.appendChild(bankTransactionsList); + statementTransactions.appendChild(ledgerBalance); + + parent.appendChild(statementTransactions); + + } } diff --git a/app/src/main/java/org/gnucash/android/model/Book.java b/app/src/main/java/org/gnucash/android/model/Book.java new file mode 100644 index 000000000..84ada6f5d --- /dev/null +++ b/app/src/main/java/org/gnucash/android/model/Book.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.model; + +import android.net.Uri; + +import java.sql.Timestamp; + +/** + * Represents a GnuCash book which is made up of accounts and transactions + * @author Ngewi Fet + */ +public class Book extends BaseModel { + + private Uri mSourceUri; + private String mDisplayName; + private String mRootAccountUID; + private String mRootTemplateUID; + private boolean mActive; + + private Timestamp mLastSync; + + /** + * Default constructor + */ + public Book(){ + init(); + } + + /** + * Create a new book instance + * @param rootAccountUID GUID of root account + */ + public Book(String rootAccountUID){ + this.mRootAccountUID = rootAccountUID; + init(); + } + + /** + * Initialize default values for the book + */ + private void init(){ + this.mRootTemplateUID = generateUID(); + mLastSync = new Timestamp(System.currentTimeMillis()); + } + + /** + * Return the root account GUID of this book + * @return GUID of the book root account + */ + public String getRootAccountUID() { + return mRootAccountUID; + } + + /** + * Sets the GUID of the root account of this book. + *

Each book has only one root account

+ * @param rootAccountUID GUID of the book root account + */ + public void setRootAccountUID(String rootAccountUID) { + mRootAccountUID = rootAccountUID; + } + + /** + * Return GUID of the template root account + * @return GUID of template root acount + */ + public String getRootTemplateUID() { + return mRootTemplateUID; + } + + /** + * Set the GUID of the root template account + * @param rootTemplateUID GUID of the root template account + */ + public void setRootTemplateUID(String rootTemplateUID) { + mRootTemplateUID = rootTemplateUID; + } + + /** + * Check if this book is the currently active book in the app + *

An active book is one whose data is currently displayed in the UI

+ * @return {@code true} if this is the currently active book, {@code false} otherwise + */ + public boolean isActive() { + return mActive; + } + + /** + * Sets this book as the currently active one in the application + * @param active Flag for activating/deactivating the book + */ + public void setActive(boolean active) { + mActive = active; + } + + /** + * Return the Uri of the XML file from which the book was imported. + *

In API level 16 and above, this is the Uri from the storage access framework which will + * be used for synchronization of the book

+ * @return Uri of the book source XML + */ + public Uri getSourceUri() { + return mSourceUri; + } + + /** + * Set the Uri of the XML source for the book + *

This Uri will be used for sync where applicable

+ * @param uri Uri of the GnuCash XML source file + */ + public void setSourceUri(Uri uri) { + this.mSourceUri = uri; + } + + /** + * Returns a name for the book + *

This is the user readable string which is used in UI unlike the root account GUID which + * is used for uniquely identifying each book

+ * @return Name of the book + */ + public String getDisplayName() { + return mDisplayName; + } + + /** + * Set a name for the book + * @param name Name of the book + */ + public void setDisplayName(String name) { + this.mDisplayName = name; + } + + /** + * Get the time of last synchronization of the book + * @return Timestamp of last synchronization + */ + public Timestamp getLastSync() { + return mLastSync; + } + + /** + * Set the time of last synchronization of the book + * @param lastSync Timestamp of last synchronization + */ + public void setLastSync(Timestamp lastSync) { + this.mLastSync = lastSync; + } +} diff --git a/app/src/main/java/org/gnucash/android/model/Budget.java b/app/src/main/java/org/gnucash/android/model/Budget.java new file mode 100644 index 000000000..db199b366 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/model/Budget.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.model; + +import android.support.annotation.NonNull; +import android.util.Log; + +import org.joda.time.LocalDateTime; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Budgets model + * @author Ngewi Fet + */ +public class Budget extends BaseModel { + + private String mName; + private String mDescription; + private Recurrence mRecurrence; + private List mBudgetAmounts = new ArrayList<>(); + private long mNumberOfPeriods = 12; //default to 12 periods per year + + /** + * Default constructor + */ + public Budget(){ + //nothing to see here, move along + } + + /** + * Overloaded constructor. + * Initializes the name and amount of this budget + * @param name String name of the budget + */ + public Budget(@NonNull String name){ + this.mName = name; + } + + public Budget(@NonNull String name, @NonNull Recurrence recurrence){ + this.mName = name; + this.mRecurrence = recurrence; + } + + /** + * Returns the name of the budget + * @return name of the budget + */ + public String getName() { + return mName; + } + + /** + * Sets the name of the budget + * @param name String name of budget + */ + public void setName(@NonNull String name) { + this.mName = name; + } + + /** + * Returns the description of the budget + * @return String description of budget + */ + public String getDescription() { + return mDescription; + } + + /** + * Sets the description of the budget + * @param description String description + */ + public void setDescription(String description) { + this.mDescription = description; + } + + /** + * Returns the recurrence for this budget + * @return Recurrence object for this budget + */ + public Recurrence getRecurrence() { + return mRecurrence; + } + + /** + * Set the recurrence pattern for this budget + * @param recurrence Recurrence object + */ + public void setRecurrence(@NonNull Recurrence recurrence) { + this.mRecurrence = recurrence; + } + + /** + * Return list of budget amounts associated with this budget + * @return List of budget amounts + */ + public List getBudgetAmounts() { + return mBudgetAmounts; + } + + /** + * Set the list of budget amounts + * @param budgetAmounts List of budget amounts + */ + public void setBudgetAmounts(List budgetAmounts) { + this.mBudgetAmounts = budgetAmounts; + for (BudgetAmount budgetAmount : mBudgetAmounts) { + budgetAmount.setBudgetUID(getUID()); + } + } + + /** + * Adds a BudgetAmount to this budget + * @param budgetAmount Budget amount + */ + public void addBudgetAmount(BudgetAmount budgetAmount){ + budgetAmount.setBudgetUID(getUID()); + mBudgetAmounts.add(budgetAmount); + } + + /** + * Returns the budget amount for a specific account + * @param accountUID GUID of the account + * @return Money amount of the budget or null if the budget has no amount for the account + */ + public Money getAmount(@NonNull String accountUID){ + for (BudgetAmount budgetAmount : mBudgetAmounts) { + if (budgetAmount.getAccountUID().equals(accountUID)) + return budgetAmount.getAmount(); + } + return null; + } + + /** + * Returns the budget amount for a specific account and period + * @param accountUID GUID of the account + * @param periodNum Budgeting period, zero-based index + * @return Money amount or zero if no matching {@link BudgetAmount} is found for the period + */ + public Money getAmount(@NonNull String accountUID, int periodNum){ + for (BudgetAmount budgetAmount : mBudgetAmounts) { + if (budgetAmount.getAccountUID().equals(accountUID) + && (budgetAmount.getPeriodNum() == periodNum || budgetAmount.getPeriodNum() == -1)){ + return budgetAmount.getAmount(); + } + } + return Money.getZeroInstance(); + } + + /** + * Returns the sum of all budget amounts in this budget + *

NOTE: This method ignores budgets of accounts which are in different currencies

+ * @return Money sum of all amounts + */ + public Money getAmountSum(){ + Money sum = null; //we explicitly allow this null instead of a money instance, because this method should never return null for a budget + for (BudgetAmount budgetAmount : mBudgetAmounts) { + if (sum == null){ + sum = budgetAmount.getAmount(); + } else { + try { + sum = sum.add(budgetAmount.getAmount().abs()); + } catch (Money.CurrencyMismatchException ex){ + Log.i(getClass().getSimpleName(), "Skip some budget amounts with different currency"); + } + } + } + return sum; + } + + /** + * Returns the number of periods covered by this budget + * @return Number of periods + */ + public long getNumberOfPeriods() { + return mNumberOfPeriods; + } + + /** + * Returns the timestamp of the start of current period of the budget + * @return Start timestamp in milliseconds + */ + public long getStartofCurrentPeriod(){ + LocalDateTime localDate = new LocalDateTime(); + int interval = mRecurrence.getPeriodType().getMultiplier(); + switch (mRecurrence.getPeriodType()){ + case DAY: + localDate = localDate.millisOfDay().withMinimumValue().plusDays(interval); + break; + case WEEK: + localDate = localDate.dayOfWeek().withMinimumValue().minusDays(interval); + break; + case MONTH: + localDate = localDate.dayOfMonth().withMinimumValue().minusMonths(interval); + break; + case YEAR: + localDate = localDate.dayOfYear().withMinimumValue().minusYears(interval); + break; + } + return localDate.toDate().getTime(); + } + + /** + * Returns the end timestamp of the current period + * @return End timestamp in milliseconds + */ + public long getEndOfCurrentPeriod(){ + LocalDateTime localDate = new LocalDateTime(); + int interval = mRecurrence.getPeriodType().getMultiplier(); + switch (mRecurrence.getPeriodType()){ + case DAY: + localDate = localDate.millisOfDay().withMaximumValue().plusDays(interval); + break; + case WEEK: + localDate = localDate.dayOfWeek().withMaximumValue().plusWeeks(interval); + break; + case MONTH: + localDate = localDate.dayOfMonth().withMaximumValue().plusMonths(interval); + break; + case YEAR: + localDate = localDate.dayOfYear().withMaximumValue().plusYears(interval); + break; + } + return localDate.toDate().getTime(); + } + + public long getStartOfPeriod(int periodNum){ + LocalDateTime localDate = new LocalDateTime(mRecurrence.getPeriodStart().getTime()); + int interval = mRecurrence.getPeriodType().getMultiplier() * periodNum; + switch (mRecurrence.getPeriodType()){ + case DAY: + localDate = localDate.millisOfDay().withMinimumValue().plusDays(interval); + break; + case WEEK: + localDate = localDate.dayOfWeek().withMinimumValue().minusDays(interval); + break; + case MONTH: + localDate = localDate.dayOfMonth().withMinimumValue().minusMonths(interval); + break; + case YEAR: + localDate = localDate.dayOfYear().withMinimumValue().minusYears(interval); + break; + } + return localDate.toDate().getTime(); + } + + /** + * Returns the end timestamp of the period + * @param periodNum Number of the period + * @return End timestamp in milliseconds of the period + */ + public long getEndOfPeriod(int periodNum){ + LocalDateTime localDate = new LocalDateTime(); + int interval = mRecurrence.getPeriodType().getMultiplier() * periodNum; + switch (mRecurrence.getPeriodType()){ + case DAY: + localDate = localDate.millisOfDay().withMaximumValue().plusDays(interval); + break; + case WEEK: + localDate = localDate.dayOfWeek().withMaximumValue().plusWeeks(interval); + break; + case MONTH: + localDate = localDate.dayOfMonth().withMaximumValue().plusMonths(interval); + break; + case YEAR: + localDate = localDate.dayOfYear().withMaximumValue().plusYears(interval); + break; + } + return localDate.toDate().getTime(); + } + + /** + * Sets the number of periods for the budget + * @param numberOfPeriods Number of periods as long + */ + public void setNumberOfPeriods(long numberOfPeriods) { + this.mNumberOfPeriods = numberOfPeriods; + } + + /** + * Returns the number of accounts in this budget + * @return Number of budgeted accounts + */ + public int getNumberOfAccounts(){ + Set accountSet = new HashSet<>(); + for (BudgetAmount budgetAmount : mBudgetAmounts) { + accountSet.add(budgetAmount.getAccountUID()); + } + return accountSet.size(); + } + + /** + * Returns the list of budget amounts where only one BudgetAmount is present if the amount of the budget amount + * is the same for all periods in the budget. + * BudgetAmounts with different amounts per period are still return separately + *

+ * This method is used during import because GnuCash desktop saves one BudgetAmount per period for the whole budgeting period. + * While this can be easily displayed in a table form on the desktop, it is not feasible in the Android app. + * So we display only one BudgetAmount if it covers all periods in the budgeting period + *

+ * @return List of {@link BudgetAmount}s + */ + public List getCompactedBudgetAmounts(){ + + Map> accountAmountMap = new HashMap<>(); + for (BudgetAmount budgetAmount : mBudgetAmounts) { + String accountUID = budgetAmount.getAccountUID(); + BigDecimal amount = budgetAmount.getAmount().asBigDecimal(); + if (accountAmountMap.containsKey(accountUID)){ + accountAmountMap.get(accountUID).add(amount); + } else { + List amounts = new ArrayList<>(); + amounts.add(amount); + accountAmountMap.put(accountUID, amounts); + } + } + + List compactBudgetAmounts = new ArrayList<>(); + for (Map.Entry> entry : accountAmountMap.entrySet()) { + List amounts = entry.getValue(); + BigDecimal first = amounts.get(0); + boolean allSame = true; + for (BigDecimal bigDecimal : amounts) { + allSame &= bigDecimal.equals(first); + } + + if (allSame){ + if (amounts.size() == 1) { + for (BudgetAmount bgtAmount : mBudgetAmounts) { + if (bgtAmount.getAccountUID().equals(entry.getKey())) { + compactBudgetAmounts.add(bgtAmount); + break; + } + } + } else { + BudgetAmount bgtAmount = new BudgetAmount(getUID(), entry.getKey()); + bgtAmount.setAmount(new Money(first, Commodity.DEFAULT_COMMODITY)); + bgtAmount.setPeriodNum(-1); + compactBudgetAmounts.add(bgtAmount); + } + } else { + //if not all amounts are the same, then just add them as we read them + for (BudgetAmount bgtAmount : mBudgetAmounts) { + if (bgtAmount.getAccountUID().equals(entry.getKey())){ + compactBudgetAmounts.add(bgtAmount); + } + } + } + } + + return compactBudgetAmounts; + } + + /** + * Returns a list of budget amounts where each period has it's own budget amount + *

Any budget amounts in the database with a period number of -1 are expanded to individual budget amounts for all periods

+ *

This method is useful with exporting budget amounts to XML

+ * @return List of expande + */ + public List getExpandedBudgetAmounts(){ + List amountsToAdd = new ArrayList<>(); + List amountsToRemove = new ArrayList<>(); + for (BudgetAmount budgetAmount : mBudgetAmounts) { + if (budgetAmount.getPeriodNum() == -1){ + amountsToRemove.add(budgetAmount); + String accountUID = budgetAmount.getAccountUID(); + for (int period = 0; period < mNumberOfPeriods; period++) { + BudgetAmount bgtAmount = new BudgetAmount(getUID(), accountUID); + bgtAmount.setAmount(budgetAmount.getAmount()); + bgtAmount.setPeriodNum(period); + amountsToAdd.add(bgtAmount); + } + } + } + + List expandedBudgetAmounts = new ArrayList<>(mBudgetAmounts); + for (BudgetAmount bgtAmount : amountsToRemove) { + expandedBudgetAmounts.remove(bgtAmount); + } + + for (BudgetAmount bgtAmount : amountsToAdd) { + expandedBudgetAmounts.add(bgtAmount); + } + return expandedBudgetAmounts; + } +} diff --git a/app/src/main/java/org/gnucash/android/model/BudgetAmount.java b/app/src/main/java/org/gnucash/android/model/BudgetAmount.java new file mode 100644 index 000000000..42efb56d9 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/model/BudgetAmount.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.gnucash.android.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import java.math.BigDecimal; + +/** + * Budget amounts for the different accounts. + * The {@link Money} amounts are absolute values + * @see Budget + */ +public class BudgetAmount extends BaseModel implements Parcelable { + + private String mBudgetUID; + private String mAccountUID; + /** + * Period number for this budget amount + * A value of -1 indicates that this budget amount applies to all periods + */ + private long mPeriodNum; + private Money mAmount; + + /** + * Create a new budget amount + * @param budgetUID GUID of the budget + * @param accountUID GUID of the account + */ + public BudgetAmount(String budgetUID, String accountUID){ + this.mBudgetUID = budgetUID; + this.mAccountUID = accountUID; + } + + /** + * Creates a new budget amount with the absolute value of {@code amount} + * @param amount Money amount of the budget + * @param accountUID GUID of the account + */ + public BudgetAmount(Money amount, String accountUID){ + this.mAmount = amount.abs(); + this.mAccountUID = accountUID; + } + + public String getBudgetUID() { + return mBudgetUID; + } + + public void setBudgetUID(String budgetUID) { + this.mBudgetUID = budgetUID; + } + + public String getAccountUID() { + return mAccountUID; + } + + public void setAccountUID(String accountUID) { + this.mAccountUID = accountUID; + } + + /** + * Returns the period number of this budget amount + *

The period is zero-based index, and a value of -1 indicates that this budget amount is applicable to all budgeting periods

+ * @return Period number + */ + public long getPeriodNum() { + return mPeriodNum; + } + + /** + * Set the period number for this budget amount + *

A value of -1 indicates that this BudgetAmount is for all periods

+ * @param periodNum Zero-based period number of the budget amount + */ + public void setPeriodNum(long periodNum) { + this.mPeriodNum = periodNum; + } + + /** + * Returns the Money amount of this budget amount + * @return Money amount + */ + public Money getAmount() { + return mAmount; + } + + /** + * Sets the amount for the budget + *

The absolute value of the amount is used

+ * @param amount Money amount + */ + public void setAmount(Money amount) { + this.mAmount = amount.abs(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(getUID()); + dest.writeString(mBudgetUID); + dest.writeString(mAccountUID); + dest.writeString(mAmount.toPlainString()); + dest.writeLong(mPeriodNum); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator(){ + + @Override + public BudgetAmount createFromParcel(Parcel source) { + return new BudgetAmount(source); + } + + @Override + public BudgetAmount[] newArray(int size) { + return new BudgetAmount[size]; + } + }; + + /** + * Private constructor for creating new BudgetAmounts from a Parcel + * @param source Parcel + */ + private BudgetAmount(Parcel source){ + setUID(source.readString()); + mBudgetUID = source.readString(); + mAccountUID = source.readString(); + mAmount = new Money(new BigDecimal(source.readString()), Commodity.DEFAULT_COMMODITY); + mPeriodNum = source.readLong(); + } + + +} diff --git a/app/src/main/java/org/gnucash/android/model/Commodity.java b/app/src/main/java/org/gnucash/android/model/Commodity.java index f60b787e3..7fa136989 100644 --- a/app/src/main/java/org/gnucash/android/model/Commodity.java +++ b/app/src/main/java/org/gnucash/android/model/Commodity.java @@ -16,7 +16,7 @@ package org.gnucash.android.model; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; /** * Commodities are the currencies used in the application. @@ -190,6 +190,15 @@ public void setQuoteFlag(int quoteFlag) { this.mQuoteFlag = quoteFlag; } + @Override + /** + * Returns the full name of the currency, or the currency code if there is no full name + * @return String representation of the commodity + */ + public String toString() { + return mFullname == null || mFullname.isEmpty() ? mMnemonic : mFullname; + } + /** * Overrides {@link BaseModel#equals(Object)} to compare only the currency codes of the commodity. *

Two commodities are considered equal if they have the same currency code

diff --git a/app/src/main/java/org/gnucash/android/model/Money.java b/app/src/main/java/org/gnucash/android/model/Money.java index 02c6930ca..ef7cec86d 100644 --- a/app/src/main/java/org/gnucash/android/model/Money.java +++ b/app/src/main/java/org/gnucash/android/model/Money.java @@ -22,16 +22,12 @@ import com.crashlytics.android.Crashlytics; -import org.gnucash.android.app.GnuCashApplication; - import java.math.BigDecimal; import java.math.BigInteger; import java.math.RoundingMode; -import java.security.InvalidParameterException; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.text.NumberFormat; -import java.text.ParseException; import java.util.Currency; import java.util.Locale; @@ -84,8 +80,7 @@ public final class Money implements Comparable{ */ public static Money getZeroInstance(){ if (sDefaultZero == null) { - String currencyCode = GnuCashApplication.getDefaultCurrencyCode(); - sDefaultZero = new Money(BigDecimal.ZERO, Commodity.getInstance(currencyCode)); + sDefaultZero = new Money(BigDecimal.ZERO, Commodity.DEFAULT_COMMODITY); } return sDefaultZero; } @@ -249,10 +244,11 @@ private int getScale() { /** * Returns the amount represented by this Money object + *

The scale and rounding mode of the returned value are set to that of this Money object

* @return {@link BigDecimal} valure of amount in object */ public BigDecimal asBigDecimal() { - return mAmount; + return mAmount.setScale(mCommodity.getSmallestFractionDigits(), RoundingMode.HALF_EVEN); } /** @@ -339,11 +335,11 @@ private void setAmount(@NonNull BigDecimal amount) { * * @param addend Second operand in the addition. * @return Money object whose value is the sum of this object and money - * @throws IllegalArgumentException if the Money objects to be added have different Currencies + * @throws CurrencyMismatchException if the Money objects to be added have different Currencies */ public Money add(Money addend){ if (!mCommodity.equals(addend.mCommodity)) - throw new IllegalArgumentException("Only Money with same currency can be added"); + throw new CurrencyMismatchException(); BigDecimal bigD = mAmount.add(addend.mAmount); return new Money(bigD, mCommodity); @@ -355,11 +351,11 @@ public Money add(Money addend){ * This object is the minuend and the parameter is the subtrahend * @param subtrahend Second operand in the subtraction. * @return Money object whose value is the difference of this object and subtrahend - * @throws IllegalArgumentException if the Money objects to be added have different Currencies + * @throws CurrencyMismatchException if the Money objects to be added have different Currencies */ public Money subtract(Money subtrahend){ if (!mCommodity.equals(subtrahend.mCommodity)) - throw new IllegalArgumentException("Operation can only be performed on money with same currency"); + throw new CurrencyMismatchException(); BigDecimal bigD = mAmount.subtract(subtrahend.mAmount); return new Money(bigD, mCommodity); @@ -369,13 +365,14 @@ public Money subtract(Money subtrahend){ * Returns a new Money object whose value is the quotient of the values of * this object and divisor. * This object is the dividend and divisor is the divisor + *

This method uses the rounding mode {@link BigDecimal#ROUND_HALF_EVEN}

* @param divisor Second operand in the division. * @return Money object whose value is the quotient of this object and divisor - * @throws IllegalArgumentException if the Money objects to be added have different Currencies + * @throws CurrencyMismatchException if the Money objects to be added have different Currencies */ public Money divide(Money divisor){ if (!mCommodity.equals(divisor.mCommodity)) - throw new IllegalArgumentException("Operation can only be performed on money with same currency"); + throw new CurrencyMismatchException(); BigDecimal bigD = mAmount.divide(divisor.mAmount, mCommodity.getSmallestFractionDigits(), ROUNDING_MODE); return new Money(bigD, mCommodity); @@ -398,11 +395,11 @@ public Money divide(int divisor){ * * @param money Second operand in the multiplication. * @return Money object whose value is the product of this object and money - * @throws IllegalArgumentException if the Money objects to be added have different Currencies + * @throws CurrencyMismatchException if the Money objects to be added have different Currencies */ public Money multiply(Money money){ if (!mCommodity.equals(money.mCommodity)) - throw new IllegalArgumentException("Operation can only be performed on money with same currency"); + throw new CurrencyMismatchException(); BigDecimal bigD = mAmount.multiply(money.mAmount); return new Money(bigD, mCommodity); @@ -490,7 +487,7 @@ public boolean equals(Object obj) { @Override public int compareTo(@NonNull Money another) { if (!mCommodity.equals(another.mCommodity)) - throw new IllegalArgumentException("Cannot compare different currencies yet"); + throw new CurrencyMismatchException(); return mAmount.compareTo(another.mAmount); } @@ -498,7 +495,7 @@ public int compareTo(@NonNull Money another) { * Returns a new instance of {@link Money} object with the absolute value of the current object * @return Money object with absolute value of this instance */ - public Money absolute() { + public Money abs() { return new Money(mAmount.abs(), mCommodity); } @@ -509,4 +506,11 @@ public Money absolute() { public boolean isAmountZero() { return mAmount.compareTo(BigDecimal.ZERO) == 0; } + + public class CurrencyMismatchException extends IllegalArgumentException{ + @Override + public String getMessage() { + return "Cannot perform operation on Money instances with different currencies"; + } + } } diff --git a/app/src/main/java/org/gnucash/android/model/PeriodType.java b/app/src/main/java/org/gnucash/android/model/PeriodType.java index dc3b2d25f..06b13cc4b 100644 --- a/app/src/main/java/org/gnucash/android/model/PeriodType.java +++ b/app/src/main/java/org/gnucash/android/model/PeriodType.java @@ -20,6 +20,7 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.ui.util.RecurrenceParser; import java.text.SimpleDateFormat; import java.util.Date; @@ -31,10 +32,48 @@ * @see org.gnucash.android.model.ScheduledAction */ public enum PeriodType { - DAY, WEEK, MONTH, YEAR; + DAY, WEEK, MONTH, YEAR; // TODO: 22.10.2015 add support for hourly int mMultiplier = 1; //multiplier for the period type + /** + * Computes the {@link PeriodType} for a given {@code period} + * @param period Period in milliseconds since Epoch + * @return PeriodType corresponding to the period + */ + public static PeriodType parse(long period){ + PeriodType periodType = DAY; + int result = (int) (period/ RecurrenceParser.YEAR_MILLIS); + if (result > 0) { + periodType = YEAR; + periodType.setMultiplier(result); + return periodType; + } + + result = (int) (period/RecurrenceParser.MONTH_MILLIS); + if (result > 0) { + periodType = MONTH; + periodType.setMultiplier(result); + return periodType; + } + + result = (int) (period/RecurrenceParser.WEEK_MILLIS); + if (result > 0) { + periodType = WEEK; + periodType.setMultiplier(result); + return periodType; + } + + result = (int) (period/RecurrenceParser.DAY_MILLIS); + if (result > 0) { + periodType = DAY; + periodType.setMultiplier(result); + return periodType; + } + + return periodType; + } + /** * Sets the multiplier for this period type * e.g. bi-weekly actions have period type {@link PeriodType#WEEK} and multiplier 2 @@ -79,7 +118,7 @@ public String getFrequencyDescription() { */ public String getFrequencyRepeatString(){ Resources res = GnuCashApplication.getAppContext().getResources(); - + //todo: take multiplier into account here switch (this) { case DAY: return res.getQuantityString(R.plurals.label_every_x_days, mMultiplier, mMultiplier); diff --git a/app/src/main/java/org/gnucash/android/model/Price.java b/app/src/main/java/org/gnucash/android/model/Price.java index 7fde8683f..6c02e8d5e 100644 --- a/app/src/main/java/org/gnucash/android/model/Price.java +++ b/app/src/main/java/org/gnucash/android/model/Price.java @@ -2,7 +2,6 @@ import org.gnucash.android.util.TimestampHelper; - import java.math.BigDecimal; import java.math.MathContext; import java.sql.Timestamp; diff --git a/app/src/main/java/org/gnucash/android/model/Recurrence.java b/app/src/main/java/org/gnucash/android/model/Recurrence.java new file mode 100644 index 000000000..48db0af91 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/model/Recurrence.java @@ -0,0 +1,366 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.model; + +import android.content.Context; +import android.support.annotation.NonNull; + +import org.gnucash.android.R; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.ui.util.RecurrenceParser; +import org.joda.time.DateTime; +import org.joda.time.Days; +import org.joda.time.LocalDate; +import org.joda.time.LocalDateTime; +import org.joda.time.Months; +import org.joda.time.ReadablePeriod; +import org.joda.time.Weeks; +import org.joda.time.Years; + +import java.sql.Timestamp; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * Model for recurrences in the database + *

Basically a wrapper around {@link PeriodType}

+ */ +public class Recurrence extends BaseModel { + + private PeriodType mPeriodType; + + /** + * Start time of the recurrence + */ + private Timestamp mPeriodStart; + + /** + * End time of this recurrence + *

This value is not persisted to the database

+ */ + private Timestamp mPeriodEnd; + + /** + * Describes which day on which to run the recurrence + */ + private String mByDay; + + public Recurrence(@NonNull PeriodType periodType){ + setPeriodType(periodType); + mPeriodStart = new Timestamp(System.currentTimeMillis()); + } + + /** + * Return the PeriodType for this recurrence + * @return PeriodType for the recurrence + */ + public PeriodType getPeriodType() { + return mPeriodType; + } + + /** + * Sets the period type for the recurrence + * @param periodType PeriodType + */ + public void setPeriodType(PeriodType periodType) { + this.mPeriodType = periodType; + } + + /** + * Return the start time for this recurrence + * @return Timestamp of start of recurrence + */ + public Timestamp getPeriodStart() { + return mPeriodStart; + } + + /** + * Set the start time of this recurrence + * @param periodStart {@link Timestamp} of recurrence + */ + public void setPeriodStart(Timestamp periodStart) { + this.mPeriodStart = periodStart; + } + + + /** + * Returns an approximate period for this recurrence + *

The period is approximate because months do not all have the same number of days, + * but that is assumed

+ * @return Milliseconds since Epoch representing the period + * @deprecated Do not use in new code. Uses fixed period values for months and years (which have variable units of time) + */ + public long getPeriod(){ + long baseMillis = 0; + switch (mPeriodType){ + case DAY: + baseMillis = RecurrenceParser.DAY_MILLIS; + break; + case WEEK: + baseMillis = RecurrenceParser.WEEK_MILLIS; + break; + case MONTH: + baseMillis = RecurrenceParser.MONTH_MILLIS; + break; + case YEAR: + baseMillis = RecurrenceParser.YEAR_MILLIS; + break; + } + return mPeriodType.getMultiplier() * baseMillis; + } + + /** + * Returns the event schedule (start, end and recurrence) + * @return String description of repeat schedule + */ + public String getRepeatString(){ + StringBuilder repeatBuilder = new StringBuilder(mPeriodType.getFrequencyRepeatString()); + Context context = GnuCashApplication.getAppContext(); + + String dayOfWeek = new SimpleDateFormat("EEEE", GnuCashApplication.getDefaultLocale()) + .format(new Date(mPeriodStart.getTime())); + if (mPeriodType == PeriodType.WEEK) { + repeatBuilder.append(" ").append(context.getString(R.string.repeat_on_weekday, dayOfWeek)); + } + + if (mPeriodEnd != null){ + String endDateString = SimpleDateFormat.getDateInstance().format(new Date(mPeriodEnd.getTime())); + repeatBuilder.append(", ").append(context.getString(R.string.repeat_until_date, endDateString)); + } + return repeatBuilder.toString(); + } + + /** + * Creates an RFC 2445 string which describes this recurring event. + *

See http://recurrance.sourceforge.net/

+ *

The output of this method is not meant for human consumption

+ * @return String describing event + */ + public String getRuleString(){ + String separator = ";"; + + StringBuilder ruleBuilder = new StringBuilder(); + +// ======================================================================= + //This section complies with the formal rules, but the betterpickers library doesn't like/need it + +// SimpleDateFormat startDateFormat = new SimpleDateFormat("'TZID'=zzzz':'yyyyMMdd'T'HHmmss", Locale.US); +// ruleBuilder.append("DTSTART;"); +// ruleBuilder.append(startDateFormat.format(new Date(mStartDate))); +// ruleBuilder.append("\n"); +// ruleBuilder.append("RRULE:"); +// ======================================================================== + + + ruleBuilder.append("FREQ=").append(mPeriodType.getFrequencyDescription()).append(separator); + ruleBuilder.append("INTERVAL=").append(mPeriodType.getMultiplier()).append(separator); + if (getCount() > 0) + ruleBuilder.append("COUNT=").append(getCount()).append(separator); + ruleBuilder.append(mPeriodType.getByParts(mPeriodStart.getTime())).append(separator); + + return ruleBuilder.toString(); + } + + /** + * Return the number of days left in this period + * @return Number of days left in period + */ + public int getDaysLeftInCurrentPeriod(){ + LocalDate startDate = new LocalDate(System.currentTimeMillis()); + int interval = mPeriodType.getMultiplier() - 1; + LocalDate endDate = null; + switch (mPeriodType){ + case DAY: + endDate = new LocalDate(System.currentTimeMillis()).plusDays(interval); + break; + case WEEK: + endDate = startDate.dayOfWeek().withMaximumValue().plusWeeks(interval); + break; + case MONTH: + endDate = startDate.dayOfMonth().withMaximumValue().plusMonths(interval); + break; + case YEAR: + endDate = startDate.dayOfYear().withMaximumValue().plusYears(interval); + break; + } + + return Days.daysBetween(startDate, endDate).getDays(); + } + + /** + * Returns the number of periods from the start date of this recurrence until the end of the + * interval multiplier specified in the {@link PeriodType} + * //fixme: Improve the documentation + * @return Number of periods in this recurrence + */ + public int getNumberOfPeriods(int numberOfPeriods) { + LocalDate startDate = new LocalDate(mPeriodStart.getTime()); + LocalDate endDate; + int interval = mPeriodType.getMultiplier(); + //// TODO: 15.08.2016 Why do we add the number of periods. maybe rename method or param + switch (mPeriodType){ + + case DAY: + return 1; + case WEEK: + endDate = startDate.dayOfWeek().withMaximumValue().plusWeeks(numberOfPeriods); + return Weeks.weeksBetween(startDate, endDate).getWeeks() / interval; + case MONTH: + endDate = startDate.dayOfMonth().withMaximumValue().plusMonths(numberOfPeriods); + return Months.monthsBetween(startDate, endDate).getMonths() / interval; + case YEAR: + endDate = startDate.dayOfYear().withMaximumValue().plusYears(numberOfPeriods); + return Years.yearsBetween(startDate, endDate).getYears() / interval; + } + + return 0; + } + + /** + * Return the name of the current period + * @return String of current period + */ + public String getTextOfCurrentPeriod(int periodNum){ + LocalDate startDate = new LocalDate(mPeriodStart.getTime()); + switch (mPeriodType){ + + case DAY: + return startDate.dayOfWeek().getAsText(); + case WEEK: + return startDate.weekOfWeekyear().getAsText(); + case MONTH: + return startDate.monthOfYear().getAsText(); + case YEAR: + return startDate.year().getAsText(); + } + return "Period " + periodNum; + } + + /** + * Sets the string which determines on which day the recurrence will be run + * @param byDay Byday string of recurrence rule (RFC 2445) + */ + public void setByDay(String byDay){ + this.mByDay = byDay; + } + + /** + * Return the byDay string of recurrence rule (RFC 2445) + * @return String with by day specification + */ + public String getByDay(){ + return mByDay; + } + + /** + * Computes the number of occurrences of this recurrences between start and end date + *

If there is no end date, it returns -1

+ * @return Number of occurrences, or -1 if there is no end date + */ + public int getCount(){ + if (mPeriodEnd == null) + return -1; + + int multiple = mPeriodType.getMultiplier(); + ReadablePeriod jodaPeriod; + switch (mPeriodType){ + case DAY: + jodaPeriod = Days.days(multiple); + break; + case WEEK: + jodaPeriod = Weeks.weeks(multiple); + break; + case MONTH: + jodaPeriod = Months.months(multiple); + break; + case YEAR: + jodaPeriod = Years.years(multiple); + break; + default: + jodaPeriod = Months.months(multiple); + } + int count = 0; + LocalDateTime startTime = new LocalDateTime(mPeriodStart.getTime()); + while (startTime.toDateTime().getMillis() < mPeriodEnd.getTime()){ + ++count; + startTime = startTime.plus(jodaPeriod); + } + return count; + +/* + //this solution does not use looping, but is not very accurate + + int multiplier = mPeriodType.getMultiplier(); + LocalDateTime startDate = new LocalDateTime(mPeriodStart.getTime()); + LocalDateTime endDate = new LocalDateTime(mPeriodEnd.getTime()); + switch (mPeriodType){ + case DAY: + return Days.daysBetween(startDate, endDate).dividedBy(multiplier).getDays(); + case WEEK: + return Weeks.weeksBetween(startDate, endDate).dividedBy(multiplier).getWeeks(); + case MONTH: + return Months.monthsBetween(startDate, endDate).dividedBy(multiplier).getMonths(); + case YEAR: + return Years.yearsBetween(startDate, endDate).dividedBy(multiplier).getYears(); + default: + return -1; + } +*/ + } + + /** + * Sets the end time of this recurrence by specifying the number of occurences + * @param numberOfOccurences Number of occurences from the start time + */ + public void setPeriodEnd(int numberOfOccurences){ + LocalDateTime localDate = new LocalDateTime(mPeriodStart.getTime()); + LocalDateTime endDate; + int occurrenceDuration = numberOfOccurences * mPeriodType.getMultiplier(); + switch (mPeriodType){ + case DAY: + endDate = localDate.plusDays(occurrenceDuration); + break; + case WEEK: + endDate = localDate.plusWeeks(occurrenceDuration); + break; + default: + case MONTH: + endDate = localDate.plusMonths(occurrenceDuration); + break; + case YEAR: + endDate = localDate.plusYears(occurrenceDuration); + break; + } + mPeriodEnd = new Timestamp(endDate.toDateTime().getMillis()); + } + + /** + * Return the end date of the period in milliseconds + * @return End date of the recurrence period + */ + public Timestamp getPeriodEnd(){ + return mPeriodEnd; + } + + /** + * Set period end date + * @param endTimestamp End time in milliseconds + */ + public void setPeriodEnd(Timestamp endTimestamp){ + mPeriodEnd = endTimestamp; + } +} diff --git a/app/src/main/java/org/gnucash/android/model/ScheduledAction.java b/app/src/main/java/org/gnucash/android/model/ScheduledAction.java index bea49f09c..f7279af5a 100644 --- a/app/src/main/java/org/gnucash/android/model/ScheduledAction.java +++ b/app/src/main/java/org/gnucash/android/model/ScheduledAction.java @@ -15,11 +15,16 @@ */ package org.gnucash.android.model; -import org.gnucash.android.ui.util.RecurrenceParser; -import org.joda.time.format.DateTimeFormat; -import org.joda.time.format.DateTimeFormatter; +import android.content.Context; +import android.support.annotation.NonNull; -import java.io.IOException; +import org.gnucash.android.R; +import org.gnucash.android.app.GnuCashApplication; +import org.joda.time.DateTime; +import org.joda.time.LocalDate; +import org.joda.time.LocalDateTime; + +import java.sql.Timestamp; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; @@ -33,16 +38,19 @@ */ public class ScheduledAction extends BaseModel{ - private long mPeriod; private long mStartDate; private long mEndDate; private String mTag; + /** + * Recurrence of this scheduled action + */ + private Recurrence mRecurrence; + /** * Types of events which can be scheduled */ - public enum ActionType {TRANSACTION, BACKUP - } + public enum ActionType {TRANSACTION, BACKUP} /** * Next scheduled run of Event @@ -66,7 +74,7 @@ public enum ActionType {TRANSACTION, BACKUP private ActionType mActionType; /** - * Number of times this event is to be executed + * Number of times this event is planned to be executed */ private int mTotalFrequency = 0; @@ -77,13 +85,15 @@ public enum ActionType {TRANSACTION, BACKUP /** * Flag for whether the scheduled transaction should be auto-created - * TODO: Add this flag to the database. At the moment we always treat it as true */ - private boolean autoCreate = true; + private boolean mAutoCreate = true; + private boolean mAutoNotify = false; + private int mAdvanceCreateDays = 0; + private int mAdvanceNotifyDays = 0; + private String mTemplateAccountUID; public ScheduledAction(ActionType actionType){ mActionType = actionType; - mStartDate = System.currentTimeMillis(); mEndDate = 0; mIsEnabled = true; //all actions are enabled by default } @@ -122,104 +132,93 @@ public void setActionUID(String actionUID) { /** * Returns the timestamp of the last execution of this scheduled action + *

This is not necessarily the time when the scheduled action was due, only when it was actually last executed.

* @return Timestamp in milliseconds since Epoch */ - public long getLastRun() { + public long getLastRunTime() { return mLastRun; } /** - * Set time of last execution of the scheduled action - * @param nextRun Timestamp in milliseconds since Epoch + * Returns the time when the last schedule in the sequence of planned executions was executed. + * This relies on the number of executions of the scheduled action + *

This is different from {@link #getLastRunTime()} which returns the date when the system last + * run the scheduled action.

+ * @return Time of last schedule, or -1 if the scheduled action has never been run */ - public void setLastRun(long nextRun) { - this.mLastRun = nextRun; - } + public long getTimeOfLastSchedule(){ + if (mExecutionCount == 0) + return -1; - /** - * Returns the period of this scheduled action - * @return Period in milliseconds since Epoch - */ - public long getPeriod() { - return mPeriod; - } + LocalDateTime startTime = LocalDateTime.fromDateFields(new Date(mStartDate)); + int multiplier = mRecurrence.getPeriodType().getMultiplier(); - /** - * Sets the period of the scheduled action - * @param period Period in milliseconds since Epoch - */ - public void setPeriod(long period) { - this.mPeriod = period; + int factor = (mExecutionCount-1) * multiplier; + switch (mRecurrence.getPeriodType()){ + case DAY: + startTime = startTime.plusDays(factor); + break; + case WEEK: + startTime = startTime.plusWeeks(factor); + break; + case MONTH: + startTime = startTime.plusMonths(factor); + break; + case YEAR: + startTime = startTime.plusYears(factor); + break; + } + + return startTime.toDate().getTime(); } /** - * Sets the period given the period type. - * The {@link PeriodType} should have the multiplier set, - * e.g. bi-weekly actions have period type {@link PeriodType#WEEK} and multiplier 2 - * @param periodType Type of period + * Computes the next time that this scheduled action is supposed to be executed + *

This method does not consider the end time, or number of times it should be run. + * It only considers when the next execution would theoretically be due

+ * @return Next run time in milliseconds */ - public void setPeriod(PeriodType periodType){ - int multiplier = periodType.getMultiplier(); - switch (periodType){ + public long computeNextScheduledExecutionTime(){ + int multiplier = mRecurrence.getPeriodType().getMultiplier(); + //this is the last planned time for the action to occur, not the last run time + long lastActionTime = getTimeOfLastSchedule(); //mStartDate + ((mExecutionCount-1)*getPeriod()); + if (lastActionTime < 0){ + return mStartDate; + } + + LocalDateTime localDate = LocalDateTime.fromDateFields(new Date(lastActionTime)); + switch (mRecurrence.getPeriodType()) { case DAY: - mPeriod = RecurrenceParser.DAY_MILLIS * multiplier; + localDate = localDate.plusDays(multiplier); break; case WEEK: - mPeriod = RecurrenceParser.WEEK_MILLIS * multiplier; + localDate = localDate.plusWeeks(multiplier); break; case MONTH: - mPeriod = RecurrenceParser.MONTH_MILLIS * multiplier; + localDate = localDate.plusMonths(multiplier); break; case YEAR: - mPeriod = RecurrenceParser.YEAR_MILLIS * multiplier; + localDate = localDate.plusYears(multiplier); break; } + return localDate.toDate().getTime(); } /** - * Returns the period type for this scheduled action - * @return Period type of the action + * Set time of last execution of the scheduled action + * @param nextRun Timestamp in milliseconds since Epoch */ - public PeriodType getPeriodType(){ - return getPeriodType(mPeriod); + public void setLastRun(long nextRun) { + this.mLastRun = nextRun; } /** - * Computes the {@link PeriodType} for a given {@code period} - * @param period Period in milliseconds since Epoch - * @return PeriodType corresponding to the period - */ - public static PeriodType getPeriodType(long period){ - PeriodType periodType = PeriodType.DAY; - int result = (int) (period/RecurrenceParser.YEAR_MILLIS); - if (result > 0) { - periodType = PeriodType.YEAR; - periodType.setMultiplier(result); - return periodType; - } - - result = (int) (period/RecurrenceParser.MONTH_MILLIS); - if (result > 0) { - periodType = PeriodType.MONTH; - periodType.setMultiplier(result); - return periodType; - } - - result = (int) (period/RecurrenceParser.WEEK_MILLIS); - if (result > 0) { - periodType = PeriodType.WEEK; - periodType.setMultiplier(result); - return periodType; - } - - result = (int) (period/RecurrenceParser.DAY_MILLIS); - if (result > 0) { - periodType = PeriodType.DAY; - periodType.setMultiplier(result); - return periodType; - } - - return periodType; + * Returns the period of this scheduled action in milliseconds. + * @return Period in milliseconds since Epoch + * @deprecated Uses fixed values for time of months and years (which actually vary depending on number of days in month or leap year) + */ + public long getPeriod() { + return mRecurrence.getPeriod(); } /** @@ -236,6 +235,9 @@ public long getStartTime() { */ public void setStartTime(long startDate) { this.mStartDate = startDate; + if (mRecurrence != null) { + mRecurrence.setPeriodStart(new Timestamp(startDate)); + } } /** @@ -246,22 +248,15 @@ public long getEndTime() { return mEndDate; } - /** - * Returns the approximate end time of this scheduled action. - *

This is useful when the number of occurences was set, rather than a specific end time. - * The end time is then computed from the start time, period and number of occurrences.

- * @return End time in milliseconds for the scheduled action - */ - public long getApproxEndTime(){ - return mStartDate + (mPeriod * mTotalFrequency); - } - /** * Sets the end time of the scheduled action * @param endDate Timestamp in milliseconds since Epoch */ public void setEndTime(long endDate) { this.mEndDate = endDate; + if (mRecurrence != null){ + mRecurrence.setPeriodEnd(new Timestamp(mEndDate)); + } } /** @@ -302,19 +297,19 @@ public void setEnabled(boolean enabled){ } /** - * Returns the total number of occurences of this scheduled action. - * @return Total number of occurences of this action + * Returns the total number of planned occurrences of this scheduled action. + * @return Total number of planned occurrences of this action */ - public int getTotalFrequency(){ + public int getTotalPlannedExecutionCount(){ return mTotalFrequency; } /** * Sets the number of occurences of this action - * @param occurencesCount Number of occurences + * @param plannedExecutions Number of occurences */ - public void setTotalFrequency(int occurencesCount){ - this.mTotalFrequency = occurencesCount; + public void setTotalPlannedExecutionCount(int plannedExecutions){ + this.mTotalFrequency = plannedExecutions; } /** @@ -335,18 +330,94 @@ public void setExecutionCount(int executionCount){ /** * Returns flag if transactions should be automatically created or not + *

This flag is currently unused in the app. It is only included here for compatibility with GnuCash desktop XML

* @return {@code true} if the transaction should be auto-created, {@code false} otherwise */ public boolean shouldAutoCreate() { - return autoCreate; + return mAutoCreate; } /** * Set flag for automatically creating transaction based on this scheduled action + *

This flag is currently unused in the app. It is only included here for compatibility with GnuCash desktop XML

* @param autoCreate Flag for auto creating transactions */ public void setAutoCreate(boolean autoCreate) { - this.autoCreate = autoCreate; + this.mAutoCreate = autoCreate; + } + + /** + * Check if user will be notified of creation of scheduled transactions + *

This flag is currently unused in the app. It is only included here for compatibility with GnuCash desktop XML

+ * @return {@code true} if user will be notified, {@code false} otherwise + */ + public boolean shouldAutoNotify() { + return mAutoNotify; + } + + /** + * Sets whether to notify the user that scheduled transactions have been created + *

This flag is currently unused in the app. It is only included here for compatibility with GnuCash desktop XML

+ * @param autoNotify Boolean flag + */ + public void setAutoNotify(boolean autoNotify) { + this.mAutoNotify = autoNotify; + } + + /** + * Returns number of days in advance to create the transaction + *

This flag is currently unused in the app. It is only included here for compatibility with GnuCash desktop XML

+ * @return Number of days in advance to create transaction + */ + public int getAdvanceCreateDays() { + return mAdvanceCreateDays; + } + + /** + * Set number of days in advance to create the transaction + *

This flag is currently unused in the app. It is only included here for compatibility with GnuCash desktop XML

+ * @param advanceCreateDays Number of days + */ + public void setAdvanceCreateDays(int advanceCreateDays) { + this.mAdvanceCreateDays = advanceCreateDays; + } + + /** + * Returns the number of days in advance to notify of scheduled transactions + *

This flag is currently unused in the app. It is only included here for compatibility with GnuCash desktop XML

+ * @return {@code true} if user will be notified, {@code false} otherwise + */ + public int getAdvanceNotifyDays() { + return mAdvanceNotifyDays; + } + + /** + * Set number of days in advance to notify of scheduled transactions + *

This flag is currently unused in the app. It is only included here for compatibility with GnuCash desktop XML

+ * @param advanceNotifyDays Number of days + */ + public void setAdvanceNotifyDays(int advanceNotifyDays) { + this.mAdvanceNotifyDays = advanceNotifyDays; + } + + /** + * Return the template account GUID for this scheduled action + *

This method generates one if none was set

+ * @return String GUID of template account + */ + public String getTemplateAccountUID() { + if (mTemplateAccountUID == null) + return mTemplateAccountUID = generateUID(); + else + return mTemplateAccountUID; + } + + /** + * Set the template account GUID + * @param templateAccountUID String GUID of template account + */ + public void setTemplateAccountUID(String templateAccountUID) { + this.mTemplateAccountUID = templateAccountUID; } /** @@ -354,22 +425,12 @@ public void setAutoCreate(boolean autoCreate) { * @return String description of repeat schedule */ public String getRepeatString(){ - String dayOfWeek = new SimpleDateFormat("EEEE", Locale.US).format(new Date(mStartDate)); - PeriodType periodType = getPeriodType(); - StringBuilder ruleBuilder = new StringBuilder(periodType.getFrequencyRepeatString()); - - if (periodType == PeriodType.WEEK) { - ruleBuilder.append(" on ").append(dayOfWeek); + StringBuilder ruleBuilder = new StringBuilder(mRecurrence.getRepeatString()); + Context context = GnuCashApplication.getAppContext(); + if (mEndDate <= 0 && mTotalFrequency > 0){ + ruleBuilder.append(", ").append(context.getString(R.string.repeat_x_times, mTotalFrequency)); } - if (mEndDate > 0){ - ruleBuilder.append(", "); - ruleBuilder.append(" until ") - .append(SimpleDateFormat.getDateInstance(DateFormat.SHORT).format(new Date(mEndDate))); - } else if (mTotalFrequency > 0){ - ruleBuilder.append(", "); - ruleBuilder.append(" for ").append(mTotalFrequency).append(" times"); - } return ruleBuilder.toString(); } @@ -380,23 +441,8 @@ public String getRepeatString(){ */ public String getRuleString(){ String separator = ";"; - PeriodType periodType = getPeriodType(); - StringBuilder ruleBuilder = new StringBuilder(); - -// ======================================================================= - //This section complies with the formal rules, but the betterpickers library doesn't like/need it - -// SimpleDateFormat startDateFormat = new SimpleDateFormat("'TZID'=zzzz':'yyyyMMdd'T'HHmmss", Locale.US); -// ruleBuilder.append("DTSTART;"); -// ruleBuilder.append(startDateFormat.format(new Date(mStartDate))); -// ruleBuilder.append("\n"); -// ruleBuilder.append("RRULE:"); -// ======================================================================== - - ruleBuilder.append("FREQ=").append(periodType.getFrequencyDescription()).append(separator); - ruleBuilder.append("INTERVAL=").append(periodType.getMultiplier()).append(separator); - ruleBuilder.append(periodType.getByParts(mStartDate)).append(separator); + StringBuilder ruleBuilder = new StringBuilder(mRecurrence.getRuleString()); if (mEndDate > 0){ SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US); @@ -409,16 +455,63 @@ public String getRuleString(){ return ruleBuilder.toString(); } + /** + * Return GUID of recurrence pattern for this scheduled action + * @return {@link Recurrence} object + */ + public Recurrence getRecurrence() { + return mRecurrence; + } + + /** + * Overloaded method for setting the recurrence of the scheduled action. + *

This method allows you to specify the periodicity and the ordinal of it. For example, + * a recurrence every fortnight would give parameters: {@link PeriodType#WEEK}, ordinal:2

+ * @param periodType Periodicity of the scheduled action + * @param ordinal Ordinal of the periodicity. If unsure, specify 1 + * @see #setRecurrence(Recurrence) + */ + public void setRecurrence(PeriodType periodType, int ordinal){ + periodType.setMultiplier(ordinal); + Recurrence recurrence = new Recurrence(periodType); + setRecurrence(recurrence); + } + + /** + * Sets the recurrence pattern of this scheduled action + *

This also sets the start period of the recurrence object, if there is one

+ * @param recurrence {@link Recurrence} object + */ + public void setRecurrence(@NonNull Recurrence recurrence) { + this.mRecurrence = recurrence; + //if we were parsing XML and parsed the start and end date from the scheduled action first, + //then use those over the values which might be gotten from the recurrence + if (mStartDate > 0){ + mRecurrence.setPeriodStart(new Timestamp(mStartDate)); + } else { + mStartDate = mRecurrence.getPeriodStart().getTime(); + } + + if (mEndDate > 0){ + mRecurrence.setPeriodEnd(new Timestamp(mEndDate)); + } else if (mRecurrence.getPeriodEnd() != null){ + mEndDate = mRecurrence.getPeriodEnd().getTime(); + } + } + /** * Creates a ScheduledAction from a Transaction and a period * @param transaction Transaction to be scheduled * @param period Period in milliseconds since Epoch * @return Scheduled Action + * @deprecated Used for parsing legacy backup files. Use {@link Recurrence} instead */ + @Deprecated public static ScheduledAction parseScheduledAction(Transaction transaction, long period){ ScheduledAction scheduledAction = new ScheduledAction(ActionType.TRANSACTION); scheduledAction.mActionUID = transaction.getUID(); - scheduledAction.mPeriod = period; + Recurrence recurrence = new Recurrence(PeriodType.parse(period)); + scheduledAction.setRecurrence(recurrence); return scheduledAction; } diff --git a/app/src/main/java/org/gnucash/android/model/Split.java b/app/src/main/java/org/gnucash/android/model/Split.java index 5e19936c5..a3101e474 100644 --- a/app/src/main/java/org/gnucash/android/model/Split.java +++ b/app/src/main/java/org/gnucash/android/model/Split.java @@ -1,9 +1,13 @@ package org.gnucash.android.model; +import android.os.Parcel; +import android.os.Parcelable; import android.support.annotation.NonNull; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; + +import java.sql.Timestamp; /** * A split amount in a transaction. @@ -15,7 +19,24 @@ * * @author Ngewi Fet */ -public class Split extends BaseModel{ +public class Split extends BaseModel implements Parcelable{ + + /** + * Flag indicating that the split has been reconciled + */ + public static final char FLAG_RECONCILED = 'y'; + + /** + * Flag indicating that the split has not been reconciled + */ + public static final char FLAG_NOT_RECONCILED = 'n'; + + /** + * Flag indicating that the split has been cleared, but not reconciled + */ + public static final char FLAG_CLEARED = 'c'; + + /** * Amount value of this split which is in the currency of the transaction */ @@ -46,6 +67,13 @@ public class Split extends BaseModel{ */ private String mMemo; + private char mReconcileState = FLAG_NOT_RECONCILED; + + /** + * Database required non-null field + */ + private Timestamp mReconcileDate = new Timestamp(System.currentTimeMillis()); + /** * Initialize split with a value amount and account * @param value Money value amount of this split @@ -63,12 +91,13 @@ public Split(@NonNull Money value, @NonNull Money quantity, String accountUID){ /** * Initialize split with a value amount and account - * @param amount Money value amount of this split. Value is always in the currency the owning transaction - * @param accountUID String UID of transfer account + * @param amount Money value amount of this split. Value is always in the currency the owning transaction. + * This amount will be assigned as both the value and the quantity of this split + * @param accountUID String UID of owning account */ public Split(@NonNull Money amount, String accountUID){ - setQuantity(amount); setValue(amount); + setQuantity(new Money(amount)); setAccountUID(accountUID); //NOTE: This is a rather simplististic approach to the split type. //It typically also depends on the account type of the account. But we do not want to access @@ -209,7 +238,7 @@ public void setMemo(String memo) { * @see TransactionType#invert() */ public Split createPair(String accountUID){ - Split pair = new Split(mValue.absolute(), accountUID); + Split pair = new Split(mValue.abs(), accountUID); pair.setType(mSplitType.invert()); pair.setMemo(mMemo); pair.setTransactionUID(mTransactionUID); @@ -239,7 +268,7 @@ protected Split clone() throws CloneNotSupportedException { * @return whether the two splits are a pair */ public boolean isPairOf(Split other) { - return mValue.absolute().equals(other.mValue.absolute()) + return mValue.abs().equals(other.mValue.abs()) && mSplitType.invert().equals(other.mSplitType); } @@ -272,7 +301,7 @@ public Money getFormattedQuantity(){ */ public static Money getFormattedAmount(Money amount, String accountUID, TransactionType splitType){ boolean isDebitAccount = AccountsDbAdapter.getInstance().getAccountType(accountUID).hasDebitNormalBalance(); - Money absAmount = amount.absolute(); + Money absAmount = amount.abs(); boolean isDebitSplit = splitType == TransactionType.DEBIT; if (isDebitAccount) { @@ -290,6 +319,63 @@ public static Money getFormattedAmount(Money amount, String accountUID, Transact } } + /** + * Return the reconciled state of this split + *

+ * The reconciled state is one of the following values: + *

    + *
  • y: means this split has been reconciled
  • + *
  • n: means this split is not reconciled
  • + *
  • c: means split has been cleared, but not reconciled
  • + *
+ *


+ * You can check the return value against the reconciled flags {@link #FLAG_RECONCILED}, {@link #FLAG_NOT_RECONCILED}, {@link #FLAG_CLEARED} + * @return Character showing reconciled state + */ + public char getReconcileState() { + return mReconcileState; + } + + /** + * Check if this split is reconciled + * @return {@code true} if the split is reconciled, {@code false} otherwise + */ + public boolean isReconciled(){ + return mReconcileState == FLAG_RECONCILED; + } + + /** + * Set reconciled state of this split. + *

+ * The reconciled state is one of the following values: + *

    + *
  • y: means this split has been reconciled
  • + *
  • n: means this split is not reconciled
  • + *
  • c: means split has been cleared, but not reconciled
  • + *
+ *

+ * @param reconcileState One of the following flags {@link #FLAG_RECONCILED}, {@link #FLAG_NOT_RECONCILED}, {@link #FLAG_CLEARED} + */ + public void setReconcileState(char reconcileState) { + this.mReconcileState = reconcileState; + } + + /** + * Return the date of reconciliation + * @return Timestamp + */ + public Timestamp getReconcileDate() { + return mReconcileDate; + } + + /** + * Set reconciliation date for this split + * @param reconcileDate Timestamp of reconciliation + */ + public void setReconcileDate(Timestamp reconcileDate) { + this.mReconcileDate = reconcileDate; + } + @Override public String toString() { return mSplitType.name() + " of " + mValue.toString() + " in account: " + mAccountUID; @@ -305,7 +391,7 @@ public String toString() { */ public String toCsv(){ String sep = ";"; - + //TODO: add reconciled state and date String splitString = getUID() + sep + mValue.getNumerator() + sep + mValue.getDenominator() + sep + mValue.getCurrency().getCurrencyCode() + sep + mQuantity.getNumerator() + sep + mQuantity.getDenominator() + sep + mQuantity.getCurrency().getCurrencyCode() + sep + mTransactionUID + sep + mAccountUID + sep + mSplitType.name(); @@ -325,6 +411,7 @@ public String toCsv(){ * @return Split instance parsed from the string */ public static Split parseSplit(String splitCsvString) { + //TODO: parse reconciled state and date String[] tokens = splitCsvString.split(";"); if (tokens.length < 8) { //old format splits Money amount = new Money(tokens[0], tokens[1]); @@ -357,4 +444,132 @@ public static Split parseSplit(String splitCsvString) { return split; } } + + /** + * Two splits are considered equivalent if all the fields (excluding GUID and timestamps - created, modified, reconciled) are equal. + * Any two splits which are equal are also equivalent, but the reverse is not true + *

The difference with to {@link #equals(Object)} is that the GUID of the split is not considered. + * This is useful in cases where a new split is generated for a transaction with the same properties, + * but a new GUID is generated e.g. when editing a transaction and modifying the splits

+ * + * @param split Other split for which to test equivalence + * @return {@code true} if both splits are equivalent, {@code false} otherwise + */ + public boolean isEquivalentTo(Split split){ + if (this == split) return true; + if (super.equals(split)) return true; + + if (mReconcileState != split.mReconcileState) return false; + if (!mValue.equals(split.mValue)) return false; + if (!mQuantity.equals(split.mQuantity)) return false; + if (!mTransactionUID.equals(split.mTransactionUID)) return false; + if (!mAccountUID.equals(split.mAccountUID)) return false; + if (mSplitType != split.mSplitType) return false; + return mMemo != null ? mMemo.equals(split.mMemo) : split.mMemo == null; + } + + /** + * Two splits are considered equal if all their properties excluding timestampes (created, modified, reconciled) are equal. + * @param o Other split to compare for equality + * @return {@code true} if this split is equal to {@code o}, {@code false} otherwise + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + + Split split = (Split) o; + + if (mReconcileState != split.mReconcileState) return false; + if (!mValue.equals(split.mValue)) return false; + if (!mQuantity.equals(split.mQuantity)) return false; + if (!mTransactionUID.equals(split.mTransactionUID)) return false; + if (!mAccountUID.equals(split.mAccountUID)) return false; + if (mSplitType != split.mSplitType) return false; + return mMemo != null ? mMemo.equals(split.mMemo) : split.mMemo == null; + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + mValue.hashCode(); + result = 31 * result + mQuantity.hashCode(); + result = 31 * result + mTransactionUID.hashCode(); + result = 31 * result + mAccountUID.hashCode(); + result = 31 * result + mSplitType.hashCode(); + result = 31 * result + (mMemo != null ? mMemo.hashCode() : 0); + result = 31 * result + (int) mReconcileState; + return result; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(getUID()); + dest.writeString(mAccountUID); + dest.writeString(mTransactionUID); + dest.writeString(mSplitType.name()); + + dest.writeLong(mValue.getNumerator()); + dest.writeLong(mValue.getDenominator()); + dest.writeString(mValue.getCommodity().getCurrencyCode()); + + dest.writeLong(mQuantity.getNumerator()); + dest.writeLong(mQuantity.getDenominator()); + dest.writeString(mQuantity.getCommodity().getCurrencyCode()); + + dest.writeString(mMemo == null ? "" : mMemo); + dest.writeString(String.valueOf(mReconcileState)); + dest.writeString(mReconcileDate.toString()); + } + + /** + * Constructor for creating a Split object from a Parcel + * @param source Source parcel containing the split + * @see #CREATOR + */ + private Split(Parcel source){ + setUID(source.readString()); + mAccountUID = source.readString(); + mTransactionUID = source.readString(); + mSplitType = TransactionType.valueOf(source.readString()); + + long valueNum = source.readLong(); + long valueDenom = source.readLong(); + String valueCurrency = source.readString(); + mValue = new Money(valueNum, valueDenom, valueCurrency); + + long qtyNum = source.readLong(); + long qtyDenom = source.readLong(); + String qtyCurrency = source.readString(); + mQuantity = new Money(qtyNum, qtyDenom, qtyCurrency); + + String memo = source.readString(); + mMemo = memo.isEmpty() ? null : memo; + mReconcileState = source.readString().charAt(0); + mReconcileDate = Timestamp.valueOf(source.readString()); + } + + /** + * Creates new Parcels containing the information in this split during serialization + */ + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + + @Override + public Split createFromParcel(Parcel source) { + return new Split(source); + } + + @Override + public Split[] newArray(int size) { + return new Split[size]; + } + }; + } diff --git a/app/src/main/java/org/gnucash/android/model/Transaction.java b/app/src/main/java/org/gnucash/android/model/Transaction.java index fd7528e45..7ff34aa65 100644 --- a/app/src/main/java/org/gnucash/android/model/Transaction.java +++ b/app/src/main/java/org/gnucash/android/model/Transaction.java @@ -19,7 +19,7 @@ import android.content.Intent; import org.gnucash.android.BuildConfig; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.export.ofx.OfxHelper; import org.gnucash.android.model.Account.OfxAccountType; import org.w3c.dom.Document; @@ -171,25 +171,37 @@ private void initDefaults(){ } /** - * Creates a split which will balance the transaction - *

Note:If a transaction has splits with different currencies, not auto-balancing will be performed.

+ * Creates a split which will balance the transaction, in value. + *

Note:If a transaction has splits with different currencies, no auto-balancing will be performed.

* *

The added split will not use any account in db, but will use currency code as account UID. * The added split will be returned, to be filled with proper account UID later.

* @return Split whose amount is the imbalance of this transaction */ - public Split getAutoBalanceSplit(){ - Money imbalance = getImbalance(); + public Split createAutoBalanceSplit(){ + Money imbalance = getImbalance(); //returns imbalance of 0 for multicurrency transactions if (!imbalance.isAmountZero()){ - Currency currency = Currency.getInstance(mCurrencyCode); - Split split = new Split(imbalance.negate(), - currency.getCurrencyCode()); + Split split = new Split(imbalance.negate(), mCurrencyCode); //yes, this is on purpose + //the account UID is set to the currency. This should be overridden before saving to db addSplit(split); return split; } return null; } + /** + * Set the GUID of the transaction + * If the transaction has Splits, their transactionGUID will be updated as well + * @param uid String unique ID + */ + @Override + public void setUID(String uid) { + super.setUID(uid); + for (Split split : mSplitList) { + split.setTransactionUID(uid); + } + } + /** * Returns list of splits for this transaction * @return {@link java.util.List} of splits in the transaction @@ -250,19 +262,19 @@ public Money getBalance(String accountUID){ /** * Computes the imbalance amount for the given transaction. * In double entry, all transactions should resolve to zero. But imbalance occurs when there are unresolved splits. - *

If it is a multi-currency transaction, an imbalance of zero will be returned

+ *

Note: If this is a multi-currency transaction, an imbalance of zero will be returned

* @return Money imbalance of the transaction or zero if it is a multi-currency transaction */ public Money getImbalance(){ Money imbalance = Money.createZeroInstance(mCurrencyCode); for (Split split : mSplitList) { - if (!split.getValue().getCurrency().getCurrencyCode().equals(mCurrencyCode)) { + if (!split.getQuantity().getCurrency().getCurrencyCode().equals(mCurrencyCode)) { // this may happen when importing XML exported from GNCA before 2.0.0 // these transactions should only be imported from XML exported from GNC desktop // so imbalance split should not be generated for them return Money.createZeroInstance(mCurrencyCode); } - Money amount = split.getValue().absolute(); + Money amount = split.getValue().abs(); if (split.getType() == TransactionType.DEBIT) imbalance = imbalance.subtract(amount); else @@ -293,9 +305,9 @@ public static Money computeBalance(String accountUID, List splitList) { continue; Money absAmount; if (split.getValue().getCurrency() == accountCurrency){ - absAmount = split.getValue().absolute(); + absAmount = split.getValue().abs(); } else { //if this split belongs to the account, then either its value or quantity is in the account currency - absAmount = split.getQuantity().absolute(); + absAmount = split.getQuantity().abs(); } boolean isDebitSplit = split.getType() == TransactionType.DEBIT; if (isDebitAccount) { diff --git a/app/src/main/java/org/gnucash/android/receivers/AccountCreator.java b/app/src/main/java/org/gnucash/android/receivers/AccountCreator.java index 100ae5891..2daf82442 100644 --- a/app/src/main/java/org/gnucash/android/receivers/AccountCreator.java +++ b/app/src/main/java/org/gnucash/android/receivers/AccountCreator.java @@ -22,40 +22,48 @@ import android.os.Bundle; import android.util.Log; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; import org.gnucash.android.model.Account; import org.gnucash.android.model.Commodity; /** * Broadcast receiver responsible for creating {@link Account}s received through intents. - * In order to create an Account, you need to broadcast an {@link Intent} with arguments - * for the name, currency and optionally, a unique identifier for the account (which should be unique to Gnucash) + * In order to create an Account, you need to broadcast an {@link Intent} with arguments + * for the name, currency and optionally, a unique identifier for the account (which should be unique to Gnucash) * of the Account to be created. Also remember to set the right mime type so that Android can properly route the Intent - * Note This Broadcast receiver requires the permission "org.gnucash.android.permission.CREATE_ACCOUNT" + * Note This Broadcast receiver requires the permission "org.gnucash.android.permission.CREATE_ACCOUNT" * in order to be able to use Intents to create accounts. So remember to declare it in your manifest + * * @author Ngewi Fet * @see {@link Account#EXTRA_CURRENCY_CODE}, {@link Account#MIME_TYPE} {@link Intent#EXTRA_TITLE}, {@link Intent#EXTRA_UID} */ public class AccountCreator extends BroadcastReceiver { - @Override - public void onReceive(Context context, Intent intent) { - Log.i("Gnucash", "Received account creation intent"); - Bundle args = intent.getExtras(); - - Account account = new Account(args.getString(Intent.EXTRA_TITLE)); - account.setParentUID(args.getString(Account.EXTRA_PARENT_UID)); - - String currencyCode = args.getString(Account.EXTRA_CURRENCY_CODE); - if (currencyCode != null){ - account.setCommodity(Commodity.getInstance(currencyCode)); - } - - String uid = args.getString(Intent.EXTRA_UID); - if (uid != null) - account.setUID(uid); - - AccountsDbAdapter.getInstance().addRecord(account); - } + @Override + public void onReceive(Context context, Intent intent) { + Log.i("Gnucash", "Received account creation intent"); + Bundle args = intent.getExtras(); + + Account account = new Account(args.getString(Intent.EXTRA_TITLE)); + account.setParentUID(args.getString(Account.EXTRA_PARENT_UID)); + + String currencyCode = args.getString(Account.EXTRA_CURRENCY_CODE); + if (currencyCode != null) { + Commodity commodity = Commodity.getInstance(currencyCode); + if (commodity != null) { + account.setCommodity(commodity); + } else { + throw new IllegalArgumentException("Commodity with '" + currencyCode + + "' currency code not found in the database"); + } + } + + String uid = args.getString(Intent.EXTRA_UID); + if (uid != null) + account.setUID(uid); + + AccountsDbAdapter.getInstance().addRecord(account, DatabaseAdapter.UpdateMethod.insert); + } } diff --git a/app/src/main/java/org/gnucash/android/receivers/TransactionRecorder.java b/app/src/main/java/org/gnucash/android/receivers/TransactionRecorder.java index 469b0a997..a1c76c85f 100644 --- a/app/src/main/java/org/gnucash/android/receivers/TransactionRecorder.java +++ b/app/src/main/java/org/gnucash/android/receivers/TransactionRecorder.java @@ -24,8 +24,10 @@ import com.crashlytics.android.Crashlytics; -import org.gnucash.android.db.CommoditiesDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; + import org.gnucash.android.model.Account; import org.gnucash.android.model.Commodity; import org.gnucash.android.model.Money; @@ -78,7 +80,7 @@ public void onReceive(Context context, Intent intent) { Commodity commodity = CommoditiesDbAdapter.getInstance().getCommodity(currencyCode); amountBigDecimal = amountBigDecimal.setScale(commodity.getSmallestFractionDigits(), BigDecimal.ROUND_HALF_EVEN).round(MathContext.DECIMAL128); Money amount = new Money(amountBigDecimal, Commodity.getInstance(currencyCode)); - Split split = new Split(amount.absolute(), accountUID); + Split split = new Split(amount.abs(), accountUID); split.setType(type); transaction.addSplit(split); @@ -103,7 +105,7 @@ public void onReceive(Context context, Intent intent) { } } - TransactionsDbAdapter.getInstance().addRecord(transaction); + TransactionsDbAdapter.getInstance().addRecord(transaction, DatabaseAdapter.UpdateMethod.insert); WidgetConfigurationActivity.updateAllWidgets(context); } diff --git a/app/src/main/java/org/gnucash/android/service/ScheduledActionService.java b/app/src/main/java/org/gnucash/android/service/ScheduledActionService.java new file mode 100644 index 000000000..b8de7c520 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/service/ScheduledActionService.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.service; + +import android.app.IntentService; +import android.content.ContentValues; +import android.content.Intent; +import android.database.sqlite.SQLiteDatabase; +import android.os.PowerManager; +import android.support.annotation.VisibleForTesting; +import android.util.Log; + +import com.crashlytics.android.Crashlytics; + +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseHelper; +import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.RecurrenceDbAdapter; +import org.gnucash.android.db.adapter.ScheduledActionDbAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; +import org.gnucash.android.export.ExportAsyncTask; +import org.gnucash.android.export.ExportParams; +import org.gnucash.android.model.Book; +import org.gnucash.android.model.ScheduledAction; +import org.gnucash.android.model.Transaction; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.ExecutionException; + +/** + * Service for running scheduled events. + *

The service is started and goes through all scheduled event entries in the the database and executes them. + * Then it is stopped until the next time it is run.
+ * Scheduled runs of the service should be achieved using an {@link android.app.AlarmManager}

+ * @author Ngewi Fet + */ +public class ScheduledActionService extends IntentService { + + public static final String LOG_TAG = "ScheduledActionService"; + + public ScheduledActionService() { + super(LOG_TAG); + } + + @Override + protected void onHandleIntent(Intent intent) { + Log.i(LOG_TAG, "Starting scheduled action service"); + + PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); + PowerManager.WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOG_TAG); + wakeLock.acquire(); + + try { + BooksDbAdapter booksDbAdapter = BooksDbAdapter.getInstance(); + List books = booksDbAdapter.getAllRecords(); + for (Book book : books) { + DatabaseHelper dbHelper = new DatabaseHelper(GnuCashApplication.getAppContext(), book.getUID()); + SQLiteDatabase db = dbHelper.getWritableDatabase(); + RecurrenceDbAdapter recurrenceDbAdapter = new RecurrenceDbAdapter(db); + ScheduledActionDbAdapter scheduledActionDbAdapter = new ScheduledActionDbAdapter(db, recurrenceDbAdapter); + + List scheduledActions = scheduledActionDbAdapter.getAllEnabledScheduledActions(); + Log.i(LOG_TAG, String.format("Processing %d total scheduled actions for Book: %s", + scheduledActions.size(), book.getDisplayName())); + processScheduledActions(scheduledActions, db); + } + + Log.i(LOG_TAG, "Completed service @ " + java.text.DateFormat.getDateTimeInstance().format(new Date())); + + } finally { //release the lock either way + wakeLock.release(); + } + } + + /** + * Process scheduled actions and execute any pending actions + * @param scheduledActions List of scheduled actions + */ + //made public static for testing. Do not call these methods directly + @VisibleForTesting + public static void processScheduledActions(List scheduledActions, SQLiteDatabase db) { + for (ScheduledAction scheduledAction : scheduledActions) { + + long now = System.currentTimeMillis(); + int totalPlannedExecutions = scheduledAction.getTotalPlannedExecutionCount(); + int executionCount = scheduledAction.getExecutionCount(); + + //the end time of the ScheduledAction is not handled here because + //it is handled differently for transactions and backups. See the individual methods. + if (scheduledAction.getStartTime() > now //if schedule begins in the future + || !scheduledAction.isEnabled() // of if schedule is disabled + || (totalPlannedExecutions > 0 && executionCount >= totalPlannedExecutions)) { //limit was set and we reached or exceeded it + Log.i(LOG_TAG, "Skipping scheduled action: " + scheduledAction.toString()); + continue; + } + + executeScheduledEvent(scheduledAction, db); + } + } + + /** + * Executes a scheduled event according to the specified parameters + * @param scheduledAction ScheduledEvent to be executed + */ + private static void executeScheduledEvent(ScheduledAction scheduledAction, SQLiteDatabase db){ + Log.i(LOG_TAG, "Executing scheduled action: " + scheduledAction.toString()); + int executionCount = scheduledAction.getExecutionCount(); + + switch (scheduledAction.getActionType()){ + case TRANSACTION: + executionCount += executeTransactions(scheduledAction, db); + break; + + case BACKUP: + executionCount += executeBackup(scheduledAction, db); + break; + } + + //the last run time is computed instead of just using "now" so that if the more than + // one period has been skipped, all intermediate transactions can be created + + //update the last run time and execution count + ContentValues contentValues = new ContentValues(); + contentValues.put(DatabaseSchema.ScheduledActionEntry.COLUMN_LAST_RUN, System.currentTimeMillis()); + contentValues.put(DatabaseSchema.ScheduledActionEntry.COLUMN_EXECUTION_COUNT, executionCount); + new ScheduledActionDbAdapter(db, new RecurrenceDbAdapter(db)).updateRecord(scheduledAction.getUID(), contentValues); + + //set the values in the object because they will be checked for the next iteration in the calling loop + scheduledAction.setExecutionCount(executionCount); + } + + /** + * Executes scheduled backups for a given scheduled action. + * The backup will be executed only once, even if multiple schedules were missed + * @param scheduledAction Scheduled action referencing the backup + * @param db SQLiteDatabase to backup + * @return Number of times backup is executed. This should either be 1 or 0 + */ + private static int executeBackup(ScheduledAction scheduledAction, SQLiteDatabase db) { + int executionCount = 0; + long now = System.currentTimeMillis(); + long endTime = scheduledAction.getEndTime(); + + if (endTime > 0 && endTime < now) + return executionCount; + + ExportParams params = ExportParams.parseCsv(scheduledAction.getTag()); + try { + //wait for async task to finish before we proceed (we are holding a wake lock) + new ExportAsyncTask(GnuCashApplication.getAppContext(), db).execute(params).get(); + scheduledAction.setExecutionCount(++executionCount); + } catch (InterruptedException | ExecutionException e) { + Crashlytics.logException(e); + Log.e(LOG_TAG, e.getMessage()); + } + return executionCount; + } + + /** + * Executes scheduled transactions which are to be added to the database. + *

If a schedule was missed, all the intervening transactions will be generated, even if + * the end time of the transaction was already reached

+ * @param scheduledAction Scheduled action which references the transaction + * @param db SQLiteDatabase where the transactions are to be executed + * @return Number of transactions created as a result of this action + */ + private static int executeTransactions(ScheduledAction scheduledAction, SQLiteDatabase db) { + int executionCount = 0; + String actionUID = scheduledAction.getActionUID(); + TransactionsDbAdapter transactionsDbAdapter = new TransactionsDbAdapter(db, new SplitsDbAdapter(db)); + Transaction trxnTemplate = transactionsDbAdapter.getRecord(actionUID); + + long now = System.currentTimeMillis(); + //if there is an end time in the past, we execute all schedules up to the end time. + //if the end time is in the future, we execute all schedules until now (current time) + //if there is no end time, we execute all schedules until now + long endTime = scheduledAction.getEndTime() > 0 ? Math.min(scheduledAction.getEndTime(), now) : now; + int totalPlannedExecutions = scheduledAction.getTotalPlannedExecutionCount(); + List transactions = new ArrayList<>(); + + //we may be executing scheduled action significantly after scheduled time (depending on when Android fires the alarm) + //so compute the actual transaction time from pre-known values + long transactionTime = scheduledAction.computeNextScheduledExecutionTime(); + while (transactionTime <= endTime) { + Transaction recurringTrxn = new Transaction(trxnTemplate, true); + recurringTrxn.setTime(transactionTime); + transactions.add(recurringTrxn); + recurringTrxn.setScheduledActionUID(scheduledAction.getUID()); + scheduledAction.setExecutionCount(++executionCount); //required for computingNextScheduledExecutionTime + + if (totalPlannedExecutions > 0 && executionCount >= totalPlannedExecutions) + break; //if we hit the total planned executions set, then abort + transactionTime = scheduledAction.computeNextScheduledExecutionTime(); + } + + transactionsDbAdapter.bulkAddRecords(transactions, DatabaseAdapter.UpdateMethod.insert); + return executionCount; + } +} diff --git a/app/src/main/java/org/gnucash/android/service/SchedulerService.java b/app/src/main/java/org/gnucash/android/service/SchedulerService.java deleted file mode 100644 index 232f48c6f..000000000 --- a/app/src/main/java/org/gnucash/android/service/SchedulerService.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (c) 2015 Ngewi Fet - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.gnucash.android.service; - -import android.app.IntentService; -import android.content.ContentValues; -import android.content.Intent; -import android.os.PowerManager; -import android.os.SystemClock; -import android.util.Log; - -import com.crashlytics.android.Crashlytics; - -import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.db.ScheduledActionDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; -import org.gnucash.android.export.ExportAsyncTask; -import org.gnucash.android.export.ExportParams; -import org.gnucash.android.model.ScheduledAction; -import org.gnucash.android.model.Transaction; - -import java.sql.Timestamp; -import java.util.List; -import java.util.concurrent.ExecutionException; - -/** - * Service for running scheduled events. - *

The service is started and goes through all scheduled event entries in the the database and executes them. - * Then it is stopped until the next time it is run.
- * Scheduled runs of the service should be achieved using an {@link android.app.AlarmManager}

- * @author Ngewi Fet - */ -public class SchedulerService extends IntentService { - - public static final String LOG_TAG = "SchedulerService"; - - /** - * Creates an IntentService - * - */ - public SchedulerService() { - super(LOG_TAG); - } - - @Override - protected void onHandleIntent(Intent intent) { - Log.i(LOG_TAG, "Starting scheduled action service"); - PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); - PowerManager.WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, - LOG_TAG); - wakeLock.acquire(); - - ScheduledActionDbAdapter scheduledActionDbAdapter = GnuCashApplication.getScheduledEventDbAdapter(); - List scheduledActions = scheduledActionDbAdapter.getAllEnabledScheduledActions(); - - for (ScheduledAction scheduledAction : scheduledActions) { - long lastRun = scheduledAction.getLastRun(); - long period = scheduledAction.getPeriod(); - long endTime = scheduledAction.getEndTime(); - - long now = System.currentTimeMillis(); - - if (((endTime > 0 && now < endTime) //if and endTime is set and we did not reach it yet - || (scheduledAction.getExecutionCount() < scheduledAction.getTotalFrequency()) //or the number of scheduled runs - || (endTime == 0 && scheduledAction.getTotalFrequency() == 0)) //or the action is to run forever - && ((lastRun + period) <= now) //one period has passed since last execution - && scheduledAction.getStartTime() <= now - && scheduledAction.isEnabled()){ //the start time has arrived - executeScheduledEvent(scheduledAction); - } - } - - Log.i(LOG_TAG, "Completed service @ " + SystemClock.elapsedRealtime()); - - wakeLock.release(); - } - - /** - * Executes a scheduled event according to the specified parameters - * @param scheduledAction ScheduledEvent to be executed - */ - private void executeScheduledEvent(ScheduledAction scheduledAction){ - switch (scheduledAction.getActionType()){ - case TRANSACTION: - String eventUID = scheduledAction.getActionUID(); - TransactionsDbAdapter transactionsDbAdapter = TransactionsDbAdapter.getInstance(); - Transaction trxnTemplate = transactionsDbAdapter.getRecord(eventUID); - Transaction recurringTrxn = new Transaction(trxnTemplate, true); - - //we may be executing scheduled action significantly after scheduled time (depending on when Android fires the alarm) - //so compute the actual transaction time from pre-known values - long transactionTime; //default - if (scheduledAction.getLastRun() > 0){ - transactionTime = scheduledAction.getLastRun() + scheduledAction.getPeriod(); - } else { - transactionTime = scheduledAction.getStartTime() + scheduledAction.getPeriod(); - } - recurringTrxn.setTime(transactionTime); - recurringTrxn.setCreatedTimestamp(new Timestamp(transactionTime)); - transactionsDbAdapter.addRecord(recurringTrxn); - break; - - case BACKUP: - ExportParams params = ExportParams.parseCsv(scheduledAction.getTag()); - try { - //wait for async task to finish before we proceed (we are holding a wake lock) - new ExportAsyncTask(GnuCashApplication.getAppContext()).execute(params).get(); - } catch (InterruptedException | ExecutionException e) { - //TODO: Create special log for scheduler service - Crashlytics.logException(e); - Log.e(LOG_TAG, e.getMessage()); - return; //return immediately, do not update last run time of event - } - break; - } - - //update the last run time and execution count - ContentValues contentValues = new ContentValues(); - contentValues.put(DatabaseSchema.ScheduledActionEntry.COLUMN_LAST_RUN, System.currentTimeMillis()); - contentValues.put(DatabaseSchema.ScheduledActionEntry.COLUMN_EXECUTION_COUNT, scheduledAction.getExecutionCount()+1); - ScheduledActionDbAdapter.getInstance().updateRecord(scheduledAction.getUID(), contentValues); - } -} diff --git a/app/src/main/java/org/gnucash/android/ui/account/AccountFormFragment.java b/app/src/main/java/org/gnucash/android/ui/account/AccountFormFragment.java index a6aa11250..066e68d10 100644 --- a/app/src/main/java/org/gnucash/android/ui/account/AccountFormFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/account/AccountFormFragment.java @@ -26,7 +26,6 @@ import android.database.Cursor; import android.graphics.Color; import android.os.Bundle; -import android.preference.PreferenceManager; import android.support.design.widget.TextInputLayout; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; @@ -54,9 +53,10 @@ import android.widget.Spinner; import org.gnucash.android.R; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.CommoditiesDbAdapter; import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; import org.gnucash.android.model.Account; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.Commodity; @@ -65,6 +65,7 @@ import org.gnucash.android.ui.colorpicker.ColorPickerSwatch; import org.gnucash.android.ui.colorpicker.ColorSquare; import org.gnucash.android.ui.common.UxArgument; +import org.gnucash.android.ui.settings.PreferenceActivity; import org.gnucash.android.util.CommoditiesCursorAdapter; import org.gnucash.android.util.QualifiedAccountNameCursorAdapter; @@ -237,7 +238,7 @@ public void onCreate(Bundle savedInstanceState) { setHasOptionsMenu(true); mAccountsDbAdapter = AccountsDbAdapter.getInstance(); - SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + SharedPreferences sharedPrefs = PreferenceActivity.getActiveBookSharedPreferences(); mUseDoubleEntry = sharedPrefs.getBoolean(getString(R.string.key_use_double_entry), true); } @@ -375,7 +376,7 @@ private void initializeViewsWithAccount(Account account){ setParentAccountSelection(mAccountsDbAdapter.getID(mParentAccountUID)); } - String currencyCode = account.getCurrency().getCurrencyCode(); + String currencyCode = account.getCommodity().getCurrencyCode(); setSelectedCurrency(currencyCode); if (mAccountsDbAdapter.getTransactionMaxSplitNum(mAccount.getUID()) > 1) @@ -724,6 +725,8 @@ public void onDestroy() { */ private void saveAccount() { Log.i("AccountFormFragment", "Saving account"); + if (mAccountsDbAdapter == null) + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); // accounts to update, in case we're updating full names of a sub account tree ArrayList accountsToUpdate = new ArrayList<>(); boolean nameChanged = false; @@ -735,6 +738,7 @@ private void saveAccount() { return; } mAccount = new Account(getEnteredName()); + mAccountsDbAdapter.addRecord(mAccount, DatabaseAdapter.UpdateMethod.insert); //new account, insert it } else { nameChanged = !mAccount.getName().equals(getEnteredName()); @@ -793,9 +797,7 @@ private void saveAccount() { // parent change, update all full names of descent accounts accountsToUpdate.addAll(mAccountsDbAdapter.getSimpleAccountList( DatabaseSchema.AccountEntry.COLUMN_UID + " IN ('" + - TextUtils.join("','", mDescendantAccountUIDs) + "')", - null, - null + TextUtils.join("','", mDescendantAccountUIDs) + "')", null, null )); } HashMap mapAccount = new HashMap<>(); @@ -818,12 +820,9 @@ private void saveAccount() { } } accountsToUpdate.add(mAccount); - if (mAccountsDbAdapter == null) - mAccountsDbAdapter = AccountsDbAdapter.getInstance(); + // bulk update, will not update transactions - mAccountsDbAdapter.enableForeignKey(false); - mAccountsDbAdapter.bulkAddRecords(accountsToUpdate); - mAccountsDbAdapter.enableForeignKey(true); + mAccountsDbAdapter.bulkAddRecords(accountsToUpdate, DatabaseAdapter.UpdateMethod.update); finishFragment(); } diff --git a/app/src/main/java/org/gnucash/android/ui/account/AccountsActivity.java b/app/src/main/java/org/gnucash/android/ui/account/AccountsActivity.java index 34e9e3c8f..0591ccc2d 100644 --- a/app/src/main/java/org/gnucash/android/ui/account/AccountsActivity.java +++ b/app/src/main/java/org/gnucash/android/ui/account/AccountsActivity.java @@ -34,18 +34,16 @@ import android.net.Uri; import android.os.Build; import android.os.Bundle; -import android.preference.PreferenceManager; import android.support.design.widget.CoordinatorLayout; import android.support.design.widget.FloatingActionButton; import android.support.design.widget.Snackbar; import android.support.design.widget.TabLayout; import android.support.v4.app.Fragment; -import android.support.v4.app.FragmentActivity; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentPagerAdapter; -import android.support.v4.view.PagerAdapter; import android.support.v4.view.ViewPager; -import android.support.v7.widget.Toolbar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.preference.PreferenceManager; import android.util.Log; import android.util.SparseArray; import android.view.Menu; @@ -61,24 +59,20 @@ import org.gnucash.android.BuildConfig; import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; import org.gnucash.android.export.xml.GncXmlExporter; import org.gnucash.android.importer.ImportAsyncTask; import org.gnucash.android.ui.common.BaseDrawerActivity; import org.gnucash.android.ui.common.FormActivity; +import org.gnucash.android.ui.common.Refreshable; import org.gnucash.android.ui.common.UxArgument; import org.gnucash.android.ui.transaction.TransactionsActivity; -import org.gnucash.android.ui.util.OnAccountClickedListener; -import org.gnucash.android.ui.util.Refreshable; import org.gnucash.android.ui.util.TaskDelegate; import org.gnucash.android.ui.wizard.FirstRunWizardActivity; -import java.io.FileNotFoundException; -import java.io.InputStream; - import butterknife.Bind; -import butterknife.ButterKnife; /** * Manages actions related to accounts, displaying, exporting and creating new accounts @@ -150,7 +144,8 @@ public class AccountsActivity extends BaseDrawerActivity implements OnAccountCli /** * Configuration for rating the app */ - public static RateThisApp.Config rateAppConfig = new RateThisApp.Config(30, 100); + public static RateThisApp.Config rateAppConfig = new RateThisApp.Config(14, 100); + private AccountViewPagerAdapter mPagerAdapter; /** * Adapter for managing the sub-account and transaction fragment pages in the accounts view @@ -163,23 +158,24 @@ public AccountViewPagerAdapter(FragmentManager fm){ @Override public Fragment getItem(int i) { - AccountsListFragment currentFragment; - switch (i){ - case INDEX_RECENT_ACCOUNTS_FRAGMENT: - currentFragment = AccountsListFragment.newInstance(AccountsListFragment.DisplayMode.RECENT); - break; - - case INDEX_FAVORITE_ACCOUNTS_FRAGMENT: - currentFragment = AccountsListFragment.newInstance(AccountsListFragment.DisplayMode.FAVORITES); - break; - - case INDEX_TOP_LEVEL_ACCOUNTS_FRAGMENT: - default: - currentFragment = AccountsListFragment.newInstance(AccountsListFragment.DisplayMode.TOP_LEVEL); - break; + AccountsListFragment currentFragment = (AccountsListFragment) mFragmentPageReferenceMap.get(i); + if (currentFragment == null) { + switch (i) { + case INDEX_RECENT_ACCOUNTS_FRAGMENT: + currentFragment = AccountsListFragment.newInstance(AccountsListFragment.DisplayMode.RECENT); + break; + + case INDEX_FAVORITE_ACCOUNTS_FRAGMENT: + currentFragment = AccountsListFragment.newInstance(AccountsListFragment.DisplayMode.FAVORITES); + break; + + case INDEX_TOP_LEVEL_ACCOUNTS_FRAGMENT: + default: + currentFragment = AccountsListFragment.newInstance(AccountsListFragment.DisplayMode.TOP_LEVEL); + break; + } + mFragmentPageReferenceMap.put(i, currentFragment); } - - mFragmentPageReferenceMap.put(i, currentFragment); return currentFragment; } @@ -212,19 +208,25 @@ public int getCount() { public AccountsListFragment getCurrentAccountListFragment(){ int index = mViewPager.getCurrentItem(); - return (AccountsListFragment)(mFragmentPageReferenceMap.get(index)); + Fragment fragment = (Fragment) mFragmentPageReferenceMap.get(index); + if (fragment == null) + fragment = mPagerAdapter.getItem(index); + return (AccountsListFragment) fragment; } + @Override + public int getContentView() { + return R.layout.activity_accounts; + } - @Override + @Override + public int getTitleRes() { + return R.string.title_accounts; + } + + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_accounts); - setUpDrawer(); - ButterKnife.bind(this); - - Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); - setSupportActionBar(toolbar); final Intent intent = getIntent(); handleOpenFileIntent(intent); @@ -238,7 +240,7 @@ public void onCreate(Bundle savedInstanceState) { tabLayout.setTabGravity(TabLayout.GRAVITY_FILL); //show the simple accounts list - PagerAdapter mPagerAdapter = new AccountViewPagerAdapter(getSupportFragmentManager()); + mPagerAdapter = new AccountViewPagerAdapter(getSupportFragmentManager()); mViewPager.setAdapter(mPagerAdapter); mViewPager.addOnPageChangeListener(new TabLayout.TabLayoutOnPageChangeListener(tabLayout)); @@ -250,19 +252,16 @@ public void onTabSelected(TabLayout.Tab tab) { @Override public void onTabUnselected(TabLayout.Tab tab) { - + //nothing to see here, move along } @Override public void onTabReselected(TabLayout.Tab tab) { - + //nothing to see here, move along } }); - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - int lastTabIndex = preferences.getInt(LAST_OPEN_TAB_INDEX, INDEX_TOP_LEVEL_ACCOUNTS_FRAGMENT); - int index = intent.getIntExtra(EXTRA_TAB_INDEX, lastTabIndex); - mViewPager.setCurrentItem(index); + setCurrentTab(); mFloatingActionButton.setOnClickListener(new View.OnClickListener() { @Override @@ -348,17 +347,24 @@ public void onRequestPermissionsResult(int requestCode, String[] permissions, in @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); - int index = intent.getIntExtra(EXTRA_TAB_INDEX, INDEX_TOP_LEVEL_ACCOUNTS_FRAGMENT); - setTab(index); + setIntent(intent); + setCurrentTab(); + + int index = mViewPager.getCurrentItem(); + Fragment fragment = (Fragment) mFragmentPageReferenceMap.get(index); + if (fragment != null) + ((Refreshable)fragment).refresh(); handleOpenFileIntent(intent); } /** * Sets the current tab in the ViewPager - * @param index Index of fragment to be loaded */ - public void setTab(int index){ + public void setCurrentTab(){ + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + int lastTabIndex = preferences.getInt(LAST_OPEN_TAB_INDEX, INDEX_TOP_LEVEL_ACCOUNTS_FRAGMENT); + int index = getIntent().getIntExtra(EXTRA_TAB_INDEX, lastTabIndex); mViewPager.setCurrentItem(index); } @@ -367,16 +373,18 @@ public void setTab(int index){ *

Also handles displaying the What's New dialog

*/ private void init() { - PreferenceManager.setDefaultValues(this, R.xml.fragment_transaction_preferences, false); + PreferenceManager.setDefaultValues(this, BooksDbAdapter.getInstance().getActiveBookUID(), + Context.MODE_PRIVATE, R.xml.fragment_transaction_preferences, true); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); boolean firstRun = prefs.getBoolean(getString(R.string.key_first_run), true); if (firstRun){ - startActivity(new Intent(this, FirstRunWizardActivity.class)); + startActivity(new Intent(GnuCashApplication.getAppContext(), FirstRunWizardActivity.class)); //default to using double entry and save the preference explicitly prefs.edit().putBoolean(getString(R.string.key_use_double_entry), true).apply(); + finish(); } else { getSDWritePermission(); } @@ -444,7 +452,7 @@ public void onClick(DialogInterface dialog, int which) { /** * Displays the dialog for exporting transactions */ - public static void openExportFragment(FragmentActivity activity) { + public static void openExportFragment(AppCompatActivity activity) { Intent intent = new Intent(activity, FormActivity.class); intent.putExtra(UxArgument.FORM_TYPE, FormActivity.FormType.EXPORT.name()); activity.startActivity(intent); @@ -493,22 +501,16 @@ public void onTaskComplete() { /** * Starts Intent chooser for selecting a GnuCash accounts file to import. - *

The {@code activity} is responsible for the actual import of the file and can do so by calling {@link #importXmlFileFromIntent(Activity, Intent)}
+ *

The {@code activity} is responsible for the actual import of the file and can do so by calling {@link #importXmlFileFromIntent(Activity, Intent, TaskDelegate)}
* The calling class should respond to the request code {@link AccountsActivity#REQUEST_PICK_ACCOUNTS_FILE} in its {@link #onActivityResult(int, int, Intent)} method

* @param activity Activity starting the request and will also handle the response - * @see #importXmlFileFromIntent(Activity, Intent) + * @see #importXmlFileFromIntent(Activity, Intent, TaskDelegate) */ public static void startXmlFileChooser(Activity activity) { Intent pickIntent = new Intent(Intent.ACTION_GET_CONTENT); -// ArrayList mimeTypes = new ArrayList<>(); -// mimeTypes.add("application/*"); -// mimeTypes.add("file/*"); -// mimeTypes.add("text/*"); -// mimeTypes.add("application/vnd.google-apps.file"); -// pickIntent.putStringArrayListExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); pickIntent.addCategory(Intent.CATEGORY_OPENABLE); pickIntent.setType("*/*"); - Intent chooser = Intent.createChooser(pickIntent, "Select GnuCash account file"); + Intent chooser = Intent.createChooser(pickIntent, "Select GnuCash account file"); //todo internationalize string try { activity.startActivityForResult(chooser, REQUEST_PICK_ACCOUNTS_FILE); @@ -519,6 +521,26 @@ public static void startXmlFileChooser(Activity activity) { } } + /** + * Overloaded method. + * Starts chooser for selecting a GnuCash account file to import + * @param fragment Fragment creating the chooser and which will also handle the result + * @see #startXmlFileChooser(Activity) + */ + public static void startXmlFileChooser(Fragment fragment) { + Intent pickIntent = new Intent(Intent.ACTION_GET_CONTENT); + pickIntent.addCategory(Intent.CATEGORY_OPENABLE); + pickIntent.setType("*/*"); + Intent chooser = Intent.createChooser(pickIntent, "Select GnuCash account file"); //todo internationalize string + + try { + fragment.startActivityForResult(chooser, REQUEST_PICK_ACCOUNTS_FILE); + } catch (ActivityNotFoundException ex){ + Crashlytics.log("No file manager for selecting files available"); + Crashlytics.logException(ex); + Toast.makeText(fragment.getActivity(), R.string.toast_install_file_manager, Toast.LENGTH_LONG).show(); + } + } /** * Reads and XML file from an intent and imports it into the database diff --git a/app/src/main/java/org/gnucash/android/ui/account/AccountsListFragment.java b/app/src/main/java/org/gnucash/android/ui/account/AccountsListFragment.java index a1623f233..9a4b0a9d9 100644 --- a/app/src/main/java/org/gnucash/android/ui/account/AccountsListFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/account/AccountsListFragment.java @@ -46,21 +46,26 @@ import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; +import android.widget.ProgressBar; import android.widget.TextView; import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; import org.gnucash.android.db.DatabaseCursorLoader; import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BudgetsDbAdapter; import org.gnucash.android.model.Account; +import org.gnucash.android.model.Budget; +import org.gnucash.android.model.Money; import org.gnucash.android.ui.common.FormActivity; +import org.gnucash.android.ui.common.Refreshable; import org.gnucash.android.ui.common.UxArgument; import org.gnucash.android.ui.util.AccountBalanceTask; import org.gnucash.android.ui.util.CursorRecyclerAdapter; import org.gnucash.android.ui.util.widget.EmptyRecyclerView; -import org.gnucash.android.ui.util.OnAccountClickedListener; -import org.gnucash.android.ui.util.Refreshable; + +import java.util.List; import butterknife.Bind; import butterknife.ButterKnife; @@ -99,6 +104,11 @@ public enum DisplayMode { */ protected static final String TAG = "AccountsListFragment"; + /** + * Tag to save {@link AccountsListFragment#mDisplayMode} to fragment state + */ + private static final String STATE_DISPLAY_MODE = "mDisplayMode"; + /** * Database adapter for loading Account records from the database */ @@ -171,7 +181,8 @@ public void onCreate(Bundle savedInstanceState) { if (args != null) mParentAccountUID = args.getString(UxArgument.PARENT_ACCOUNT_UID); - mAccountsDbAdapter = AccountsDbAdapter.getInstance(); + if (savedInstanceState != null) + mDisplayMode = (DisplayMode) savedInstanceState.getSerializable(STATE_DISPLAY_MODE); } @Override @@ -188,14 +199,17 @@ public void onActivityCreated(Bundle savedInstanceState) { mAccountRecyclerAdapter = new AccountRecyclerAdapter(null); mRecyclerView.setAdapter(mAccountRecyclerAdapter); - getLoaderManager().initLoader(0, null, this); + } + + @Override + public void onStart() { + super.onStart(); + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); } @Override public void onResume() { super.onResume(); - ActionBar actionbar = ((AppCompatActivity) getActivity()).getSupportActionBar(); - actionbar.setTitle(R.string.title_accounts); refresh(); } @@ -274,6 +288,10 @@ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { @Override + /** + * Refresh the account list as a sublist of another account + * @param parentAccountUID GUID of the parent account + */ public void refresh(String parentAccountUID) { getArguments().putString(UxArgument.PARENT_ACCOUNT_UID, parentAccountUID); refresh(); @@ -288,12 +306,19 @@ public void refresh() { getLoaderManager().restartLoader(0, null, this); } + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putSerializable(STATE_DISPLAY_MODE, mDisplayMode); + } + /** * Closes any open database adapters used by the list */ @Override public void onDestroy() { super.onDestroy(); + mAccountRecyclerAdapter.swapCursor(null); } /** @@ -455,6 +480,7 @@ public AccountViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { @Override public void onBindViewHolderCursor(final AccountViewHolder holder, final Cursor cursor) { final String accountUID = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.AccountEntry.COLUMN_UID)); + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); holder.accoundId = mAccountsDbAdapter.getID(accountUID); holder.accountName.setText(cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.AccountEntry.COLUMN_NAME))); @@ -468,7 +494,7 @@ public void onBindViewHolderCursor(final AccountViewHolder holder, final Cursor // add a summary of transactions to the account view if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { - // Make sure the balance task is truely multithread + // Make sure the balance task is truly multithread new AccountBalanceTask(holder.accountBalance).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, accountUID); } else { new AccountBalanceTask(holder.accountBalance).execute(accountUID); @@ -494,6 +520,20 @@ public void onClick(View v) { }); } + List budgets = BudgetsDbAdapter.getInstance().getAccountBudgets(accountUID); + //TODO: include fetch only active budgets + if (budgets.size() == 1){ + Budget budget = budgets.get(0); + Money balance = mAccountsDbAdapter.getAccountBalance(accountUID, budget.getStartofCurrentPeriod(), budget.getEndOfCurrentPeriod()); + double budgetProgress = balance.divide(budget.getAmount(accountUID)).asBigDecimal().doubleValue() * 100; + + holder.budgetIndicator.setVisibility(View.VISIBLE); + holder.budgetIndicator.setProgress((int) budgetProgress); + } else { + holder.budgetIndicator.setVisibility(View.GONE); + } + + if (mAccountsDbAdapter.isFavoriteAccount(accountUID)){ holder.favoriteStatus.setImageResource(R.drawable.ic_star_black_24dp); } else { @@ -534,6 +574,7 @@ class AccountViewHolder extends RecyclerView.ViewHolder implements PopupMenu.OnM @Bind(R.id.favorite_status) ImageView favoriteStatus; @Bind(R.id.options_menu) ImageView optionsMenu; @Bind(R.id.account_color_strip) View colorStripView; + @Bind(R.id.budget_indicator) ProgressBar budgetIndicator; long accoundId; public AccountViewHolder(View itemView) { diff --git a/app/src/main/java/org/gnucash/android/ui/account/DeleteAccountDialogFragment.java b/app/src/main/java/org/gnucash/android/ui/account/DeleteAccountDialogFragment.java index c06d02823..bc84ac4e1 100644 --- a/app/src/main/java/org/gnucash/android/ui/account/DeleteAccountDialogFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/account/DeleteAccountDialogFragment.java @@ -32,16 +32,15 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.db.SplitsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.AccountType; -import org.gnucash.android.ui.util.Refreshable; +import org.gnucash.android.ui.common.Refreshable; import org.gnucash.android.ui.homescreen.WidgetConfigurationActivity; import org.gnucash.android.util.QualifiedAccountNameCursorAdapter; -import java.util.Currency; import java.util.List; /** @@ -57,31 +56,29 @@ public class DeleteAccountDialogFragment extends DialogFragment { /** * Spinner for selecting the account to move the transactions to */ - Spinner mTransactionsDestinationAccountSpinner; + private Spinner mTransactionsDestinationAccountSpinner; - Spinner mAccountsDestinationAccountSpinner; + private Spinner mAccountsDestinationAccountSpinner; /** * Dialog positive button. Ok to moving the transactions */ - Button mOkButton; + private Button mOkButton; /** * Cancel button */ - Button mCancelButton; + private Button mCancelButton; /** * GUID of account from which to move the transactions */ - String mOriginAccountUID = null; + private String mOriginAccountUID = null; - View mAccountOptionsView; - View mTransactionOptionsView; - RadioButton mMoveAccountsRadioButton; - RadioButton mMoveTransactionsRadioButton; - RadioButton mDeleteAccountsRadioButton; - RadioButton mDeleteTransactionsRadioButton; + private RadioButton mMoveAccountsRadioButton; + private RadioButton mMoveTransactionsRadioButton; + private RadioButton mDeleteAccountsRadioButton; + private RadioButton mDeleteTransactionsRadioButton; private int mTransactionCount; private int mSubAccountCount; @@ -108,24 +105,24 @@ public void onCreate(@Nullable Bundle savedInstanceState) { @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View view = inflater.inflate(R.layout.dialog_account_delete, container, false); - mTransactionOptionsView = view.findViewById(R.id.transactions_options); - ((TextView)mTransactionOptionsView.findViewById(R.id.title_content)).setText(R.string.section_header_transactions); - ((TextView)mTransactionOptionsView.findViewById(R.id.description)).setText(R.string.label_delete_account_transactions_description); - mDeleteTransactionsRadioButton = (RadioButton) mTransactionOptionsView.findViewById(R.id.radio_delete); + View transactionOptionsView = view.findViewById(R.id.transactions_options); + ((TextView) transactionOptionsView.findViewById(R.id.title_content)).setText(R.string.section_header_transactions); + ((TextView) transactionOptionsView.findViewById(R.id.description)).setText(R.string.label_delete_account_transactions_description); + mDeleteTransactionsRadioButton = (RadioButton) transactionOptionsView.findViewById(R.id.radio_delete); mDeleteTransactionsRadioButton.setText(R.string.label_delete_transactions); - mMoveTransactionsRadioButton = ((RadioButton)mTransactionOptionsView.findViewById(R.id.radio_move)); - mTransactionsDestinationAccountSpinner = (Spinner) mTransactionOptionsView.findViewById(R.id.target_accounts_spinner); + mMoveTransactionsRadioButton = (RadioButton) transactionOptionsView.findViewById(R.id.radio_move); + mTransactionsDestinationAccountSpinner = (Spinner) transactionOptionsView.findViewById(R.id.target_accounts_spinner); - mAccountOptionsView = view.findViewById(R.id.accounts_options); - ((TextView)mAccountOptionsView.findViewById(R.id.title_content)).setText(R.string.section_header_subaccounts); - ((TextView)mAccountOptionsView.findViewById(R.id.description)).setText(R.string.label_delete_account_subaccounts_description); - mDeleteAccountsRadioButton = (RadioButton) mAccountOptionsView.findViewById(R.id.radio_delete); + View accountOptionsView = view.findViewById(R.id.accounts_options); + ((TextView) accountOptionsView.findViewById(R.id.title_content)).setText(R.string.section_header_subaccounts); + ((TextView) accountOptionsView.findViewById(R.id.description)).setText(R.string.label_delete_account_subaccounts_description); + mDeleteAccountsRadioButton = (RadioButton) accountOptionsView.findViewById(R.id.radio_delete); mDeleteAccountsRadioButton.setText(R.string.label_delete_sub_accounts); - mMoveAccountsRadioButton = (RadioButton)mAccountOptionsView.findViewById(R.id.radio_move); - mAccountsDestinationAccountSpinner = (Spinner) mAccountOptionsView.findViewById(R.id.target_accounts_spinner); + mMoveAccountsRadioButton = (RadioButton) accountOptionsView.findViewById(R.id.radio_move); + mAccountsDestinationAccountSpinner = (Spinner) accountOptionsView.findViewById(R.id.target_accounts_spinner); - mTransactionOptionsView.setVisibility(mTransactionCount > 0 ? View.VISIBLE : View.GONE); - mAccountOptionsView.setVisibility(mSubAccountCount > 0 ? View.VISIBLE : View.GONE); + transactionOptionsView.setVisibility(mTransactionCount > 0 ? View.VISIBLE : View.GONE); + accountOptionsView.setVisibility(mSubAccountCount > 0 ? View.VISIBLE : View.GONE); mCancelButton = (Button) view.findViewById(R.id.btn_cancel); mOkButton = (Button) view.findViewById(R.id.btn_save); @@ -215,14 +212,14 @@ public void onClick(View v) { AccountsDbAdapter accountsDbAdapter = AccountsDbAdapter.getInstance(); - if (mMoveTransactionsRadioButton.isChecked()){ + if ((mTransactionCount > 0) && mMoveTransactionsRadioButton.isChecked()){ long targetAccountId = mTransactionsDestinationAccountSpinner.getSelectedItemId(); //move all the splits SplitsDbAdapter.getInstance().updateRecords(DatabaseSchema.SplitEntry.COLUMN_ACCOUNT_UID + " = ?", new String[]{mOriginAccountUID}, DatabaseSchema.SplitEntry.COLUMN_ACCOUNT_UID, accountsDbAdapter.getUID(targetAccountId)); } - if (mMoveAccountsRadioButton.isChecked()){ + if ((mSubAccountCount > 0) && mMoveAccountsRadioButton.isChecked()){ long targetAccountId = mAccountsDestinationAccountSpinner.getSelectedItemId(); AccountsDbAdapter.getInstance().reassignDescendantAccounts(mOriginAccountUID, accountsDbAdapter.getUID(targetAccountId)); } diff --git a/app/src/main/java/org/gnucash/android/ui/util/OnAccountClickedListener.java b/app/src/main/java/org/gnucash/android/ui/account/OnAccountClickedListener.java similarity index 96% rename from app/src/main/java/org/gnucash/android/ui/util/OnAccountClickedListener.java rename to app/src/main/java/org/gnucash/android/ui/account/OnAccountClickedListener.java index fb75cba2a..3deae2d86 100644 --- a/app/src/main/java/org/gnucash/android/ui/util/OnAccountClickedListener.java +++ b/app/src/main/java/org/gnucash/android/ui/account/OnAccountClickedListener.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.gnucash.android.ui.util; +package org.gnucash.android.ui.account; /** * Interface for implemented by activities which wish to be notified when diff --git a/app/src/main/java/org/gnucash/android/ui/budget/BudgetAmountEditorFragment.java b/app/src/main/java/org/gnucash/android/ui/budget/BudgetAmountEditorFragment.java new file mode 100644 index 000000000..776e1073a --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/budget/BudgetAmountEditorFragment.java @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.gnucash.android.ui.budget; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.inputmethodservice.KeyboardView; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import org.gnucash.android.R; +import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.model.BudgetAmount; +import org.gnucash.android.model.Commodity; +import org.gnucash.android.model.Money; +import org.gnucash.android.ui.common.UxArgument; +import org.gnucash.android.ui.util.widget.CalculatorEditText; +import org.gnucash.android.util.QualifiedAccountNameCursorAdapter; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Currency; +import java.util.List; + +import butterknife.Bind; +import butterknife.ButterKnife; + +/** + * Fragment for editing budgeting amounts + */ +public class BudgetAmountEditorFragment extends Fragment { + + private Cursor mAccountCursor; + private QualifiedAccountNameCursorAdapter mAccountCursorAdapter; + private List mBudgetAmountViews = new ArrayList<>(); + private AccountsDbAdapter mAccountsDbAdapter; + + @Bind(R.id.budget_amount_layout) LinearLayout mBudgetAmountTableLayout; + @Bind(R.id.calculator_keyboard) KeyboardView mKeyboardView; + + public static BudgetAmountEditorFragment newInstance(Bundle args){ + BudgetAmountEditorFragment fragment = new BudgetAmountEditorFragment(); + fragment.setArguments(args); + return fragment; + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_budget_amount_editor, container, false); + ButterKnife.bind(this, view); + setupAccountSpinnerAdapter(); + return view; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + ActionBar actionBar = ((AppCompatActivity)getActivity()).getSupportActionBar(); + assert actionBar != null; + actionBar.setTitle("Edit Budget Amounts"); + setHasOptionsMenu(true); + + ArrayList budgetAmounts = getArguments().getParcelableArrayList(UxArgument.BUDGET_AMOUNT_LIST); + if (budgetAmounts != null) { + if (budgetAmounts.isEmpty()){ + BudgetAmountViewHolder viewHolder = (BudgetAmountViewHolder) addBudgetAmountView(null).getTag(); + viewHolder.removeItemBtn.setVisibility(View.GONE); //there should always be at least one + } else { + loadBudgetAmountViews(budgetAmounts); + } + } else { + BudgetAmountViewHolder viewHolder = (BudgetAmountViewHolder) addBudgetAmountView(null).getTag(); + viewHolder.removeItemBtn.setVisibility(View.GONE); //there should always be at least one + } + + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.budget_amount_editor_actions, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()){ + case R.id.menu_add_budget_amount: + addBudgetAmountView(null); + return true; + + case R.id.menu_save: + saveBudgetAmounts(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } + + /** + * Checks if the budget amounts can be saved + * @return {@code true} if all amounts a properly entered, {@code false} otherwise + */ + private boolean canSave(){ + for (View budgetAmountView : mBudgetAmountViews) { + BudgetAmountViewHolder viewHolder = (BudgetAmountViewHolder) budgetAmountView.getTag(); + viewHolder.amountEditText.evaluate(); + if (viewHolder.amountEditText.getError() != null){ + return false; + } + //at least one account should be loaded (don't create budget with empty account tree + if (viewHolder.budgetAccountSpinner.getCount() == 0){ + Toast.makeText(getActivity(), "You need an account hierarchy to create a budget!", + Toast.LENGTH_SHORT).show(); + return false; + } + } + return true; + } + + private void saveBudgetAmounts() { + if (canSave()){ + ArrayList budgetAmounts = (ArrayList) extractBudgetAmounts(); + Intent data = new Intent(); + data.putParcelableArrayListExtra(UxArgument.BUDGET_AMOUNT_LIST, budgetAmounts); + getActivity().setResult(Activity.RESULT_OK, data); + getActivity().finish(); + } + } + + /** + * Load views for the budget amounts + * @param budgetAmounts List of {@link BudgetAmount}s + */ + private void loadBudgetAmountViews(List budgetAmounts){ + for (BudgetAmount budgetAmount : budgetAmounts) { + addBudgetAmountView(budgetAmount); + } + } + + /** + * Inflates a new BudgetAmount item view and adds it to the UI. + *

If the {@code budgetAmount} is not null, then it is used to initialize the view

+ * @param budgetAmount Budget amount + */ + private View addBudgetAmountView(BudgetAmount budgetAmount){ + LayoutInflater layoutInflater = getActivity().getLayoutInflater(); + View budgetAmountView = layoutInflater.inflate(R.layout.item_budget_amount, + mBudgetAmountTableLayout, false); + BudgetAmountViewHolder viewHolder = new BudgetAmountViewHolder(budgetAmountView); + if (budgetAmount != null){ + viewHolder.bindViews(budgetAmount); + } + mBudgetAmountTableLayout.addView(budgetAmountView, 0); + mBudgetAmountViews.add(budgetAmountView); +// mScrollView.fullScroll(ScrollView.FOCUS_DOWN); + return budgetAmountView; + } + + /** + * Loads the accounts in the spinner + */ + private void setupAccountSpinnerAdapter(){ + String conditions = "(" + DatabaseSchema.AccountEntry.COLUMN_HIDDEN + " = 0 )"; + + if (mAccountCursor != null) { + mAccountCursor.close(); + } + mAccountCursor = mAccountsDbAdapter.fetchAccountsOrderedByFullName(conditions, null); + + mAccountCursorAdapter = new QualifiedAccountNameCursorAdapter(getActivity(), mAccountCursor); + } + + /** + * Extract {@link BudgetAmount}s from the views + * @return List of budget amounts + */ + private List extractBudgetAmounts(){ + List budgetAmounts = new ArrayList<>(); + for (View view : mBudgetAmountViews) { + BudgetAmountViewHolder viewHolder = (BudgetAmountViewHolder) view.getTag(); + BigDecimal amountValue = viewHolder.amountEditText.getValue(); + if (amountValue == null) + continue; + Money amount = new Money(amountValue, Commodity.DEFAULT_COMMODITY); + String accountUID = mAccountsDbAdapter.getUID(viewHolder.budgetAccountSpinner.getSelectedItemId()); + BudgetAmount budgetAmount = new BudgetAmount(amount, accountUID); + budgetAmounts.add(budgetAmount); + } + return budgetAmounts; + } + + /** + * View holder for budget amounts + */ + class BudgetAmountViewHolder{ + @Bind(R.id.currency_symbol) TextView currencySymbolTextView; + @Bind(R.id.input_budget_amount) CalculatorEditText amountEditText; + @Bind(R.id.btn_remove_item) ImageView removeItemBtn; + @Bind(R.id.input_budget_account_spinner) Spinner budgetAccountSpinner; + View itemView; + + public BudgetAmountViewHolder(View view){ + itemView = view; + ButterKnife.bind(this, view); + itemView.setTag(this); + + amountEditText.bindListeners(mKeyboardView); + budgetAccountSpinner.setAdapter(mAccountCursorAdapter); + + budgetAccountSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + String currencyCode = mAccountsDbAdapter.getCurrencyCode(mAccountsDbAdapter.getUID(id)); + Currency currency = Currency.getInstance(currencyCode); + currencySymbolTextView.setText(currency.getSymbol()); + } + + @Override + public void onNothingSelected(AdapterView parent) { + //nothing to see here, move along + } + }); + + removeItemBtn.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mBudgetAmountTableLayout.removeView(itemView); + mBudgetAmountViews.remove(itemView); + } + }); + } + + public void bindViews(BudgetAmount budgetAmount){ + amountEditText.setValue(budgetAmount.getAmount().asBigDecimal()); + budgetAccountSpinner.setSelection(mAccountCursorAdapter.getPosition(budgetAmount.getAccountUID())); + } + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/budget/BudgetDetailFragment.java b/app/src/main/java/org/gnucash/android/ui/budget/BudgetDetailFragment.java new file mode 100644 index 000000000..730eb993d --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/budget/BudgetDetailFragment.java @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.ui.budget; + +import android.app.Activity; +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.Color; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.github.mikephil.charting.charts.BarChart; +import com.github.mikephil.charting.components.LimitLine; +import com.github.mikephil.charting.data.BarData; +import com.github.mikephil.charting.data.BarDataSet; +import com.github.mikephil.charting.data.BarEntry; + +import org.gnucash.android.R; +import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BudgetsDbAdapter; +import org.gnucash.android.model.Budget; +import org.gnucash.android.model.BudgetAmount; +import org.gnucash.android.model.Money; +import org.gnucash.android.ui.common.FormActivity; +import org.gnucash.android.ui.common.Refreshable; +import org.gnucash.android.ui.common.UxArgument; +import org.gnucash.android.ui.transaction.TransactionsActivity; +import org.gnucash.android.ui.util.widget.EmptyRecyclerView; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +import butterknife.Bind; +import butterknife.ButterKnife; + +/** + * Fragment for displaying budget details + */ +public class BudgetDetailFragment extends Fragment implements Refreshable { + @Bind(R.id.primary_text) TextView mBudgetNameTextView; + @Bind(R.id.secondary_text) TextView mBudgetDescriptionTextView; + @Bind(R.id.budget_recurrence) TextView mBudgetRecurrence; + @Bind(R.id.budget_amount_recycler) EmptyRecyclerView mRecyclerView; + + private String mBudgetUID; + private BudgetsDbAdapter mBudgetsDbAdapter; + + public static BudgetDetailFragment newInstance(String budgetUID){ + BudgetDetailFragment fragment = new BudgetDetailFragment(); + Bundle args = new Bundle(); + args.putString(UxArgument.BUDGET_UID, budgetUID); + fragment.setArguments(args); + return fragment; + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_budget_detail, container, false); + ButterKnife.bind(this, view); + mBudgetDescriptionTextView.setMaxLines(3); + + mRecyclerView.setHasFixedSize(true); + + if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { + GridLayoutManager gridLayoutManager = new GridLayoutManager(getActivity(), 2); + mRecyclerView.setLayoutManager(gridLayoutManager); + } else { + LinearLayoutManager mLayoutManager = new LinearLayoutManager(getActivity()); + mRecyclerView.setLayoutManager(mLayoutManager); + } + return view; + } + + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + mBudgetsDbAdapter = BudgetsDbAdapter.getInstance(); + mBudgetUID = getArguments().getString(UxArgument.BUDGET_UID); + bindViews(); + + setHasOptionsMenu(true); + } + + private void bindViews(){ + Budget budget = mBudgetsDbAdapter.getRecord(mBudgetUID); + mBudgetNameTextView.setText(budget.getName()); + + String description = budget.getDescription(); + if (description != null && !description.isEmpty()) + mBudgetDescriptionTextView.setText(description); + else { + mBudgetDescriptionTextView.setVisibility(View.GONE); + } + mBudgetRecurrence.setText(budget.getRecurrence().getRepeatString()); + + mRecyclerView.setAdapter(new BudgetAmountAdapter()); + } + + @Override + public void onResume() { + super.onResume(); + refresh(); + + View view = getActivity().findViewById(R.id.fab_create_budget); + if (view != null){ + view.setVisibility(View.GONE); + } + } + + @Override + public void refresh() { + bindViews(); + String budgetName = mBudgetsDbAdapter.getAttribute(mBudgetUID, DatabaseSchema.BudgetEntry.COLUMN_NAME); + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + assert actionBar != null; + actionBar.setTitle("Budget: " + budgetName); + } + + @Override + public void refresh(String budgetUID) { + mBudgetUID = budgetUID; + refresh(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.budget_actions, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()){ + case R.id.menu_edit_budget: + Intent addAccountIntent = new Intent(getActivity(), FormActivity.class); + addAccountIntent.setAction(Intent.ACTION_INSERT_OR_EDIT); + addAccountIntent.putExtra(UxArgument.FORM_TYPE, FormActivity.FormType.BUDGET.name()); + addAccountIntent.putExtra(UxArgument.BUDGET_UID, mBudgetUID); + startActivityForResult(addAccountIntent, 0x11); + return true; + + default: + return false; + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (resultCode == Activity.RESULT_OK){ + refresh(); + } + } + + + public class BudgetAmountAdapter extends RecyclerView.Adapter{ + private List mBudgetAmounts; + private Budget mBudget; + + public BudgetAmountAdapter(){ + mBudget = mBudgetsDbAdapter.getRecord(mBudgetUID); + mBudgetAmounts = mBudget.getCompactedBudgetAmounts(); + } + + @Override + public BudgetAmountViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = LayoutInflater.from(getActivity()).inflate(R.layout.cardview_budget_amount, parent, false); + return new BudgetAmountViewHolder(view); + } + + @Override + public void onBindViewHolder(BudgetAmountViewHolder holder, final int position) { + BudgetAmount budgetAmount = mBudgetAmounts.get(position); + Money projectedAmount = budgetAmount.getAmount(); + AccountsDbAdapter accountsDbAdapter = AccountsDbAdapter.getInstance(); + + holder.budgetAccount.setText(accountsDbAdapter.getAccountFullName(budgetAmount.getAccountUID())); + holder.budgetAmount.setText(projectedAmount.formattedString()); + + Money spentAmount = accountsDbAdapter.getAccountBalance(budgetAmount.getAccountUID(), + mBudget.getStartofCurrentPeriod(), mBudget.getEndOfCurrentPeriod()); + + holder.budgetSpent.setText(spentAmount.abs().formattedString()); + holder.budgetLeft.setText(projectedAmount.subtract(spentAmount.abs()).formattedString()); + + double budgetProgress = 0; + if (projectedAmount.asDouble() != 0){ + budgetProgress = spentAmount.asBigDecimal().divide(projectedAmount.asBigDecimal(), + spentAmount.getCurrency().getDefaultFractionDigits(), RoundingMode.HALF_EVEN) + .doubleValue(); + } + + holder.budgetIndicator.setProgress((int) (budgetProgress * 100)); + holder.budgetSpent.setTextColor(BudgetsActivity.getBudgetProgressColor(1 - budgetProgress)); + holder.budgetLeft.setTextColor(BudgetsActivity.getBudgetProgressColor(1 - budgetProgress)); + + generateChartData(holder.budgetChart, budgetAmount); + + holder.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Intent intent = new Intent(getActivity(), TransactionsActivity.class); + intent.putExtra(UxArgument.SELECTED_ACCOUNT_UID, mBudgetAmounts.get(position).getAccountUID()); + startActivityForResult(intent, 0x10); + } + }); + } + + /** + * Generate the chart data for the chart + * @param barChart View where to display the chart + * @param budgetAmount BudgetAmount to visualize + */ + public void generateChartData(BarChart barChart, BudgetAmount budgetAmount) { + // FIXME: 25.10.15 chart is broken + + AccountsDbAdapter accountsDbAdapter = AccountsDbAdapter.getInstance(); + + List barEntries = new ArrayList<>(); + List xVals = new ArrayList<>(); + + //todo: refactor getNumberOfPeriods into budget + int budgetPeriods = (int) mBudget.getNumberOfPeriods(); + budgetPeriods = budgetPeriods == 0 ? 12 : budgetPeriods; + int periods = mBudget.getRecurrence().getNumberOfPeriods(budgetPeriods); //// FIXME: 15.08.2016 why do we need number of periods + + for (int periodNum = 1; periodNum <= periods; periodNum++) { + BigDecimal amount = accountsDbAdapter.getAccountBalance(budgetAmount.getAccountUID(), + mBudget.getStartOfPeriod(periodNum), mBudget.getEndOfPeriod(periodNum)) + .asBigDecimal(); + + if (amount.equals(BigDecimal.ZERO)) + continue; + + barEntries.add(new BarEntry(amount.floatValue(), periodNum)); + xVals.add(mBudget.getRecurrence().getTextOfCurrentPeriod(periodNum)); + } + + String label = accountsDbAdapter.getAccountName(budgetAmount.getAccountUID()); + BarDataSet barDataSet = new BarDataSet(barEntries, label); + + BarData barData = new BarData(xVals, barDataSet); + LimitLine limitLine = new LimitLine(budgetAmount.getAmount().asBigDecimal().floatValue()); + limitLine.setLineWidth(2f); + limitLine.setLineColor(Color.RED); + + + barChart.setData(barData); + barChart.getAxisLeft().addLimitLine(limitLine); + BigDecimal maxValue = budgetAmount.getAmount().add(budgetAmount.getAmount().multiply(new BigDecimal("0.2"))).asBigDecimal(); + barChart.getAxisLeft().setAxisMaxValue(maxValue.floatValue()); + barChart.animateX(1000); + barChart.setAutoScaleMinMaxEnabled(true); + barChart.setDrawValueAboveBar(true); + barChart.invalidate(); + } + + @Override + public int getItemCount() { + return mBudgetAmounts.size(); + } + + class BudgetAmountViewHolder extends RecyclerView.ViewHolder { + @Bind(R.id.budget_account) TextView budgetAccount; + @Bind(R.id.budget_amount) TextView budgetAmount; + @Bind(R.id.budget_spent) TextView budgetSpent; + @Bind(R.id.budget_left) TextView budgetLeft; + @Bind(R.id.budget_indicator) ProgressBar budgetIndicator; + @Bind(R.id.budget_chart) BarChart budgetChart; + + public BudgetAmountViewHolder(View itemView) { + super(itemView); + ButterKnife.bind(this, itemView); + } + + } + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/budget/BudgetFormFragment.java b/app/src/main/java/org/gnucash/android/ui/budget/BudgetFormFragment.java new file mode 100644 index 000000000..e727912f2 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/budget/BudgetFormFragment.java @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.ui.budget; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.inputmethodservice.KeyboardView; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.design.widget.TextInputLayout; +import android.support.v4.app.Fragment; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import com.codetroopers.betterpickers.calendardatepicker.CalendarDatePickerDialogFragment; +import com.codetroopers.betterpickers.recurrencepicker.EventRecurrence; +import com.codetroopers.betterpickers.recurrencepicker.EventRecurrenceFormatter; +import com.codetroopers.betterpickers.recurrencepicker.RecurrencePickerDialogFragment; + +import org.gnucash.android.R; +import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BudgetsDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.model.Budget; +import org.gnucash.android.model.BudgetAmount; +import org.gnucash.android.model.Commodity; +import org.gnucash.android.model.Money; +import org.gnucash.android.model.Recurrence; +import org.gnucash.android.ui.common.FormActivity; +import org.gnucash.android.ui.common.UxArgument; +import org.gnucash.android.ui.transaction.TransactionFormFragment; +import org.gnucash.android.ui.util.RecurrenceParser; +import org.gnucash.android.ui.util.RecurrenceViewClickListener; +import org.gnucash.android.ui.util.widget.CalculatorEditText; +import org.gnucash.android.util.QualifiedAccountNameCursorAdapter; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; + +import butterknife.Bind; +import butterknife.ButterKnife; +import butterknife.OnClick; + +/** + * Fragment for creating or editing Budgets + */ +public class BudgetFormFragment extends Fragment implements RecurrencePickerDialogFragment.OnRecurrenceSetListener, CalendarDatePickerDialogFragment.OnDateSetListener { + + public static final int REQUEST_EDIT_BUDGET_AMOUNTS = 0xBA; + @Bind(R.id.input_budget_name) EditText mBudgetNameInput; + @Bind(R.id.input_description) EditText mDescriptionInput; + @Bind(R.id.input_recurrence) TextView mRecurrenceInput; + @Bind(R.id.name_text_input_layout) TextInputLayout mNameTextInputLayout; + @Bind(R.id.calculator_keyboard) KeyboardView mKeyboardView; + @Bind(R.id.input_budget_amount) CalculatorEditText mBudgetAmountInput; + @Bind(R.id.input_budget_account_spinner) Spinner mBudgetAccountSpinner; + @Bind(R.id.btn_add_budget_amount) Button mAddBudgetAmount; + @Bind(R.id.input_start_date) TextView mStartDateInput; + @Bind(R.id.budget_amount_layout) View mBudgetAmountLayout; + + EventRecurrence mEventRecurrence = new EventRecurrence(); + String mRecurrenceRule; + + private BudgetsDbAdapter mBudgetsDbAdapter; + + private Budget mBudget; + private Calendar mStartDate; + private ArrayList mBudgetAmounts; + private AccountsDbAdapter mAccountsDbAdapter; + private QualifiedAccountNameCursorAdapter mAccountsCursorAdapter; + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_budget_form, container, false); + ButterKnife.bind(this, view); + + view.findViewById(R.id.btn_remove_item).setVisibility(View.GONE); + mBudgetAmountInput.bindListeners(mKeyboardView); + mStartDateInput.setText(TransactionFormFragment.DATE_FORMATTER.format(mStartDate.getTime())); + return view; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mBudgetsDbAdapter = BudgetsDbAdapter.getInstance(); + mStartDate = Calendar.getInstance(); + mBudgetAmounts = new ArrayList<>(); + String conditions = "(" + DatabaseSchema.AccountEntry.COLUMN_HIDDEN + " = 0 )"; + mAccountsDbAdapter = AccountsDbAdapter.getInstance(); + Cursor accountCursor = mAccountsDbAdapter.fetchAccountsOrderedByFullName(conditions, null); + mAccountsCursorAdapter = new QualifiedAccountNameCursorAdapter(getActivity(), accountCursor); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + setHasOptionsMenu(true); + + mBudgetAccountSpinner.setAdapter(mAccountsCursorAdapter); + String budgetUID = getArguments().getString(UxArgument.BUDGET_UID); + if (budgetUID != null){ //if we are editing the budget + initViews(mBudget = mBudgetsDbAdapter.getRecord(budgetUID)); + } + ActionBar actionbar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + assert actionbar != null; + if (mBudget == null) + actionbar.setTitle("Create Budget"); + else + actionbar.setTitle("Edit Budget"); + + mRecurrenceInput.setOnClickListener( + new RecurrenceViewClickListener((AppCompatActivity) getActivity(), mRecurrenceRule, this)); + } + + /** + * Initialize views when editing an existing budget + * @param budget Budget to use to initialize the views + */ + private void initViews(Budget budget){ + mBudgetNameInput.setText(budget.getName()); + mDescriptionInput.setText(budget.getDescription()); + + String recurrenceRuleString = budget.getRecurrence().getRuleString(); + mRecurrenceRule = recurrenceRuleString; + mEventRecurrence.parse(recurrenceRuleString); + mRecurrenceInput.setText(budget.getRecurrence().getRepeatString()); + + mBudgetAmounts = (ArrayList) budget.getCompactedBudgetAmounts(); + toggleAmountInputVisibility(); + } + + /** + * Extracts the budget amounts from the form + *

If the budget amount was input using the simple form, then read the values.
+ * Else return the values gotten from the BudgetAmountEditor

+ * @return List of budget amounts + */ + private ArrayList extractBudgetAmounts(){ + BigDecimal value = mBudgetAmountInput.getValue(); + if (value == null) + return mBudgetAmounts; + + if (mBudgetAmounts.isEmpty()){ //has not been set in budget amounts editor + ArrayList budgetAmounts = new ArrayList<>(); + Money amount = new Money(value, Commodity.DEFAULT_COMMODITY); + String accountUID = mAccountsDbAdapter.getUID(mBudgetAccountSpinner.getSelectedItemId()); + BudgetAmount budgetAmount = new BudgetAmount(amount, accountUID); + budgetAmounts.add(budgetAmount); + return budgetAmounts; + } else { + return mBudgetAmounts; + } + } + + /** + * Checks that this budget can be saved + * Also sets the appropriate error messages on the relevant views + *

For a budget to be saved, it needs to have a name, an amount and a schedule

+ * @return {@code true} if the budget can be saved, {@code false} otherwise + */ + private boolean canSave(){ + if (mEventRecurrence.until != null && mEventRecurrence.until.length() > 0 + || mEventRecurrence.count <= 0){ + Toast.makeText(getActivity(), + "Set a number periods in the recurrence dialog to save the budget", + Toast.LENGTH_SHORT).show(); + return false; + } + + mBudgetAmounts = extractBudgetAmounts(); + String budgetName = mBudgetNameInput.getText().toString(); + boolean canSave = mRecurrenceRule != null + && !budgetName.isEmpty() + && !mBudgetAmounts.isEmpty(); + + if (!canSave){ + if (budgetName.isEmpty()){ + mNameTextInputLayout.setError("A name is required"); + mNameTextInputLayout.setErrorEnabled(true); + } else { + mNameTextInputLayout.setErrorEnabled(false); + } + + if (mBudgetAmounts.isEmpty()){ + mBudgetAmountInput.setError("Enter an amount for the budget"); + Toast.makeText(getActivity(), "Add budget amounts in order to save the budget", + Toast.LENGTH_SHORT).show(); + } + + if (mRecurrenceRule == null){ + Toast.makeText(getActivity(), "Set a repeat pattern to create a budget!", + Toast.LENGTH_SHORT).show(); + } + } + + return canSave; + } + + /** + * Extracts the information from the form and saves the budget + */ + private void saveBudget(){ + if (!canSave()) + return; + String name = mBudgetNameInput.getText().toString().trim(); + + + if (mBudget == null){ + mBudget = new Budget(name); + } else { + mBudget.setName(name); + } + + // TODO: 22.10.2015 set the period num of the budget amount + extractBudgetAmounts(); + mBudget.setBudgetAmounts(mBudgetAmounts); + + mBudget.setDescription(mDescriptionInput.getText().toString().trim()); + + Recurrence recurrence = RecurrenceParser.parse(mEventRecurrence); + recurrence.setPeriodStart(new Timestamp(mStartDate.getTimeInMillis())); + mBudget.setRecurrence(recurrence); + + mBudgetsDbAdapter.addRecord(mBudget, DatabaseAdapter.UpdateMethod.insert); + getActivity().finish(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.default_save_actions, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()){ + case R.id.menu_save: + saveBudget(); + return true; + } + return false; + } + + @OnClick(R.id.input_start_date) + public void onClickBudgetStartDate(View v) { + long dateMillis = 0; + try { + Date date = TransactionFormFragment.DATE_FORMATTER.parse(((TextView) v).getText().toString()); + dateMillis = date.getTime(); + } catch (ParseException e) { + Log.e(getTag(), "Error converting input time to Date object"); + } + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(dateMillis); + + int year = calendar.get(Calendar.YEAR); + int monthOfYear = calendar.get(Calendar.MONTH); + int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH); + CalendarDatePickerDialogFragment datePickerDialog = CalendarDatePickerDialogFragment.newInstance( + BudgetFormFragment.this, + year, monthOfYear, dayOfMonth); + datePickerDialog.show(getFragmentManager(), "date_picker_fragment"); + } + + @OnClick(R.id.btn_add_budget_amount) + public void onOpenBudgetAmountEditor(View v){ + Intent intent = new Intent(getActivity(), FormActivity.class); + intent.putExtra(UxArgument.FORM_TYPE, FormActivity.FormType.BUDGET_AMOUNT_EDITOR.name()); + mBudgetAmounts = extractBudgetAmounts(); + intent.putParcelableArrayListExtra(UxArgument.BUDGET_AMOUNT_LIST, mBudgetAmounts); + startActivityForResult(intent, REQUEST_EDIT_BUDGET_AMOUNTS); + } + + @Override + public void onRecurrenceSet(String rrule) { + mRecurrenceRule = rrule; + String repeatString = getString(R.string.label_tap_to_create_schedule); + if (mRecurrenceRule != null){ + mEventRecurrence.parse(mRecurrenceRule); + repeatString = EventRecurrenceFormatter.getRepeatString(getActivity(), getResources(), mEventRecurrence, true); + } + + mRecurrenceInput.setText(repeatString); + } + + @Override + public void onDateSet(CalendarDatePickerDialogFragment dialog, int year, int monthOfYear, int dayOfMonth) { + Calendar cal = new GregorianCalendar(year, monthOfYear, dayOfMonth); + mStartDateInput.setText(TransactionFormFragment.DATE_FORMATTER.format(cal.getTime())); + mStartDate.set(Calendar.YEAR, year); + mStartDate.set(Calendar.MONTH, monthOfYear); + mStartDate.set(Calendar.DAY_OF_MONTH, dayOfMonth); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_EDIT_BUDGET_AMOUNTS){ + if (resultCode == Activity.RESULT_OK){ + ArrayList budgetAmounts = data.getParcelableArrayListExtra(UxArgument.BUDGET_AMOUNT_LIST); + if (budgetAmounts != null){ + mBudgetAmounts = budgetAmounts; + toggleAmountInputVisibility(); + } + return; + } + } + super.onActivityResult(requestCode, resultCode, data); + } + + /** + * Toggles the visibility of the amount input based on {@link #mBudgetAmounts} + */ + private void toggleAmountInputVisibility() { + if (mBudgetAmounts.size() > 1){ + mBudgetAmountLayout.setVisibility(View.GONE); + mAddBudgetAmount.setText("Edit Budget Amounts"); + } else { + mAddBudgetAmount.setText("Add Budget Amounts"); + mBudgetAmountLayout.setVisibility(View.VISIBLE); + if (!mBudgetAmounts.isEmpty()) { + BudgetAmount budgetAmount = mBudgetAmounts.get(0); + mBudgetAmountInput.setValue(budgetAmount.getAmount().asBigDecimal()); + mBudgetAccountSpinner.setSelection(mAccountsCursorAdapter.getPosition(budgetAmount.getAccountUID())); + } + } + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/budget/BudgetListFragment.java b/app/src/main/java/org/gnucash/android/ui/budget/BudgetListFragment.java new file mode 100644 index 000000000..cc784ea29 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/budget/BudgetListFragment.java @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.ui.budget; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.database.Cursor; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.Loader; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.PopupMenu; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import org.gnucash.android.R; +import org.gnucash.android.db.DatabaseCursorLoader; +import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BudgetsDbAdapter; +import org.gnucash.android.model.Budget; +import org.gnucash.android.model.BudgetAmount; +import org.gnucash.android.model.Money; +import org.gnucash.android.ui.common.FormActivity; +import org.gnucash.android.ui.common.Refreshable; +import org.gnucash.android.ui.common.UxArgument; +import org.gnucash.android.ui.util.CursorRecyclerAdapter; +import org.gnucash.android.ui.util.widget.EmptyRecyclerView; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Currency; + +import butterknife.Bind; +import butterknife.ButterKnife; + +/** + * Budget list fragment + */ +public class BudgetListFragment extends Fragment implements Refreshable, + LoaderManager.LoaderCallbacks { + + private static final String LOG_TAG = "BudgetListFragment"; + private static final int REQUEST_EDIT_BUDGET = 0xB; + private static final int REQUEST_OPEN_ACCOUNT = 0xC; + + private BudgetRecyclerAdapter mBudgetRecyclerAdapter; + + private BudgetsDbAdapter mBudgetsDbAdapter; + + @Bind(R.id.budget_recycler_view) EmptyRecyclerView mRecyclerView; + @Bind(R.id.empty_view) Button mProposeBudgets; + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.fragment_budget_list, container, false); + ButterKnife.bind(this, view); + + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setEmptyView(mProposeBudgets); + + if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { + GridLayoutManager gridLayoutManager = new GridLayoutManager(getActivity(), 2); + mRecyclerView.setLayoutManager(gridLayoutManager); + } else { + LinearLayoutManager mLayoutManager = new LinearLayoutManager(getActivity()); + mRecyclerView.setLayoutManager(mLayoutManager); + } + return view; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + mBudgetsDbAdapter = BudgetsDbAdapter.getInstance(); + mBudgetRecyclerAdapter = new BudgetRecyclerAdapter(null); + + mRecyclerView.setAdapter(mBudgetRecyclerAdapter); + + getLoaderManager().initLoader(0, null, this); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + Log.d(LOG_TAG, "Creating the accounts loader"); + return new BudgetsCursorLoader(getActivity()); + } + + @Override + public void onLoadFinished(Loader loaderCursor, Cursor cursor) { + Log.d(LOG_TAG, "Budget loader finished. Swapping in cursor"); + mBudgetRecyclerAdapter.swapCursor(cursor); + mBudgetRecyclerAdapter.notifyDataSetChanged(); + } + + @Override + public void onLoaderReset(Loader arg0) { + Log.d(LOG_TAG, "Resetting the accounts loader"); + mBudgetRecyclerAdapter.swapCursor(null); + } + + @Override + public void onResume() { + super.onResume(); + refresh(); + getActivity().findViewById(R.id.fab_create_budget).setVisibility(View.VISIBLE); + ((AppCompatActivity)getActivity()).getSupportActionBar().setTitle("Budgets"); + } + + @Override + public void refresh() { + getLoaderManager().restartLoader(0, null, this); + } + + /** + * This method does nothing with the GUID. + * Is equivalent to calling {@link #refresh()} + * @param uid GUID of relevant item to be refreshed + */ + @Override + public void refresh(String uid) { + refresh(); + } + + /** + * Opens the budget detail fragment + * @param budgetUID GUID of budget + */ + public void onClickBudget(String budgetUID){ + FragmentManager fragmentManager = getActivity().getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager + .beginTransaction(); + + fragmentTransaction.replace(R.id.fragment_container, BudgetDetailFragment.newInstance(budgetUID)); + fragmentTransaction.addToBackStack(null); + fragmentTransaction.commit(); + } + + /** + * Launches the FormActivity for editing the budget + * @param budgetId Db record Id of the budget + */ + private void editBudget(long budgetId){ + Intent addAccountIntent = new Intent(getActivity(), FormActivity.class); + addAccountIntent.setAction(Intent.ACTION_INSERT_OR_EDIT); + addAccountIntent.putExtra(UxArgument.FORM_TYPE, FormActivity.FormType.BUDGET.name()); + addAccountIntent.putExtra(UxArgument.BUDGET_UID, mBudgetsDbAdapter.getUID(budgetId)); + startActivityForResult(addAccountIntent, REQUEST_EDIT_BUDGET); + } + + /** + * Delete the budget from the database + * @param budgetId Database record ID + */ + private void deleteBudget(long budgetId){ + BudgetsDbAdapter.getInstance().deleteRecord(budgetId); + refresh(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (resultCode == Activity.RESULT_OK){ + refresh(); + } + } + + class BudgetRecyclerAdapter extends CursorRecyclerAdapter{ + + public BudgetRecyclerAdapter(Cursor cursor) { + super(cursor); + } + + @Override + public void onBindViewHolderCursor(BudgetViewHolder holder, Cursor cursor) { + final Budget budget = mBudgetsDbAdapter.buildModelInstance(cursor); + holder.budgetId = mBudgetsDbAdapter.getID(budget.getUID()); + + holder.budgetName.setText(budget.getName()); + + AccountsDbAdapter accountsDbAdapter = AccountsDbAdapter.getInstance(); + String accountString; + int numberOfAccounts = budget.getNumberOfAccounts(); + if (numberOfAccounts == 1){ + accountString = accountsDbAdapter.getAccountFullName(budget.getBudgetAmounts().get(0).getAccountUID()); + } else { + accountString = numberOfAccounts + " budgeted accounts"; + } + holder.accountName.setText(accountString); + + holder.budgetRecurrence.setText(budget.getRecurrence().getRepeatString() + " - " + + budget.getRecurrence().getDaysLeftInCurrentPeriod() + " days left"); + + BigDecimal spentAmountValue = BigDecimal.ZERO; + for (BudgetAmount budgetAmount : budget.getCompactedBudgetAmounts()) { + Money balance = accountsDbAdapter.getAccountBalance(budgetAmount.getAccountUID(), + budget.getStartofCurrentPeriod(), budget.getEndOfCurrentPeriod()); + spentAmountValue = spentAmountValue.add(balance.asBigDecimal()); + } + + Money budgetTotal = budget.getAmountSum(); + Currency currency = budgetTotal.getCurrency(); + String usedAmount = currency.getSymbol() + spentAmountValue+ " of " + + budgetTotal.formattedString(); + holder.budgetAmount.setText(usedAmount); + + double budgetProgress = spentAmountValue.divide(budgetTotal.asBigDecimal(), + currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN) + .doubleValue(); + holder.budgetIndicator.setProgress((int) (budgetProgress * 100)); + + holder.budgetAmount.setTextColor(BudgetsActivity.getBudgetProgressColor(1 - budgetProgress)); + + holder.itemView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onClickBudget(budget.getUID()); + } + }); + } + + @Override + public BudgetViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.cardview_budget, parent, false); + + return new BudgetViewHolder(v); + } + + class BudgetViewHolder extends RecyclerView.ViewHolder implements PopupMenu.OnMenuItemClickListener{ + @Bind(R.id.primary_text) TextView budgetName; + @Bind(R.id.secondary_text) TextView accountName; + @Bind(R.id.budget_amount) TextView budgetAmount; + @Bind(R.id.options_menu) ImageView optionsMenu; + @Bind(R.id.budget_indicator) ProgressBar budgetIndicator; + @Bind(R.id.budget_recurrence) TextView budgetRecurrence; + long budgetId; + + public BudgetViewHolder(View itemView) { + super(itemView); + ButterKnife.bind(this, itemView); + + optionsMenu.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + android.support.v7.widget.PopupMenu popup = new android.support.v7.widget.PopupMenu(getActivity(), v); + popup.setOnMenuItemClickListener(BudgetViewHolder.this); + MenuInflater inflater = popup.getMenuInflater(); + inflater.inflate(R.menu.budget_context_menu, popup.getMenu()); + popup.show(); + } + }); + + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()){ + case R.id.context_menu_edit_budget: + editBudget(budgetId); + return true; + + case R.id.context_menu_delete: + deleteBudget(budgetId); + return true; + + default: + return false; + } + } + } + } + + /** + * Loads Budgets asynchronously from the database + */ + private static class BudgetsCursorLoader extends DatabaseCursorLoader { + + /** + * Constructor + * Initializes the content observer + * + * @param context Application context + */ + public BudgetsCursorLoader(Context context) { + super(context); + } + + @Override + public Cursor loadInBackground() { + mDatabaseAdapter = BudgetsDbAdapter.getInstance(); + return mDatabaseAdapter.fetchAllRecords(null, null, DatabaseSchema.BudgetEntry.COLUMN_NAME + " ASC"); + } + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/budget/BudgetsActivity.java b/app/src/main/java/org/gnucash/android/ui/budget/BudgetsActivity.java new file mode 100644 index 000000000..a53ac8131 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/budget/BudgetsActivity.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.gnucash.android.ui.budget; + +import android.content.Intent; +import android.os.Bundle; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.view.View; + +import org.gnucash.android.R; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.ui.common.BaseDrawerActivity; +import org.gnucash.android.ui.common.FormActivity; +import org.gnucash.android.ui.common.UxArgument; + +/** + * Activity for managing display and editing of budgets + */ +public class BudgetsActivity extends BaseDrawerActivity { + + public static final int REQUEST_CREATE_BUDGET = 0xA; + + @Override + public int getContentView() { + return R.layout.activity_budgets; + } + + @Override + public int getTitleRes() { + return R.string.title_budgets; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (savedInstanceState == null) { + FragmentManager fragmentManager = getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager + .beginTransaction(); + + fragmentTransaction.replace(R.id.fragment_container, new BudgetListFragment()); + fragmentTransaction.commit(); + } + } + + /** + * Callback when create budget floating action button is clicked + * @param view View which was clicked + */ + public void onCreateBudgetClick(View view){ + Intent addAccountIntent = new Intent(BudgetsActivity.this, FormActivity.class); + addAccountIntent.setAction(Intent.ACTION_INSERT_OR_EDIT); + addAccountIntent.putExtra(UxArgument.FORM_TYPE, FormActivity.FormType.BUDGET.name()); + startActivityForResult(addAccountIntent, REQUEST_CREATE_BUDGET); + } + + /** + * Returns a color between red and green depending on the value parameter + * @param value Value between 0 and 1 indicating the red to green ratio + * @return Color between red and green + */ + public static int getBudgetProgressColor(double value){ + return GnuCashApplication.darken(android.graphics.Color.HSVToColor(new float[]{(float)value*120f,1f,1f})); + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/colorpicker/ColorPickerPalette.java b/app/src/main/java/org/gnucash/android/ui/colorpicker/ColorPickerPalette.java index 7a5c05725..346430c72 100644 --- a/app/src/main/java/org/gnucash/android/ui/colorpicker/ColorPickerPalette.java +++ b/app/src/main/java/org/gnucash/android/ui/colorpicker/ColorPickerPalette.java @@ -148,7 +148,7 @@ private void setSwatchDescription(int rowNumber, int index, int rowElements, boo accessibilityIndex = index; } else { // We're in a backwards-ordered row. - int rowMax = ((rowNumber + 1) * mNumColumns); + int rowMax = (rowNumber + 1) * mNumColumns; accessibilityIndex = rowMax - rowElements; } diff --git a/app/src/main/java/org/gnucash/android/ui/common/BaseDrawerActivity.java b/app/src/main/java/org/gnucash/android/ui/common/BaseDrawerActivity.java index c0b3460bd..eee5966c3 100644 --- a/app/src/main/java/org/gnucash/android/ui/common/BaseDrawerActivity.java +++ b/app/src/main/java/org/gnucash/android/ui/common/BaseDrawerActivity.java @@ -19,39 +19,74 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Configuration; +import android.database.Cursor; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.os.Build; import android.os.Bundle; import android.preference.PreferenceManager; +import android.support.annotation.LayoutRes; +import android.support.annotation.StringRes; import android.support.design.widget.NavigationView; import android.support.v4.widget.DrawerLayout; import android.support.v7.app.ActionBar; import android.support.v7.app.ActionBarDrawerToggle; +import android.support.v7.widget.PopupMenu; +import android.support.v7.widget.Toolbar; +import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; import com.uservoice.uservoicesdk.UserVoice; import org.gnucash.android.R; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.BooksDbAdapter; import org.gnucash.android.ui.account.AccountsActivity; import org.gnucash.android.ui.passcode.PasscodeLockActivity; import org.gnucash.android.ui.report.ReportsActivity; -import org.gnucash.android.ui.settings.SettingsActivity; +import org.gnucash.android.ui.settings.PreferenceActivity; import org.gnucash.android.ui.transaction.ScheduledActionsActivity; +import butterknife.Bind; +import butterknife.ButterKnife; + /** - * Base activity implementing the navigation drawer, to be extended by all activities requiring one - *

All subclasses should call the {@link #setUpDrawer()} method in {@link #onCreate(Bundle)}, after the - * activity layout has been set.
- * The activity layout of the subclass is expected to contain {@code DrawerLayout} and a {@code NavigationView}

+ * Base activity implementing the navigation drawer, to be extended by all activities requiring one. + *

+ * Each activity inheriting from this class has an indeterminate progress bar at the top, + * (above the action bar) which can be used to display busy operations. See {@link #getProgressBar()} + *

* + *

Sub-classes should simply provide their layout using {@link #getContentView()} and then annotate + * any variables they wish to use with {@link ButterKnife#bind(Activity)} annotations. The view + * binding will be done in this base abstract class.
+ * The activity layout of the subclass is expected to contain {@code DrawerLayout} and + * a {@code NavigationView}.
+ * Sub-class should also consider using the {@code toolbar.xml} or {@code toolbar_with_spinner.xml} + * for the action bar in their XML layout. Otherwise provide another which contains widgets for the + * toolbar and progress indicator with the IDs {@code R.id.toolbar} and {@code R.id.progress_indicator} respectively. + *

* @author Ngewi Fet */ -public class BaseDrawerActivity extends PasscodeLockActivity { - protected DrawerLayout mDrawerLayout; - protected NavigationView mNavigationView; +public abstract class BaseDrawerActivity extends PasscodeLockActivity implements + PopupMenu.OnMenuItemClickListener { + + public static final int ID_MANAGE_BOOKS = 0xB00C; + @Bind(R.id.drawer_layout) DrawerLayout mDrawerLayout; + @Bind(R.id.nav_view) NavigationView mNavigationView; + @Bind(R.id.toolbar) Toolbar mToolbar; + @Bind(R.id.toolbar_progress) ProgressBar mToolbarProgress; + protected TextView mBookNameTextView; protected ActionBarDrawerToggle mDrawerToggle; + public static final int REQUEST_OPEN_DOCUMENT = 0x20; + private class DrawerItemClickListener implements NavigationView.OnNavigationItemSelectedListener { @Override @@ -65,26 +100,72 @@ public boolean onNavigationItemSelected(MenuItem menuItem) { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - } + setContentView(getContentView()); - /** - * Sets up the navigation drawer for this activity. - * - * This should be called from the activity's - * {@link Activity#onCreate(Bundle)} method after calling - * {@link Activity#setContentView(int)}. - * - */ - protected void setUpDrawer() { + ButterKnife.bind(this); + setSupportActionBar(mToolbar); final ActionBar actionBar = getSupportActionBar(); if (actionBar != null){ actionBar.setHomeButtonEnabled(true); actionBar.setDisplayHomeAsUpEnabled(true); - + actionBar.setTitle(getTitleRes()); } - mDrawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout); - mNavigationView = (NavigationView) findViewById(R.id.nav_view); + mToolbarProgress.getIndeterminateDrawable().setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN); + + View headerView = mNavigationView.getHeaderView(0); + headerView.findViewById(R.id.drawer_title).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onClickAppTitle(v); + } + }); + + mBookNameTextView = (TextView) headerView.findViewById(R.id.book_name); + mBookNameTextView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + onClickBook(v); + } + }); + updateActiveBookName(); + setUpNavigationDrawer(); + } + + @Override + protected void onResume() { + super.onResume(); + updateActiveBookName(); + } + + /** + * Return the layout to inflate for this activity + * @return Layout resource identifier + */ + public abstract @LayoutRes int getContentView(); + + /** + * Return the title for this activity. + * This will be displayed in the action bar + * @return String resource identifier + */ + public abstract @StringRes int getTitleRes(); + + /** + * Returns the progress bar for the activity. + *

This progress bar is displayed above the toolbar and should be used to show busy status + * for long operations.
+ * The progress bar visibility is set to {@link View#GONE} by default. Make visible to use

+ * @return Indeterminate progress bar. + */ + public ProgressBar getProgressBar(){ + return mToolbarProgress; + } + + /** + * Sets up the navigation drawer for this activity. + */ + private void setUpNavigationDrawer() { mNavigationView.setNavigationItemSelectedListener(new DrawerItemClickListener()); mDrawerToggle = new ActionBarDrawerToggle( @@ -107,6 +188,7 @@ public void onDrawerOpened(View drawerView) { mDrawerLayout.setDrawerListener(mDrawerToggle); } + @Override protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); @@ -132,14 +214,29 @@ public boolean onOptionsItemSelected(MenuItem item) { return super.onOptionsItemSelected(item); } + /** + * Update the display name of the currently active book + */ + protected void updateActiveBookName(){ + mBookNameTextView.setText(BooksDbAdapter.getInstance().getActiveBookDisplayName()); + } + /** * Handler for the navigation drawer items * */ protected void onDrawerMenuItemClicked(int itemId) { - switch (itemId){ case R.id.nav_item_open: { //Open... files - AccountsActivity.startXmlFileChooser(this); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT){ + //use the storage access framework + Intent openDocument = new Intent(Intent.ACTION_OPEN_DOCUMENT); + openDocument.addCategory(Intent.CATEGORY_OPENABLE); + openDocument.setType("*/*"); + startActivityForResult(openDocument, REQUEST_OPEN_DOCUMENT); + + } else { + AccountsActivity.startXmlFileChooser(this); + } } break; @@ -153,14 +250,18 @@ protected void onDrawerMenuItemClicked(int itemId) { break; case R.id.nav_item_reports: { - if (!(this instanceof AccountsActivity) || !(this instanceof ReportsActivity)) - this.finish(); Intent intent = new Intent(this, ReportsActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); startActivity(intent); } break; +/* + //todo: Re-enable this when Budget UI is complete + case R.id.nav_item_budgets: + startActivity(new Intent(this, BudgetsActivity.class)); + break; +*/ case R.id.nav_item_scheduled_actions: { //show scheduled transactions Intent intent = new Intent(this, ScheduledActionsActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); @@ -173,7 +274,7 @@ protected void onDrawerMenuItemClicked(int itemId) { break; case R.id.nav_item_settings: //Settings activity - startActivity(new Intent(this, SettingsActivity.class)); + startActivity(new Intent(this, PreferenceActivity.class)); break; case R.id.nav_item_help: @@ -187,7 +288,8 @@ protected void onDrawerMenuItemClicked(int itemId) { @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (resultCode == Activity.RESULT_CANCELED){ + if (resultCode == Activity.RESULT_CANCELED) { + super.onActivityResult(requestCode, resultCode, data); return; } @@ -195,7 +297,58 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { case AccountsActivity.REQUEST_PICK_ACCOUNTS_FILE: AccountsActivity.importXmlFileFromIntent(this, data, null); break; + case BaseDrawerActivity.REQUEST_OPEN_DOCUMENT: //this uses the Storage Access Framework + final int takeFlags = data.getFlags() + & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + AccountsActivity.importXmlFileFromIntent(this, data, null); + getContentResolver().takePersistableUriPermission(data.getData(), takeFlags); + break; + default: + super.onActivityResult(requestCode, resultCode, data); + break; } } + @Override + public boolean onMenuItemClick(MenuItem item) { + long id = item.getItemId(); + if (id == ID_MANAGE_BOOKS){ + Intent intent = new Intent(this, PreferenceActivity.class); + intent.setAction(PreferenceActivity.ACTION_MANAGE_BOOKS); + startActivity(intent); + mDrawerLayout.closeDrawer(mNavigationView); + return true; + } + BooksDbAdapter booksDbAdapter = BooksDbAdapter.getInstance(); + String bookUID = booksDbAdapter.getUID(id); + if (!bookUID.equals(booksDbAdapter.getActiveBookUID())){ + GnuCashApplication.loadBook(bookUID); + finish(); + } + AccountsActivity.start(GnuCashApplication.getAppContext()); + return true; + } + + public void onClickAppTitle(View view){ + mDrawerLayout.closeDrawer(mNavigationView); + AccountsActivity.start(this); + } + + public void onClickBook(View view){ + PopupMenu popup = new PopupMenu(this, view); + popup.setOnMenuItemClickListener(this); + + Menu menu = popup.getMenu(); + int maxRecent = 0; + Cursor cursor = BooksDbAdapter.getInstance().fetchAllRecords(null, null, + DatabaseSchema.BookEntry.COLUMN_MODIFIED_AT + " DESC"); + while (cursor.moveToNext() && maxRecent++ < 5) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(DatabaseSchema.BookEntry._ID)); + String name = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.BookEntry.COLUMN_DISPLAY_NAME)); + menu.add(0, (int)id, maxRecent, name); + } + menu.add(0, ID_MANAGE_BOOKS, maxRecent, R.string.menu_manage_books); + + popup.show(); + } } diff --git a/app/src/main/java/org/gnucash/android/ui/common/FormActivity.java b/app/src/main/java/org/gnucash/android/ui/common/FormActivity.java index 4af305350..e078ba187 100644 --- a/app/src/main/java/org/gnucash/android/ui/common/FormActivity.java +++ b/app/src/main/java/org/gnucash/android/ui/common/FormActivity.java @@ -28,12 +28,14 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.ui.account.AccountFormFragment; +import org.gnucash.android.ui.budget.BudgetAmountEditorFragment; +import org.gnucash.android.ui.budget.BudgetFormFragment; import org.gnucash.android.ui.export.ExportFormFragment; import org.gnucash.android.ui.passcode.PasscodeLockActivity; -import org.gnucash.android.ui.transaction.TransactionFormFragment; import org.gnucash.android.ui.transaction.SplitEditorFragment; +import org.gnucash.android.ui.transaction.TransactionFormFragment; import org.gnucash.android.ui.util.widget.CalculatorKeyboard; /** @@ -48,7 +50,7 @@ public class FormActivity extends PasscodeLockActivity { private CalculatorKeyboard mOnBackListener; - public enum FormType {ACCOUNT, TRANSACTION, EXPORT, SPLIT_EDITOR} + public enum FormType {ACCOUNT, TRANSACTION, EXPORT, SPLIT_EDITOR, BUDGET, BUDGET_AMOUNT_EDITOR} @Override protected void onCreate(Bundle savedInstanceState) { @@ -95,6 +97,14 @@ protected void onCreate(Bundle savedInstanceState) { showSplitEditorFragment(intent.getExtras()); break; + case BUDGET: + showBudgetFormFragment(intent.getExtras()); + break; + + case BUDGET_AMOUNT_EDITOR: + showBudgetAmountEditorFragment(intent.getExtras()); + break; + default: throw new IllegalArgumentException("No form display type specified"); } @@ -164,6 +174,25 @@ private void showSplitEditorFragment(Bundle args){ showFormFragment(splitEditor); } + /** + * Load the budget form + * @param args View arguments + */ + private void showBudgetFormFragment(Bundle args){ + BudgetFormFragment budgetFormFragment = new BudgetFormFragment(); + budgetFormFragment.setArguments(args); + showFormFragment(budgetFormFragment); + } + + /** + * Load the budget amount editor fragment + * @param args Arguments + */ + private void showBudgetAmountEditorFragment(Bundle args){ + BudgetAmountEditorFragment fragment = BudgetAmountEditorFragment.newInstance(args); + showFormFragment(fragment); + } + /** * Loads the fragment into the fragment container, replacing whatever was there before * @param fragment Fragment to be displayed diff --git a/app/src/main/java/org/gnucash/android/ui/util/Refreshable.java b/app/src/main/java/org/gnucash/android/ui/common/Refreshable.java similarity index 96% rename from app/src/main/java/org/gnucash/android/ui/util/Refreshable.java rename to app/src/main/java/org/gnucash/android/ui/common/Refreshable.java index eb1c78c0b..3c09041b9 100644 --- a/app/src/main/java/org/gnucash/android/ui/util/Refreshable.java +++ b/app/src/main/java/org/gnucash/android/ui/common/Refreshable.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.gnucash.android.ui.util; +package org.gnucash.android.ui.common; /** * Interface for fragments which are refreshable diff --git a/app/src/main/java/org/gnucash/android/ui/common/UxArgument.java b/app/src/main/java/org/gnucash/android/ui/common/UxArgument.java index 9226a5715..b2077c620 100644 --- a/app/src/main/java/org/gnucash/android/ui/common/UxArgument.java +++ b/app/src/main/java/org/gnucash/android/ui/common/UxArgument.java @@ -93,10 +93,14 @@ public final class UxArgument { public static final String SPLIT_LIST = "split_list"; /** - * GUID of splits which have been removed from the split editor + * GUID of a budget */ - public static String REMOVED_SPLITS = "removed_split_guids"; + public static final String BUDGET_UID = "budget_uid"; + /** + * List of budget amounts (as csv) + */ + public static final String BUDGET_AMOUNT_LIST = "budget_amount_list"; //prevent initialization of instances of this class private UxArgument(){ diff --git a/app/src/main/java/org/gnucash/android/ui/export/ExportFormFragment.java b/app/src/main/java/org/gnucash/android/ui/export/ExportFormFragment.java index 05dc1a9f4..a5b8ee1cd 100644 --- a/app/src/main/java/org/gnucash/android/ui/export/ExportFormFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/export/ExportFormFragment.java @@ -25,11 +25,9 @@ import android.os.Bundle; import android.preference.PreferenceManager; import android.support.v4.app.Fragment; -import android.support.v4.app.FragmentManager; import android.support.v7.app.ActionBar; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.SwitchCompat; -import android.text.format.Time; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; @@ -46,16 +44,17 @@ import android.widget.Spinner; import android.widget.TextView; -import com.codetroopers.betterpickers.calendardatepicker.CalendarDatePickerDialog; -import com.codetroopers.betterpickers.radialtimepicker.RadialTimePickerDialog; +import com.codetroopers.betterpickers.calendardatepicker.CalendarDatePickerDialogFragment; +import com.codetroopers.betterpickers.radialtimepicker.RadialTimePickerDialogFragment; import com.codetroopers.betterpickers.recurrencepicker.EventRecurrence; import com.codetroopers.betterpickers.recurrencepicker.EventRecurrenceFormatter; -import com.codetroopers.betterpickers.recurrencepicker.RecurrencePickerDialog; +import com.codetroopers.betterpickers.recurrencepicker.RecurrencePickerDialogFragment; import com.dropbox.sync.android.DbxAccountManager; import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.ScheduledActionDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.ScheduledActionDbAdapter; import org.gnucash.android.export.ExportAsyncTask; import org.gnucash.android.export.ExportFormat; import org.gnucash.android.export.ExportParams; @@ -63,9 +62,11 @@ import org.gnucash.android.model.ScheduledAction; import org.gnucash.android.ui.account.AccountsActivity; import org.gnucash.android.ui.common.UxArgument; -import org.gnucash.android.ui.settings.SettingsActivity; +import org.gnucash.android.ui.settings.BackupPreferenceFragment; +import org.gnucash.android.ui.settings.dialog.OwnCloudDialogFragment; import org.gnucash.android.ui.transaction.TransactionFormFragment; import org.gnucash.android.ui.util.RecurrenceParser; +import org.gnucash.android.ui.util.RecurrenceViewClickListener; import org.gnucash.android.util.PreferencesHelper; import org.gnucash.android.util.TimestampHelper; @@ -74,7 +75,6 @@ import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; -import java.util.List; import butterknife.Bind; import butterknife.ButterKnife; @@ -87,9 +87,9 @@ * @author Ngewi Fet */ public class ExportFormFragment extends Fragment implements - RecurrencePickerDialog.OnRecurrenceSetListener, - CalendarDatePickerDialog.OnDateSetListener, - RadialTimePickerDialog.OnTimeSetListener { + RecurrencePickerDialogFragment.OnRecurrenceSetListener, + CalendarDatePickerDialogFragment.OnDateSetListener, + RadialTimePickerDialogFragment.OnTimeSetListener { /** * Spinner for selecting destination for the exported file. @@ -134,14 +134,14 @@ public class ExportFormFragment extends Fragment implements /** * Event recurrence options */ - EventRecurrence mEventRecurrence = new EventRecurrence(); + private EventRecurrence mEventRecurrence = new EventRecurrence(); /** * Recurrence rule */ - String mRecurrenceRule; + private String mRecurrenceRule; - Calendar mExportStartCalendar = Calendar.getInstance(); + private Calendar mExportStartCalendar = Calendar.getInstance(); /** * Tag for logging @@ -156,7 +156,7 @@ public class ExportFormFragment extends Fragment implements private ExportParams.ExportTarget mExportTarget = ExportParams.ExportTarget.SD_CARD; - public void onRadioButtonClicked(View view){ + private void onRadioButtonClicked(View view){ switch (view.getId()){ case R.id.radio_ofx_format: mExportFormat = ExportFormat.OFX; @@ -273,17 +273,17 @@ private void startExport(){ exportParameters.setExportTarget(mExportTarget); exportParameters.setDeleteTransactionsAfterExport(mDeleteAllCheckBox.isChecked()); - List scheduledActions = RecurrenceParser.parse(mEventRecurrence, - ScheduledAction.ActionType.BACKUP); - for (ScheduledAction scheduledAction : scheduledActions) { + Log.i(TAG, "Commencing async export of transactions"); + new ExportAsyncTask(getActivity(), GnuCashApplication.getActiveDb()).execute(exportParameters); + + if (mRecurrenceRule != null) { + ScheduledAction scheduledAction = new ScheduledAction(ScheduledAction.ActionType.BACKUP); + scheduledAction.setRecurrence(RecurrenceParser.parse(mEventRecurrence)); scheduledAction.setTag(exportParameters.toCsv()); scheduledAction.setActionUID(BaseModel.generateUID()); - ScheduledActionDbAdapter.getInstance().addRecord(scheduledAction); + ScheduledActionDbAdapter.getInstance().addRecord(scheduledAction, DatabaseAdapter.UpdateMethod.insert); } - Log.i(TAG, "Commencing async export of transactions"); - new ExportAsyncTask(getActivity()).execute(exportParameters); - int position = mDestinationSpinner.getSelectedItemPosition(); PreferenceManager.getDefaultSharedPreferences(getActivity()) .edit().putInt(getString(R.string.key_last_export_destination), position) @@ -294,6 +294,9 @@ private void startExport(){ //getActivity().finish(); } + /** + * Bind views to actions when initializing the export form + */ private void bindViewListeners(){ // export destination bindings ArrayAdapter adapter = ArrayAdapter.createFromResource(getActivity(), @@ -312,8 +315,8 @@ public void onItemSelected(AdapterView parent, View view, int position, long case 1: recurrenceOptionsView.setVisibility(View.VISIBLE); mExportTarget = ExportParams.ExportTarget.DROPBOX; - String dropboxAppKey = getString(R.string.dropbox_app_key, SettingsActivity.DROPBOX_APP_KEY); - String dropboxAppSecret = getString(R.string.dropbox_app_secret, SettingsActivity.DROPBOX_APP_SECRET); + String dropboxAppKey = getString(R.string.dropbox_app_key, BackupPreferenceFragment.DROPBOX_APP_KEY); + String dropboxAppSecret = getString(R.string.dropbox_app_secret, BackupPreferenceFragment.DROPBOX_APP_SECRET); DbxAccountManager mDbxAccountManager = DbxAccountManager.getInstance(getActivity().getApplicationContext(), dropboxAppKey, dropboxAppSecret); if (!mDbxAccountManager.hasLinkedAccount()) { @@ -323,10 +326,19 @@ public void onItemSelected(AdapterView parent, View view, int position, long case 2: recurrenceOptionsView.setVisibility(View.VISIBLE); mExportTarget = ExportParams.ExportTarget.GOOGLE_DRIVE; - SettingsActivity.mGoogleApiClient = SettingsActivity.getGoogleApiClient(getActivity()); - SettingsActivity.mGoogleApiClient.connect(); + BackupPreferenceFragment.mGoogleApiClient = BackupPreferenceFragment.getGoogleApiClient(getActivity()); + BackupPreferenceFragment.mGoogleApiClient.connect(); break; case 3: + recurrenceOptionsView.setVisibility(View.VISIBLE); + mExportTarget = ExportParams.ExportTarget.OWNCLOUD; + if(!(PreferenceManager.getDefaultSharedPreferences(getActivity()) + .getBoolean(getString(R.string.key_owncloud_sync), false))) { + OwnCloudDialogFragment ocDialog = OwnCloudDialogFragment.newInstance(null); + ocDialog.show(getActivity().getSupportFragmentManager(), "ownCloud dialog"); + } + break; + case 4: mExportTarget = ExportParams.ExportTarget.SHARING; recurrenceOptionsView.setVisibility(View.GONE); break; @@ -372,7 +384,7 @@ public void onClick(View v) { int year = calendar.get(Calendar.YEAR); int monthOfYear = calendar.get(Calendar.MONTH); int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH); - CalendarDatePickerDialog datePickerDialog = CalendarDatePickerDialog.newInstance( + CalendarDatePickerDialogFragment datePickerDialog = CalendarDatePickerDialogFragment.newInstance( ExportFormFragment.this, year, monthOfYear, dayOfMonth); datePickerDialog.show(getFragmentManager(), "date_picker_fragment"); @@ -394,7 +406,7 @@ public void onClick(View v) { Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(timeMillis); - RadialTimePickerDialog timePickerDialog = RadialTimePickerDialog.newInstance( + RadialTimePickerDialogFragment timePickerDialog = RadialTimePickerDialogFragment.newInstance( ExportFormFragment.this, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true); timePickerDialog.show(getFragmentManager(), "time_picker_dialog_fragment"); @@ -416,30 +428,7 @@ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { mExportAllSwitch.setChecked(sharedPrefs.getBoolean(getString(R.string.key_export_all_transactions), false)); mDeleteAllCheckBox.setChecked(sharedPrefs.getBoolean(getString(R.string.key_delete_transactions_after_export), false)); - mRecurrenceTextView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - FragmentManager fm = getActivity().getSupportFragmentManager(); - Bundle b = new Bundle(); - Time t = new Time(); - t.setToNow(); - b.putLong(RecurrencePickerDialog.BUNDLE_START_TIME_MILLIS, t.toMillis(false)); - b.putString(RecurrencePickerDialog.BUNDLE_TIME_ZONE, t.timezone); - - // may be more efficient to serialize and pass in EventRecurrence - b.putString(RecurrencePickerDialog.BUNDLE_RRULE, mRecurrenceRule); - - RecurrencePickerDialog rpd = (RecurrencePickerDialog) fm.findFragmentByTag( - "recurrence_picker"); - if (rpd != null) { - rpd.dismiss(); - } - rpd = new RecurrencePickerDialog(); - rpd.setArguments(b); - rpd.setOnRecurrenceSetListener(ExportFormFragment.this); - rpd.show(fm, "recurrence_picker"); - } - }); + mRecurrenceTextView.setOnClickListener(new RecurrenceViewClickListener((AppCompatActivity) getActivity(), mRecurrenceRule, this)); //this part (setting the export format) must come after the recurrence view bindings above String defaultExportFormat = sharedPrefs.getString(getString(R.string.key_default_export_format), ExportFormat.QIF.name()); @@ -492,13 +481,13 @@ public void onRecurrenceSet(String rrule) { */ @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == SettingsActivity.REQUEST_RESOLVE_CONNECTION && resultCode == Activity.RESULT_OK) { - SettingsActivity.mGoogleApiClient.connect(); + if (requestCode == BackupPreferenceFragment.REQUEST_RESOLVE_CONNECTION && resultCode == Activity.RESULT_OK) { + BackupPreferenceFragment.mGoogleApiClient.connect(); } } @Override - public void onDateSet(CalendarDatePickerDialog dialog, int year, int monthOfYear, int dayOfMonth) { + public void onDateSet(CalendarDatePickerDialogFragment dialog, int year, int monthOfYear, int dayOfMonth) { Calendar cal = new GregorianCalendar(year, monthOfYear, dayOfMonth); mExportStartDate.setText(TransactionFormFragment.DATE_FORMATTER.format(cal.getTime())); mExportStartCalendar.set(Calendar.YEAR, year); @@ -507,7 +496,7 @@ public void onDateSet(CalendarDatePickerDialog dialog, int year, int monthOfYear } @Override - public void onTimeSet(RadialTimePickerDialog dialog, int hourOfDay, int minute) { + public void onTimeSet(RadialTimePickerDialogFragment dialog, int hourOfDay, int minute) { Calendar cal = new GregorianCalendar(0, 0, 0, hourOfDay, minute); mExportStartTime.setText(TransactionFormFragment.TIME_FORMATTER.format(cal.getTime())); mExportStartCalendar.set(Calendar.HOUR_OF_DAY, hourOfDay); diff --git a/app/src/main/java/org/gnucash/android/ui/homescreen/WidgetConfigurationActivity.java b/app/src/main/java/org/gnucash/android/ui/homescreen/WidgetConfigurationActivity.java index e11229d72..eb13294f8 100644 --- a/app/src/main/java/org/gnucash/android/ui/homescreen/WidgetConfigurationActivity.java +++ b/app/src/main/java/org/gnucash/android/ui/homescreen/WidgetConfigurationActivity.java @@ -25,7 +25,6 @@ import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; import android.database.Cursor; -import android.os.AsyncTask; import android.os.Bundle; import android.preference.PreferenceManager; import android.support.v4.widget.SimpleCursorAdapter; @@ -37,19 +36,17 @@ import android.widget.Toast; import org.gnucash.android.R; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.model.Account; import org.gnucash.android.model.Money; import org.gnucash.android.receivers.TransactionAppWidgetProvider; +import org.gnucash.android.ui.account.AccountsActivity; import org.gnucash.android.ui.common.FormActivity; import org.gnucash.android.ui.common.UxArgument; -import org.gnucash.android.ui.account.AccountsActivity; import org.gnucash.android.ui.transaction.TransactionsActivity; import org.gnucash.android.util.QualifiedAccountNameCursorAdapter; import java.util.Locale; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; /** * Activity for configuration which account to display on a widget. @@ -83,6 +80,8 @@ public void onCreate(Bundle savedInstanceState) { } SimpleCursorAdapter cursorAdapter = new QualifiedAccountNameCursorAdapter(this, cursor); + //without this line, the app crashes when a user tries to select an account + cursorAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); mAccountsSpinner.setAdapter(cursorAdapter); bindListeners(); diff --git a/app/src/main/java/org/gnucash/android/ui/report/BaseReportFragment.java b/app/src/main/java/org/gnucash/android/ui/report/BaseReportFragment.java new file mode 100644 index 000000000..e6f849e40 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/report/BaseReportFragment.java @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.gnucash.android.ui.report; + +import android.content.Context; +import android.graphics.Color; +import android.os.AsyncTask; +import android.os.Bundle; +import android.support.annotation.LayoutRes; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.v4.app.Fragment; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.github.mikephil.charting.data.Entry; +import com.github.mikephil.charting.highlight.Highlight; +import com.github.mikephil.charting.listener.OnChartValueSelectedListener; + +import org.gnucash.android.R; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.model.AccountType; +import org.gnucash.android.model.Commodity; +import org.gnucash.android.ui.common.Refreshable; +import org.joda.time.LocalDateTime; +import org.joda.time.Months; +import org.joda.time.Years; + +import butterknife.Bind; +import butterknife.ButterKnife; + +/** + * Base class for report fragments. + *

All report fragments should extend this class. At the minimum, reports must implement + * {@link #getLayoutResource()}, {@link #getReportType()}, {@link #generateReport()}, {@link #displayReport()} and {@link #getTitle()}

+ *

Implementing classes should create their own XML layouts and provide it in {@link #getLayoutResource()}. + * Then annotate any views in the resource using {@code @Bind} annotation from ButterKnife library. + * This base activity will automatically call {@link ButterKnife#bind(View)} for the layout. + *

+ *

Any custom information to be initialized for the report should be done in {@link #onActivityCreated(Bundle)} in implementing classes. + * The report is then generated in {@link #onStart()} + *

+ * @author Ngewi Fet + */ +public abstract class BaseReportFragment extends Fragment implements + OnChartValueSelectedListener, ReportOptionsListener, Refreshable { + + /** + * Color for chart with no data + */ + public static final int NO_DATA_COLOR = Color.LTGRAY; + + protected static String TAG = "BaseReportFragment"; + + /** + * Reporting period start time + */ + protected long mReportPeriodStart = -1; + /** + * Reporting period end time + */ + protected long mReportPeriodEnd = -1; + + /** + * Account type for which to display reports + */ + protected AccountType mAccountType; + + /** + * Commodity for which to display reports + */ + protected Commodity mCommodity; + + /** + * Intervals in which to group reports + */ + protected ReportsActivity.GroupInterval mGroupInterval = ReportsActivity.GroupInterval.MONTH; + + /** + * Pattern to use to display selected chart values + */ + public static final String SELECTED_VALUE_PATTERN = "%s - %.2f (%.2f %%)"; + + protected ReportsActivity mReportsActivity; + + @Nullable @Bind(R.id.selected_chart_slice) protected TextView mSelectedValueTextView; + + private AsyncTask mReportGenerator; + + /** + * Return the title of this report + * @return Title string identifier + */ + public abstract @StringRes int getTitle(); + + /** + * Returns the layout resource to use for this report + * @return Layout resource identifier + */ + public abstract @LayoutRes int getLayoutResource(); + + /** + * Returns what kind of report this is + * @return Type of report + */ + public abstract ReportType getReportType(); + + /** + * Return {@code true} if this report fragment requires account type options. + *

Sub-classes should implement this method. The base implementation returns {@code true}

+ * @return {@code true} if the fragment makes use of account type options, {@code false} otherwise + */ + public boolean requiresAccountTypeOptions(){ + return true; + } + + /** + * Return {@code true} if this report fragment requires time range options. + *

Base implementation returns true

+ * @return {@code true} if the report fragment requires time range options, {@code false} otherwise + */ + public boolean requiresTimeRangeOptions(){ + return true; + } + + /** + * Generates the data for the report + *

This method should not call any methods which modify the UI as it will be run in a background thread + *
Put any code to update the UI in {@link #displayReport()} + *

+ */ + protected abstract void generateReport(); + + /** + * Update the view after the report chart has been generated
+ * Sub-classes should call to the base method + */ + protected abstract void displayReport(); + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + TAG = this.getClass().getSimpleName(); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View view = inflater.inflate(getLayoutResource(), container, false); + ButterKnife.bind(this, view); + return view; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + ActionBar actionBar = ((AppCompatActivity)getActivity()).getSupportActionBar(); + assert actionBar != null; + actionBar.setTitle(getTitle()); + + setHasOptionsMenu(true); + mCommodity = CommoditiesDbAdapter.getInstance() + .getCommodity(GnuCashApplication.getDefaultCurrencyCode()); + + ReportsActivity reportsActivity = (ReportsActivity) getActivity(); + mReportPeriodStart = reportsActivity.getReportPeriodStart(); + mReportPeriodEnd = reportsActivity.getReportPeriodEnd(); + mAccountType = reportsActivity.getAccountType(); + } + + @Override + public void onStart() { + super.onStart(); + refresh(); + } + + @Override + public void onResume() { + super.onResume(); + mReportsActivity.setAppBarColor(getReportType().getTitleColor()); + mReportsActivity.toggleToolbarTitleVisibility(); + toggleBaseReportingOptionsVisibility(); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (!(getActivity() instanceof ReportsActivity)) + throw new RuntimeException("Report fragments can only be used with the ReportsActivity"); + else + mReportsActivity = (ReportsActivity) getActivity(); + } + + @Override + public void onDetach() { + super.onDetach(); + if (mReportGenerator != null) + mReportGenerator.cancel(true); + } + + private void toggleBaseReportingOptionsVisibility() { + View timeRangeLayout = mReportsActivity.findViewById(R.id.time_range_layout); + View dateRangeDivider = mReportsActivity.findViewById(R.id.date_range_divider); + if (timeRangeLayout != null && dateRangeDivider != null) { + int visibility = requiresTimeRangeOptions() ? View.VISIBLE : View.GONE; + timeRangeLayout.setVisibility(visibility); + dateRangeDivider.setVisibility(visibility); + } + + View accountTypeSpinner = mReportsActivity.findViewById(R.id.report_account_type_spinner); + int visibility = requiresAccountTypeOptions() ? View.VISIBLE : View.GONE; + accountTypeSpinner.setVisibility(visibility); + } + + + /** + * Calculates difference between two date values accordingly to {@code mGroupInterval} + * @param start start date + * @param end end date + * @return difference between two dates or {@code -1} + */ + protected int getDateDiff(LocalDateTime start, LocalDateTime end) { + switch (mGroupInterval) { + case QUARTER: + int y = Years.yearsBetween(start.withDayOfYear(1).withMillisOfDay(0), end.withDayOfYear(1).withMillisOfDay(0)).getYears(); + return getQuarter(end) - getQuarter(start) + y * 4; + case MONTH: + return Months.monthsBetween(start.withDayOfMonth(1).withMillisOfDay(0), end.withDayOfMonth(1).withMillisOfDay(0)).getMonths(); + case YEAR: + return Years.yearsBetween(start.withDayOfYear(1).withMillisOfDay(0), end.withDayOfYear(1).withMillisOfDay(0)).getYears(); + default: + return -1; + } + } + + + /** + * Returns a quarter of the specified date + * @param date date + * @return a quarter + */ + protected int getQuarter(LocalDateTime date) { + return (date.getMonthOfYear() - 1) / 3 + 1; + } + + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.chart_actions, menu); + } + + @Override + public void refresh() { + if (mReportGenerator != null) + mReportGenerator.cancel(true); + + mReportGenerator = new AsyncTask() { + + @Override + protected void onPreExecute() { + mReportsActivity.getProgressBar().setVisibility(View.VISIBLE); + } + + @Override + protected Void doInBackground(Void... params) { + generateReport(); + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + displayReport(); + mReportsActivity.getProgressBar().setVisibility(View.GONE); + } + }; + mReportGenerator.execute(); + } + + /** + * Charts do not support account specific refreshes in general. + * So we provide a base implementation which just calls {@link #refresh()} + * + * @param uid GUID of relevant item to be refreshed + */ + @Override + public void refresh(String uid) { + refresh(); + } + + @Override + public void onGroupingUpdated(ReportsActivity.GroupInterval groupInterval) { + if (mGroupInterval != groupInterval) { + mGroupInterval = groupInterval; + refresh(); + } + } + + @Override + public void onTimeRangeUpdated(long start, long end) { + if (mReportPeriodStart != start || mReportPeriodEnd != end) { + mReportPeriodStart = start; + mReportPeriodEnd = end; + refresh(); + } + } + + @Override + public void onAccountTypeUpdated(AccountType accountType) { + if (mAccountType != accountType) { + mAccountType = accountType; + refresh(); + } + } + + @Override + public void onValueSelected(Entry e, int dataSetIndex, Highlight h) { + //nothing to see here, move along + } + + @Override + public void onNothingSelected() { + if (mSelectedValueTextView != null) + mSelectedValueTextView.setText(R.string.select_chart_to_view_details); + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/report/ReportType.java b/app/src/main/java/org/gnucash/android/ui/report/ReportType.java new file mode 100644 index 000000000..d9887e1db --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/report/ReportType.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.ui.report; + +import android.content.Context; +import android.support.annotation.ColorRes; + +import org.gnucash.android.R; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.ui.report.barchart.StackedBarChartFragment; +import org.gnucash.android.ui.report.linechart.CashFlowLineChartFragment; +import org.gnucash.android.ui.report.piechart.PieChartFragment; +import org.gnucash.android.ui.report.sheet.BalanceSheetFragment; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Different types of reports + *

This class also contains mappings for the reports of the different types which are available + * in the system. When adding a new report, make sure to add a mapping in the constructor

+ */ +public enum ReportType { + PIE_CHART(0), BAR_CHART(1), LINE_CHART(2), TEXT(3), NONE(4); + + Map mReportTypeMap = new HashMap<>(); + int mValue = 4; + + ReportType(int index){ + mValue = index; + Context context = GnuCashApplication.getAppContext(); + switch (index){ + case 0: + mReportTypeMap.put(context.getString(R.string.title_pie_chart), PieChartFragment.class); + break; + case 1: + mReportTypeMap.put(context.getString(R.string.title_bar_chart), StackedBarChartFragment.class); + break; + case 2: + mReportTypeMap.put(context.getString(R.string.title_cash_flow_report), CashFlowLineChartFragment.class); + break; + case 3: + mReportTypeMap.put(context.getString(R.string.title_balance_sheet_report), BalanceSheetFragment.class); + break; + case 4: + break; + } + } + + /** + * Returns the toolbar color to be used for this report type + * @return Color resource + */ + public @ColorRes int getTitleColor(){ + switch (mValue){ + case 0: + return R.color.account_green; + case 1: + return R.color.account_red; + case 2: + return R.color.account_blue; + case 3: + return R.color.account_purple; + case 4: + default: + return R.color.theme_primary; + } + } + + public List getReportNames(){ + return new ArrayList<>(mReportTypeMap.keySet()); + } + + public BaseReportFragment getFragment(String name){ + BaseReportFragment fragment = null; + try { + fragment = (BaseReportFragment) mReportTypeMap.get(name).newInstance(); + } catch (InstantiationException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + return fragment; + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/report/ReportsActivity.java b/app/src/main/java/org/gnucash/android/ui/report/ReportsActivity.java index a01c9be5a..1b72ed8f3 100644 --- a/app/src/main/java/org/gnucash/android/ui/report/ReportsActivity.java +++ b/app/src/main/java/org/gnucash/android/ui/report/ReportsActivity.java @@ -27,7 +27,7 @@ import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentTransaction; import android.support.v7.app.ActionBar; -import android.support.v7.widget.Toolbar; +import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; import android.view.View; @@ -38,10 +38,11 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.AccountType; import org.gnucash.android.ui.common.BaseDrawerActivity; -import org.gnucash.android.ui.report.dialog.DateRangePickerDialogFragment; +import org.gnucash.android.ui.common.Refreshable; +import org.gnucash.android.ui.util.dialog.DateRangePickerDialogFragment; import org.joda.time.LocalDate; import java.util.Arrays; @@ -50,18 +51,21 @@ import java.util.List; import butterknife.Bind; -import butterknife.ButterKnife; /** - * base activity for reporting + * Activity for displaying report fragments (which must implement {@link BaseReportFragment}) + *

In order to add new reports, extend the {@link BaseReportFragment} class to provide the view + * for the report. Then add the report mapping in {@link ReportType} constructor depending on what + * kind of report it is. The report will be dynamically included at runtime.

* * @author Oleksandr Tyshkovets * @author Ngewi Fet */ public class ReportsActivity extends BaseDrawerActivity implements AdapterView.OnItemSelectedListener, - DatePickerDialog.OnDateSetListener, DateRangePickerDialogFragment.OnDateRangeSetListener{ + DatePickerDialog.OnDateSetListener, DateRangePickerDialogFragment.OnDateRangeSetListener, + Refreshable{ - static final int[] COLORS = { + public static final int[] COLORS = { Color.parseColor("#17ee4e"), Color.parseColor("#cc1f09"), Color.parseColor("#3940f7"), Color.parseColor("#f9cd04"), Color.parseColor("#5f33a8"), Color.parseColor("#e005b6"), Color.parseColor("#17d6ed"), Color.parseColor("#e4a9a2"), Color.parseColor("#8fe6cd"), @@ -73,36 +77,56 @@ public class ReportsActivity extends BaseDrawerActivity implements AdapterView.O @Bind(R.id.time_range_spinner) Spinner mTimeRangeSpinner; @Bind(R.id.report_account_type_spinner) Spinner mAccountTypeSpinner; + @Bind(R.id.toolbar_spinner) Spinner mReportTypeSpinner; private TransactionsDbAdapter mTransactionsDbAdapter; private AccountType mAccountType = AccountType.EXPENSE; + private ReportType mReportType = ReportType.NONE; + private ReportsOverviewFragment mReportsOverviewFragment; public enum GroupInterval {WEEK, MONTH, QUARTER, YEAR, ALL} // default time range is the last 3 months - private long mReportStartTime = new LocalDate().minusMonths(2).dayOfMonth().withMinimumValue().toDate().getTime(); - private long mReportEndTime = new LocalDate().plusDays(1).toDate().getTime(); + private long mReportPeriodStart = new LocalDate().minusMonths(2).dayOfMonth().withMinimumValue().toDate().getTime(); + private long mReportPeriodEnd = new LocalDate().plusDays(1).toDate().getTime(); private GroupInterval mReportGroupInterval = GroupInterval.MONTH; + private boolean mSkipNextReportTypeSelectedRun = false; + + AdapterView.OnItemSelectedListener mReportTypeSelectedListener = new AdapterView.OnItemSelectedListener() { + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (mSkipNextReportTypeSelectedRun){ + mSkipNextReportTypeSelectedRun = false; + } else { + String reportName = parent.getItemAtPosition(position).toString(); + loadFragment(mReportType.getFragment(reportName)); + } + } + + @Override + public void onNothingSelected(AdapterView parent) { + //nothing to see here, move along + } + }; @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_reports); - setUpDrawer(); - ButterKnife.bind(this); + public int getContentView() { + return R.layout.activity_reports; + } - Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); - setSupportActionBar(toolbar); + @Override + public int getTitleRes() { + return R.string.title_reports; + } - ActionBar actionBar = getSupportActionBar(); - assert actionBar != null; - actionBar.setTitle(R.string.title_reports); - actionBar.setDisplayHomeAsUpEnabled(true); + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); mTransactionsDbAdapter = TransactionsDbAdapter.getInstance(); - ArrayAdapter adapter = ArrayAdapter.createFromResource(this, R.array.report_time_range, android.R.layout.simple_spinner_item); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); @@ -110,15 +134,21 @@ protected void onCreate(Bundle savedInstanceState) { mTimeRangeSpinner.setOnItemSelectedListener(this); mTimeRangeSpinner.setSelection(1); - ArrayAdapter dataAdapter = new ArrayAdapter<>(this, - android.R.layout.simple_spinner_item, - Arrays.asList(AccountType.EXPENSE, AccountType.INCOME)); + ArrayAdapter dataAdapter = ArrayAdapter.createFromResource(this, + R.array.report_account_types, android.R.layout.simple_spinner_item); dataAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); mAccountTypeSpinner.setAdapter(dataAdapter); mAccountTypeSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override - public void onItemSelected(AdapterView adapterView, View view, int i, long l) { - mAccountType = (AccountType) mAccountTypeSpinner.getSelectedItem(); + public void onItemSelected(AdapterView adapterView, View view, int position, long id) { + switch(position) { + default: + case 0: + mAccountType = AccountType.EXPENSE; + break; + case 1: + mAccountType = AccountType.INCOME; + } updateAccountTypeOnFragments(); } @@ -128,38 +158,69 @@ public void onNothingSelected(AdapterView adapterView) { } }); - if (savedInstanceState == null) { - FragmentManager fragmentManager = getSupportFragmentManager(); - FragmentTransaction fragmentTransaction = fragmentManager - .beginTransaction(); + mReportsOverviewFragment = new ReportsOverviewFragment(); - fragmentTransaction.replace(R.id.fragment_container, new ReportSummaryFragment()); - fragmentTransaction.commit(); + if (savedInstanceState == null) { + loadFragment(mReportsOverviewFragment); } } @Override public void onAttachFragment(Fragment fragment) { super.onAttachFragment(fragment); - View timeRangeLayout = findViewById(R.id.time_range_layout); - View dateRangeDivider = findViewById(R.id.date_range_divider); - if (timeRangeLayout != null && dateRangeDivider != null) { - if (fragment instanceof ReportSummaryFragment || fragment instanceof BalanceSheetFragment) { - timeRangeLayout.setVisibility(View.GONE); - dateRangeDivider.setVisibility(View.GONE); - } else { - timeRangeLayout.setVisibility(View.VISIBLE); - dateRangeDivider.setVisibility(View.VISIBLE); - } + + if (fragment instanceof BaseReportFragment) { + BaseReportFragment reportFragment = (BaseReportFragment)fragment; + updateReportTypeSpinner(reportFragment.getReportType(), getString(reportFragment.getTitle())); } - View accountTypeSpinner = findViewById(R.id.report_account_type_spinner); - if (accountTypeSpinner != null) { - if (fragment instanceof LineChartFragment) { - accountTypeSpinner.setVisibility(View.GONE); - } else { - accountTypeSpinner.setVisibility(View.VISIBLE); - } + } + + /** + * Load the provided fragment into the view replacing the previous one + * @param fragment BaseReportFragment instance + */ + private void loadFragment(BaseReportFragment fragment) { + FragmentManager fragmentManager = getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager + .beginTransaction(); + + fragmentTransaction.replace(R.id.fragment_container, fragment); + fragmentTransaction.commit(); + } + + /** + * Update the report type spinner + */ + public void updateReportTypeSpinner(ReportType reportType, String reportName) { + if (reportType == mReportType)//if it is the same report type, don't change anything + return; + + mReportType = reportType; + ActionBar actionBar = getSupportActionBar(); + assert actionBar != null; + ArrayAdapter arrayAdapter = new ArrayAdapter<>(actionBar.getThemedContext(), + android.R.layout.simple_list_item_1, + mReportType.getReportNames()); + + mSkipNextReportTypeSelectedRun = true; //selection event will be fired again + mReportTypeSpinner.setAdapter(arrayAdapter); + mReportTypeSpinner.setSelection(arrayAdapter.getPosition(reportName)); + mReportTypeSpinner.setOnItemSelectedListener(mReportTypeSelectedListener); + + + toggleToolbarTitleVisibility(); + } + + public void toggleToolbarTitleVisibility() { + ActionBar actionBar = getSupportActionBar(); + assert actionBar != null; + + if (mReportType == ReportType.NONE){ + mReportTypeSpinner.setVisibility(View.GONE); + } else { + mReportTypeSpinner.setVisibility(View.VISIBLE); } + actionBar.setDisplayShowTitleEnabled(mReportType == ReportType.NONE); } /** @@ -181,7 +242,7 @@ private void updateDateRangeOnFragment(){ List fragments = getSupportFragmentManager().getFragments(); for (Fragment fragment : fragments) { if (fragment instanceof ReportOptionsListener){ - ((ReportOptionsListener) fragment).onTimeRangeUpdated(mReportStartTime, mReportEndTime); + ((ReportOptionsListener) fragment).onTimeRangeUpdated(mReportPeriodStart, mReportPeriodEnd); } } } @@ -251,23 +312,23 @@ public boolean onOptionsItemSelected(MenuItem item) { @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { - mReportEndTime = new LocalDate().plusDays(1).toDate().getTime(); + mReportPeriodEnd = new LocalDate().plusDays(1).toDate().getTime(); switch (position){ case 0: //current month - mReportStartTime = new LocalDate().dayOfMonth().withMinimumValue().toDate().getTime(); + mReportPeriodStart = new LocalDate().dayOfMonth().withMinimumValue().toDate().getTime(); break; case 1: // last 3 months. x-2, x-1, x - mReportStartTime = new LocalDate().minusMonths(2).dayOfMonth().withMinimumValue().toDate().getTime(); + mReportPeriodStart = new LocalDate().minusMonths(2).dayOfMonth().withMinimumValue().toDate().getTime(); break; case 2: - mReportStartTime = new LocalDate().minusMonths(5).dayOfMonth().withMinimumValue().toDate().getTime(); + mReportPeriodStart = new LocalDate().minusMonths(5).dayOfMonth().withMinimumValue().toDate().getTime(); break; case 3: - mReportStartTime = new LocalDate().minusMonths(11).dayOfMonth().withMinimumValue().toDate().getTime(); + mReportPeriodStart = new LocalDate().minusMonths(11).dayOfMonth().withMinimumValue().toDate().getTime(); break; case 4: //ALL TIME - mReportStartTime = -1; - mReportEndTime = -1; + mReportPeriodStart = -1; + mReportPeriodEnd = -1; break; case 5: String mCurrencyCode = GnuCashApplication.getDefaultCurrencyCode(); @@ -293,14 +354,14 @@ public void onNothingSelected(AdapterView parent) { public void onDateSet(DatePicker view, int year, int monthOfYear, int dayOfMonth) { Calendar calendar = Calendar.getInstance(); calendar.set(year, monthOfYear, dayOfMonth); - mReportStartTime = calendar.getTimeInMillis(); + mReportPeriodStart = calendar.getTimeInMillis(); updateDateRangeOnFragment(); } @Override public void onDateRangeSet(Date startDate, Date endDate) { - mReportStartTime = startDate.getTime(); - mReportEndTime = endDate.getTime(); + mReportPeriodStart = startDate.getTime(); + mReportPeriodEnd = endDate.getTime(); updateDateRangeOnFragment(); } @@ -309,12 +370,48 @@ public AccountType getAccountType(){ return mAccountType; } - public long getReportEndTime() { - return mReportEndTime; + /** + * Return the end time of the reporting period + * @return Time in millis + */ + public long getReportPeriodEnd() { + return mReportPeriodEnd; } - public long getReportStartTime() { - return mReportStartTime; + /** + * Return the start time of the reporting period + * @return Time in millis + */ + public long getReportPeriodStart() { + return mReportPeriodStart; } + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK){ + if (mReportType != ReportType.NONE){ + loadFragment(mReportsOverviewFragment); + return true; + } + } + return super.onKeyUp(keyCode, event); + } + + @Override + public void refresh() { + List fragments = getSupportFragmentManager().getFragments(); + for (Fragment fragment : fragments) { + if (fragment instanceof Refreshable){ + ((Refreshable) fragment).refresh(); + } + } + } + + @Override + /** + * Just another call to refresh + */ + public void refresh(String uid) { + refresh(); + } } diff --git a/app/src/main/java/org/gnucash/android/ui/report/ReportSummaryFragment.java b/app/src/main/java/org/gnucash/android/ui/report/ReportsOverviewFragment.java similarity index 68% rename from app/src/main/java/org/gnucash/android/ui/report/ReportSummaryFragment.java rename to app/src/main/java/org/gnucash/android/ui/report/ReportsOverviewFragment.java index 93d0d6945..f0139f677 100644 --- a/app/src/main/java/org/gnucash/android/ui/report/ReportSummaryFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/report/ReportsOverviewFragment.java @@ -19,42 +19,39 @@ import android.os.Build; import android.os.Bundle; import android.support.annotation.Nullable; -import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; -import android.support.v4.app.FragmentTransaction; import android.support.v4.view.ViewCompat; -import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.AppCompatButton; -import android.view.LayoutInflater; import android.view.Menu; import android.view.View; -import android.view.ViewGroup; import android.widget.Button; import android.widget.TextView; import com.github.mikephil.charting.charts.PieChart; +import com.github.mikephil.charting.components.Legend; import com.github.mikephil.charting.components.Legend.LegendForm; import com.github.mikephil.charting.data.Entry; import com.github.mikephil.charting.data.PieData; import com.github.mikephil.charting.data.PieDataSet; import org.gnucash.android.R; -import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.model.Account; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.Money; +import org.gnucash.android.ui.report.barchart.StackedBarChartFragment; +import org.gnucash.android.ui.report.linechart.CashFlowLineChartFragment; +import org.gnucash.android.ui.report.piechart.PieChartFragment; +import org.gnucash.android.ui.report.sheet.BalanceSheetFragment; import org.gnucash.android.ui.transaction.TransactionsActivity; import org.joda.time.LocalDate; import java.util.ArrayList; import java.util.Collections; -import java.util.Currency; import java.util.List; -import java.util.Locale; import butterknife.Bind; -import butterknife.ButterKnife; +import butterknife.OnClick; import static com.github.mikephil.charting.components.Legend.LegendPosition; @@ -62,7 +59,7 @@ * Shows a summary of reports * @author Ngewi Fet */ -public class ReportSummaryFragment extends Fragment { +public class ReportsOverviewFragment extends BaseReportFragment { public static final int LEGEND_TEXT_SIZE = 14; @@ -77,6 +74,10 @@ public class ReportSummaryFragment extends Fragment { @Bind(R.id.net_worth) TextView mNetWorth; private AccountsDbAdapter mAccountsDbAdapter; + private Money mAssetsBalance; + private Money mLiabilitiesBalance; + + private boolean mChartHasData = false; @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -84,67 +85,46 @@ public void onCreate(@Nullable Bundle savedInstanceState) { mAccountsDbAdapter = AccountsDbAdapter.getInstance(); } - @Nullable @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_report_summary, container, false); - ButterKnife.bind(this, view); - - mPieChartButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - loadFragment(new PieChartFragment()); - } - }); - - mLineChartButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - loadFragment(new LineChartFragment()); - } - }); - - mBarChartButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - loadFragment(new BarChartFragment()); - } - }); + public int getLayoutResource() { + return R.layout.fragment_report_summary; + } - mBalanceSheetButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - loadFragment(new BalanceSheetFragment()); - } - }); + @Override + public int getTitle() { + return R.string.title_reports; + } - return view; + @Override + public ReportType getReportType() { + return ReportType.NONE; } @Override - public void onResume() { - super.onResume(); - ((AppCompatActivity)getActivity()).getSupportActionBar().setTitle(R.string.title_reports); - ((ReportsActivity)getActivity()).setAppBarColor(R.color.theme_primary); + public boolean requiresAccountTypeOptions() { + return false; + } - getActivity().findViewById(R.id.time_range_layout).setVisibility(View.GONE); - getActivity().findViewById(R.id.date_range_divider).setVisibility(View.GONE); + @Override + public boolean requiresTimeRangeOptions() { + return false; } @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - setHasOptionsMenu(true); + setHasOptionsMenu(false); mChart.setCenterTextSize(PieChartFragment.CENTER_TEXT_SIZE); mChart.setDescription(""); mChart.setDrawSliceText(false); - mChart.getLegend().setEnabled(true); - mChart.getLegend().setWordWrapEnabled(true); - mChart.getLegend().setForm(LegendForm.CIRCLE); - mChart.getLegend().setPosition(LegendPosition.RIGHT_OF_CHART_CENTER); - mChart.getLegend().setTextSize(LEGEND_TEXT_SIZE); + Legend legend = mChart.getLegend(); + legend.setEnabled(true); + legend.setWordWrapEnabled(true); + legend.setForm(LegendForm.CIRCLE); + legend.setPosition(LegendPosition.RIGHT_OF_CHART_CENTER); + legend.setTextSize(LEGEND_TEXT_SIZE); ColorStateList csl = new ColorStateList(new int[][]{new int[0]}, new int[]{getResources().getColor(R.color.account_green)}); setButtonTint(mPieChartButton, csl); @@ -154,29 +134,40 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { setButtonTint(mLineChartButton, csl); csl = new ColorStateList(new int[][]{new int[0]}, new int[]{getResources().getColor(R.color.account_purple)}); setButtonTint(mBalanceSheetButton, csl); + } + @Override + public void onPrepareOptionsMenu(Menu menu) { + menu.findItem(R.id.menu_group_reports_by).setVisible(false); + } + + @Override + protected void generateReport() { + PieData pieData = PieChartFragment.groupSmallerSlices(getData(), getActivity()); + if (pieData != null && pieData.getYValCount() != 0) { + mChart.setData(pieData); + float sum = mChart.getData().getYValueSum(); + String total = getResources().getString(R.string.label_chart_total); + String currencySymbol = mCommodity.getSymbol(); + mChart.setCenterText(String.format(PieChartFragment.TOTAL_VALUE_LABEL_PATTERN, total, sum, currencySymbol)); + mChartHasData = true; + } else { + mChart.setData(getEmptyData()); + mChart.setCenterText(getResources().getString(R.string.label_chart_no_data)); + mChart.getLegend().setEnabled(false); + mChartHasData = false; + } List accountTypes = new ArrayList<>(); accountTypes.add(AccountType.ASSET); accountTypes.add(AccountType.CASH); accountTypes.add(AccountType.BANK); - Money assetsBalance = mAccountsDbAdapter.getAccountBalance(accountTypes, -1, System.currentTimeMillis()); + mAssetsBalance = mAccountsDbAdapter.getAccountBalance(accountTypes, -1, System.currentTimeMillis()); accountTypes.clear(); accountTypes.add(AccountType.LIABILITY); accountTypes.add(AccountType.CREDIT); - Money liabilitiesBalance = mAccountsDbAdapter.getAccountBalance(accountTypes, -1, System.currentTimeMillis()); - - TransactionsActivity.displayBalance(mTotalAssets, assetsBalance); - TransactionsActivity.displayBalance(mTotalLiabilities, liabilitiesBalance); - TransactionsActivity.displayBalance(mNetWorth, assetsBalance.subtract(liabilitiesBalance)); - - displayChart(); - } - - @Override - public void onPrepareOptionsMenu(Menu menu) { - menu.findItem(R.id.menu_group_reports_by).setVisible(false); + mLiabilitiesBalance = mAccountsDbAdapter.getAccountBalance(accountTypes, -1, System.currentTimeMillis()); } /** @@ -184,14 +175,13 @@ public void onPrepareOptionsMenu(Menu menu) { * @return {@code PieData} instance */ private PieData getData() { - String mCurrencyCode = GnuCashApplication.getDefaultCurrencyCode(); PieDataSet dataSet = new PieDataSet(null, ""); List labels = new ArrayList<>(); List colors = new ArrayList<>(); for (Account account : mAccountsDbAdapter.getSimpleAccountList()) { if (account.getAccountType() == AccountType.EXPENSE && !account.isPlaceholderAccount() - && account.getCurrency() == Currency.getInstance(mCurrencyCode)) { + && account.getCommodity().equals(mCommodity)) { long start = new LocalDate().minusMonths(2).dayOfMonth().withMinimumValue().toDate().getTime(); long end = new LocalDate().plusDays(1).toDate().getTime(); @@ -211,30 +201,20 @@ private PieData getData() { return new PieData(labels, dataSet); } - /** - * Manages all actions about displaying the pie chart - */ - private void displayChart() { - mChart.highlightValues(null); - mChart.clear(); - - PieData pieData = PieChartFragment.groupSmallerSlices(getData(), getActivity()); - if (pieData != null && pieData.getYValCount() != 0) { - mChart.setData(pieData); - float sum = mChart.getData().getYValueSum(); - String total = getResources().getString(R.string.label_chart_total); - String currencySymbol = Currency.getInstance(GnuCashApplication.getDefaultCurrencyCode()).getSymbol(Locale.getDefault()); - mChart.setCenterText(String.format(PieChartFragment.TOTAL_VALUE_LABEL_PATTERN, total, sum, currencySymbol)); + @Override + protected void displayReport() { + if (mChartHasData){ mChart.animateXY(1800, 1800); mChart.setTouchEnabled(true); } else { - mChart.setData(getEmptyData()); - mChart.setCenterText(getResources().getString(R.string.label_chart_no_data)); - mChart.getLegend().setEnabled(false); mChart.setTouchEnabled(false); } - + mChart.highlightValues(null); mChart.invalidate(); + + TransactionsActivity.displayBalance(mTotalAssets, mAssetsBalance); + TransactionsActivity.displayBalance(mTotalLiabilities, mLiabilitiesBalance); + TransactionsActivity.displayBalance(mNetWorth, mAssetsBalance.subtract(mLiabilitiesBalance)); } /** @@ -249,6 +229,31 @@ private PieData getEmptyData() { return new PieData(Collections.singletonList(""), dataSet); } + @OnClick({R.id.btn_bar_chart, R.id.btn_pie_chart, R.id.btn_line_chart, R.id.btn_balance_sheet}) + public void onClickChartTypeButton(View view){ + BaseReportFragment fragment; + switch (view.getId()){ + case R.id.btn_pie_chart: + fragment = new PieChartFragment(); + break; + case R.id.btn_bar_chart: + fragment = new StackedBarChartFragment(); + break; + case R.id.btn_line_chart: + fragment = new CashFlowLineChartFragment(); + break; + case R.id.btn_balance_sheet: + fragment = new BalanceSheetFragment(); + break; + default: + fragment = this; + break; + } + FragmentManager fragmentManager = getActivity().getSupportFragmentManager(); + fragmentManager.beginTransaction() + .replace(R.id.fragment_container, fragment) + .commit(); + } public void setButtonTint(Button button, ColorStateList tint) { if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP && button instanceof AppCompatButton) { @@ -259,12 +264,4 @@ public void setButtonTint(Button button, ColorStateList tint) { button.setTextColor(getResources().getColor(android.R.color.white)); } - private void loadFragment(Fragment fragment){ - FragmentManager fragmentManager = getActivity().getSupportFragmentManager(); - FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); - - fragmentTransaction.replace(R.id.fragment_container, fragment); - fragmentTransaction.addToBackStack(null); - fragmentTransaction.commit(); - } } diff --git a/app/src/main/java/org/gnucash/android/ui/report/BarChartFragment.java b/app/src/main/java/org/gnucash/android/ui/report/barchart/StackedBarChartFragment.java similarity index 73% rename from app/src/main/java/org/gnucash/android/ui/report/BarChartFragment.java rename to app/src/main/java/org/gnucash/android/ui/report/barchart/StackedBarChartFragment.java index 0cf4ae44d..7408fb738 100644 --- a/app/src/main/java/org/gnucash/android/ui/report/BarChartFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/report/barchart/StackedBarChartFragment.java @@ -15,22 +15,14 @@ * limitations under the License. */ -package org.gnucash.android.ui.report; +package org.gnucash.android.ui.report.barchart; -import android.graphics.Color; import android.os.Bundle; import android.preference.PreferenceManager; import android.support.annotation.Nullable; -import android.support.v4.app.Fragment; -import android.support.v7.app.AppCompatActivity; import android.util.Log; -import android.view.LayoutInflater; import android.view.Menu; -import android.view.MenuInflater; import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; import android.widget.Toast; import com.github.mikephil.charting.charts.BarChart; @@ -40,35 +32,29 @@ import com.github.mikephil.charting.data.BarEntry; import com.github.mikephil.charting.data.Entry; import com.github.mikephil.charting.highlight.Highlight; -import com.github.mikephil.charting.listener.OnChartValueSelectedListener; import com.github.mikephil.charting.utils.LargeValueFormatter; import org.gnucash.android.R; -import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.Account; import org.gnucash.android.model.AccountType; +import org.gnucash.android.ui.report.BaseReportFragment; +import org.gnucash.android.ui.report.ReportType; import org.joda.time.LocalDate; import org.joda.time.LocalDateTime; -import org.joda.time.Months; -import org.joda.time.Years; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.Currency; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import butterknife.Bind; -import butterknife.ButterKnife; import static org.gnucash.android.ui.report.ReportsActivity.COLORS; -import static org.gnucash.android.ui.report.ReportsActivity.GroupInterval; /** * Activity used for drawing a bar chart @@ -76,73 +62,45 @@ * @author Oleksandr Tyshkovets * @author Ngewi Fet */ -public class BarChartFragment extends Fragment implements OnChartValueSelectedListener, - ReportOptionsListener { +public class StackedBarChartFragment extends BaseReportFragment { - private static final String TAG = "BarChartFragment"; private static final String X_AXIS_MONTH_PATTERN = "MMM YY"; private static final String X_AXIS_QUARTER_PATTERN = "Q%d %s"; private static final String X_AXIS_YEAR_PATTERN = "YYYY"; - private static final String SELECTED_VALUE_PATTERN = "%s - %.2f (%.2f %%)"; + private static final int ANIMATION_DURATION = 2000; - private static final int NO_DATA_COLOR = Color.LTGRAY; private static final int NO_DATA_BAR_COUNTS = 3; private AccountsDbAdapter mAccountsDbAdapter = AccountsDbAdapter.getInstance(); - @Bind(R.id.selected_chart_slice) TextView selectedValueTextView; @Bind(R.id.bar_chart) BarChart mChart; - private Currency mCurrency; - - private AccountType mAccountType; - private boolean mUseAccountColor = true; private boolean mTotalPercentageMode = true; private boolean mChartDataPresent = true; - /** - * Reporting period start time - */ - private long mReportStartTime = -1; - /** - * Reporting period end time - */ - private long mReportEndTime = -1; - - private GroupInterval mGroupInterval = GroupInterval.MONTH; + @Override + public int getTitle() { + return R.string.title_cash_flow_report; + } - @Nullable @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_bar_chart, container, false); - ButterKnife.bind(this, view); - return view; + public int getLayoutResource() { + return R.layout.fragment_bar_chart; } @Override - public void onResume() { - super.onResume(); - ((ReportsActivity)getActivity()).setAppBarColor(R.color.account_red); + public ReportType getReportType() { + return ReportType.BAR_CHART; } @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - ((AppCompatActivity)getActivity()).getSupportActionBar().setTitle(R.string.title_bar_chart); - setHasOptionsMenu(true); - mUseAccountColor = PreferenceManager.getDefaultSharedPreferences(getActivity()) .getBoolean(getString(R.string.key_use_account_color), false); - mCurrency = Currency.getInstance(GnuCashApplication.getDefaultCurrencyCode()); - - ReportsActivity reportsActivity = (ReportsActivity) getActivity(); - mReportStartTime = reportsActivity.getReportStartTime(); - mReportEndTime = reportsActivity.getReportEndTime(); - mAccountType = reportsActivity.getAccountType(); - mChart.setOnChartValueSelectedListener(this); mChart.setDescription(""); // mChart.setDrawValuesForWholeStack(false); @@ -150,14 +108,12 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { mChart.getAxisRight().setEnabled(false); mChart.getAxisLeft().setStartAtZero(false); mChart.getAxisLeft().enableGridDashedLine(4.0f, 4.0f, 0); - mChart.getAxisLeft().setValueFormatter(new LargeValueFormatter(mCurrency.getSymbol(Locale.getDefault()))); + mChart.getAxisLeft().setValueFormatter(new LargeValueFormatter(mCommodity.getSymbol())); Legend chartLegend = mChart.getLegend(); chartLegend.setForm(Legend.LegendForm.CIRCLE); chartLegend.setPosition(Legend.LegendPosition.BELOW_CHART_CENTER); chartLegend.setWordWrapEnabled(true); - mChart.setData(getData()); - displayChart(); } @@ -165,7 +121,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { * Returns a data object that represents a user data of the specified account types * @return a {@code BarData} instance that represents a user data */ - private BarData getData() { + protected BarData getData() { List values = new ArrayList<>(); List labels = new ArrayList<>(); List colors = new ArrayList<>(); @@ -205,7 +161,7 @@ private BarData getData() { for (Account account : mAccountsDbAdapter.getSimpleAccountList()) { if (account.getAccountType() == mAccountType && !account.isPlaceholderAccount() - && account.getCurrency() == mCurrency) { + && account.getCommodity().equals(mCommodity)) { double balance = mAccountsDbAdapter.getAccountsBalance( Collections.singletonList(account.getUID()), start, end).asDouble(); @@ -286,12 +242,12 @@ private BarData getEmptyData() { */ private LocalDate getStartDate(AccountType accountType) { TransactionsDbAdapter adapter = TransactionsDbAdapter.getInstance(); - String code = mCurrency.getCurrencyCode(); + String code = mCommodity.getCurrencyCode(); LocalDate startDate; - if (mReportStartTime == -1) { + if (mReportPeriodStart == -1) { startDate = new LocalDate(adapter.getTimestampOfEarliestTransaction(accountType, code)); } else { - startDate = new LocalDate(mReportStartTime); + startDate = new LocalDate(mReportPeriodStart); } startDate = startDate.withDayOfMonth(1); Log.d(TAG, accountType + " X-axis star date: " + startDate.toString("dd MM yyyy")); @@ -305,47 +261,18 @@ private LocalDate getStartDate(AccountType accountType) { */ private LocalDate getEndDate(AccountType accountType) { TransactionsDbAdapter adapter = TransactionsDbAdapter.getInstance(); - String code = mCurrency.getCurrencyCode(); + String code = mCommodity.getCurrencyCode(); LocalDate endDate; - if (mReportEndTime == -1) { + if (mReportPeriodEnd == -1) { endDate = new LocalDate(adapter.getTimestampOfLatestTransaction(accountType, code)); } else { - endDate = new LocalDate(mReportEndTime); + endDate = new LocalDate(mReportPeriodEnd); } endDate = endDate.withDayOfMonth(1); Log.d(TAG, accountType + " X-axis end date: " + endDate.toString("dd MM yyyy")); return endDate; } - /** - * Calculates difference between two date values accordingly to {@code mGroupInterval} - * @param start start date - * @param end end date - * @return difference between two dates or {@code -1} - */ - private int getDateDiff(LocalDateTime start, LocalDateTime end) { - switch (mGroupInterval) { - case QUARTER: - int y = Years.yearsBetween(start.withDayOfYear(1).withMillisOfDay(0), end.withDayOfYear(1).withMillisOfDay(0)).getYears(); - return (getQuarter(end) - getQuarter(start) + y * 4); - case MONTH: - return Months.monthsBetween(start.withDayOfMonth(1).withMillisOfDay(0), end.withDayOfMonth(1).withMillisOfDay(0)).getMonths(); - case YEAR: - return Years.yearsBetween(start.withDayOfYear(1).withMillisOfDay(0), end.withDayOfYear(1).withMillisOfDay(0)).getYears(); - default: - return -1; - } - } - - /** - * Returns a quarter of the specified date - * @param date date - * @return a quarter - */ - private int getQuarter(LocalDateTime date) { - return ((date.getMonthOfYear() - 1) / 3 + 1); - } - /** * Converts the specified list of floats to an array * @param list a list of floats @@ -359,25 +286,26 @@ private float[] floatListToArray(List list) { return array; } - /** - * Displays the stacked bar chart - */ - private void displayChart() { - mChart.highlightValues(null); + + @Override + public void generateReport() { + mChart.setData(getData()); setCustomLegend(); - mChart.notifyDataSetChanged(); mChart.getAxisLeft().setDrawLabels(mChartDataPresent); mChart.getXAxis().setDrawLabels(mChartDataPresent); mChart.setTouchEnabled(mChartDataPresent); + } - selectedValueTextView.setText(""); - + @Override + protected void displayReport() { + mChart.notifyDataSetChanged(); + mChart.highlightValues(null); if (mChartDataPresent) { mChart.animateY(ANIMATION_DURATION); } else { mChart.clearAnimation(); - selectedValueTextView.setText(getResources().getString(R.string.label_chart_no_data)); + mSelectedValueTextView.setText(R.string.label_chart_no_data); } mChart.invalidate(); @@ -400,40 +328,6 @@ private void setCustomLegend() { legend.setEnabled(false); } - @Override - public void onTimeRangeUpdated(long start, long end) { - if (mReportStartTime != start || mReportEndTime != end) { - mReportStartTime = start; - mReportEndTime = end; - - mChart.setData(getData()); - displayChart(); - } - } - - @Override - public void onGroupingUpdated(GroupInterval groupInterval) { - if (mGroupInterval != groupInterval) { - mGroupInterval = groupInterval; - mChart.setData(getData()); - displayChart(); - } - } - - @Override - public void onAccountTypeUpdated(AccountType accountType) { - if (mAccountType != accountType) { - mAccountType = accountType; - mChart.setData(getData()); - displayChart(); - } - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.chart_actions, menu); - } - @Override public void onPrepareOptionsMenu(Menu menu) { menu.findItem(R.id.menu_percentage_mode).setVisible(mChartDataPresent); @@ -490,11 +384,7 @@ public void onValueSelected(Entry e, int dataSetIndex, Highlight h) { } else { sum = entry.getNegativeSum() + entry.getPositiveSum(); } - selectedValueTextView.setText(String.format(SELECTED_VALUE_PATTERN, label.trim(), value, value / sum * 100)); + mSelectedValueTextView.setText(String.format(SELECTED_VALUE_PATTERN, label.trim(), value, value / sum * 100)); } - @Override - public void onNothingSelected() { - selectedValueTextView.setText(R.string.label_select_bar_to_view_details); - } } diff --git a/app/src/main/java/org/gnucash/android/ui/report/LineChartFragment.java b/app/src/main/java/org/gnucash/android/ui/report/linechart/CashFlowLineChartFragment.java similarity index 74% rename from app/src/main/java/org/gnucash/android/ui/report/LineChartFragment.java rename to app/src/main/java/org/gnucash/android/ui/report/linechart/CashFlowLineChartFragment.java index 5c9e12aa5..40e7b18dc 100644 --- a/app/src/main/java/org/gnucash/android/ui/report/LineChartFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/report/linechart/CashFlowLineChartFragment.java @@ -15,21 +15,13 @@ * limitations under the License. */ -package org.gnucash.android.ui.report; +package org.gnucash.android.ui.report.linechart; import android.graphics.Color; import android.os.Bundle; -import android.support.annotation.Nullable; -import android.support.v4.app.Fragment; -import android.support.v7.app.AppCompatActivity; import android.util.Log; -import android.view.LayoutInflater; import android.view.Menu; -import android.view.MenuInflater; import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; import com.github.mikephil.charting.charts.LineChart; import com.github.mikephil.charting.components.Legend; @@ -38,33 +30,28 @@ import com.github.mikephil.charting.data.LineData; import com.github.mikephil.charting.data.LineDataSet; import com.github.mikephil.charting.highlight.Highlight; -import com.github.mikephil.charting.listener.OnChartValueSelectedListener; import com.github.mikephil.charting.utils.LargeValueFormatter; import org.gnucash.android.R; -import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.Account; import org.gnucash.android.model.AccountType; +import org.gnucash.android.ui.report.BaseReportFragment; +import org.gnucash.android.ui.report.ReportType; import org.gnucash.android.ui.report.ReportsActivity.GroupInterval; import org.joda.time.LocalDate; import org.joda.time.LocalDateTime; -import org.joda.time.Months; -import org.joda.time.Years; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.Currency; import java.util.HashMap; import java.util.Iterator; import java.util.List; -import java.util.Locale; import java.util.Map; import butterknife.Bind; -import butterknife.ButterKnife; /** * Fragment for line chart reports @@ -72,14 +59,10 @@ * @author Oleksandr Tyshkovets * @author Ngewi Fet */ -public class LineChartFragment extends Fragment implements OnChartValueSelectedListener, - ReportOptionsListener{ +public class CashFlowLineChartFragment extends BaseReportFragment { - private static final String TAG = "LineChartFragment"; private static final String X_AXIS_PATTERN = "MMM YY"; - private static final String SELECTED_VALUE_PATTERN = "%s - %.2f (%.2f %%)"; private static final int ANIMATION_DURATION = 3000; - private static final int NO_DATA_COLOR = Color.GRAY; private static final int NO_DATA_BAR_COUNTS = 5; private static final int[] COLORS = { Color.parseColor("#68F1AF"), Color.parseColor("#cc1f09"), Color.parseColor("#EE8600"), @@ -96,75 +79,40 @@ public class LineChartFragment extends Fragment implements OnChartValueSelectedL private long mEarliestTransactionTimestamp; private long mLatestTransactionTimestamp; private boolean mChartDataPresent = true; - private Currency mCurrency; - - private GroupInterval mGroupInterval = GroupInterval.MONTH; - - /** - * Reporting period start time - */ - private long mReportStartTime = -1; - - /** - * Reporting period end time - */ - private long mReportEndTime = -1; @Bind(R.id.line_chart) LineChart mChart; - @Bind(R.id.selected_chart_slice) TextView mChartSliceInfo; - @Nullable @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_line_chart, container, false); - ButterKnife.bind(this, view); - return view; + public int getLayoutResource() { + return R.layout.fragment_line_chart; + } + + @Override + public int getTitle() { + return R.string.title_cash_flow_report; } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - ((AppCompatActivity)getActivity()).getSupportActionBar().setTitle(R.string.title_line_chart); - setHasOptionsMenu(true); - - mCurrency = Currency.getInstance(GnuCashApplication.getDefaultCurrencyCode()); - - ReportsActivity reportsActivity = (ReportsActivity) getActivity(); - mReportStartTime = reportsActivity.getReportStartTime(); - mReportEndTime = reportsActivity.getReportEndTime(); - mChart.setOnChartValueSelectedListener(this); mChart.setDescription(""); mChart.getXAxis().setDrawGridLines(false); mChart.getAxisRight().setEnabled(false); mChart.getAxisLeft().enableGridDashedLine(4.0f, 4.0f, 0); - mChart.getAxisLeft().setValueFormatter(new LargeValueFormatter(mCurrency.getSymbol(Locale.getDefault()))); - - // below we can add/remove displayed account's types - mChart.setData(getData(new ArrayList<>(Arrays.asList(AccountType.INCOME, AccountType.EXPENSE)))); + mChart.getAxisLeft().setValueFormatter(new LargeValueFormatter(mCommodity.getSymbol())); Legend legend = mChart.getLegend(); legend.setPosition(Legend.LegendPosition.BELOW_CHART_CENTER); legend.setTextSize(16); legend.setForm(Legend.LegendForm.CIRCLE); - if (!mChartDataPresent) { - mChart.getAxisLeft().setAxisMaxValue(10); - mChart.getAxisLeft().setDrawLabels(false); - mChart.getXAxis().setDrawLabels(false); - mChart.setTouchEnabled(false); - mChartSliceInfo.setText(getResources().getString(R.string.label_chart_no_data)); - } else { - mChart.animateX(ANIMATION_DURATION); - } - mChart.invalidate(); } @Override - public void onResume() { - super.onResume(); - ((ReportsActivity)getActivity()).setAppBarColor(R.color.account_blue); + public ReportType getReportType() { + return ReportType.LINE_CHART; } /** @@ -178,12 +126,12 @@ private LineData getData(List accountTypeList) { // LocalDateTime? LocalDate startDate; LocalDate endDate; - if (mReportStartTime == -1 && mReportEndTime == -1) { + if (mReportPeriodStart == -1 && mReportPeriodEnd == -1) { startDate = new LocalDate(mEarliestTransactionTimestamp).withDayOfMonth(1); endDate = new LocalDate(mLatestTransactionTimestamp).withDayOfMonth(1); } else { - startDate = new LocalDate(mReportStartTime).withDayOfMonth(1); - endDate = new LocalDate(mReportEndTime).withDayOfMonth(1); + startDate = new LocalDate(mReportPeriodStart).withDayOfMonth(1); + endDate = new LocalDate(mReportPeriodEnd).withDayOfMonth(1); } int count = getDateDiff(new LocalDateTime(startDate.toDate().getTime()), new LocalDateTime(endDate.toDate().getTime())); @@ -230,35 +178,6 @@ private LineData getData(List accountTypeList) { return lineData; } - /** - * Calculates difference between two date values accordingly to {@code mGroupInterval} - * @param start start date - * @param end end date - * @return difference between two dates or {@code -1} - */ - private int getDateDiff(LocalDateTime start, LocalDateTime end) { - switch (mGroupInterval) { - case QUARTER: - int y = Years.yearsBetween(start.withDayOfYear(1).withMillisOfDay(0), end.withDayOfYear(1).withMillisOfDay(0)).getYears(); - return (getQuarter(end) - getQuarter(start) + y * 4); - case MONTH: - return Months.monthsBetween(start.withDayOfMonth(1).withMillisOfDay(0), end.withDayOfMonth(1).withMillisOfDay(0)).getMonths(); - case YEAR: - return Years.yearsBetween(start.withDayOfYear(1).withMillisOfDay(0), end.withDayOfYear(1).withMillisOfDay(0)).getYears(); - default: - return -1; - } - } - - /** - * Returns a quarter of the specified date - * @param date date - * @return a quarter - */ - private int getQuarter(LocalDateTime date) { - return ((date.getMonthOfYear() - 1) / 3 + 1); - } - /** * Returns a data object that represents situation when no user data available * @return a {@code LineData} instance for situation when no user data available @@ -289,19 +208,19 @@ private List getEntryList(AccountType accountType) { for (Account account : mAccountsDbAdapter.getSimpleAccountList()) { if (account.getAccountType() == accountType && !account.isPlaceholderAccount() - && account.getCurrency() == mCurrency) { + && account.getCommodity().equals(mCommodity)) { accountUIDList.add(account.getUID()); } } LocalDateTime earliest; LocalDateTime latest; - if (mReportStartTime == -1 && mReportEndTime == -1) { + if (mReportPeriodStart == -1 && mReportPeriodEnd == -1) { earliest = new LocalDateTime(mEarliestTimestampsMap.get(accountType)); latest = new LocalDateTime(mLatestTimestampsMap.get(accountType)); } else { - earliest = new LocalDateTime(mReportStartTime); - latest = new LocalDateTime(mReportEndTime); + earliest = new LocalDateTime(mReportPeriodStart); + latest = new LocalDateTime(mReportPeriodEnd); } Log.d(TAG, "Earliest " + accountType + " date " + earliest.toString("dd MM yyyy")); Log.d(TAG, "Latest " + accountType + " date " + latest.toString("dd MM yyyy")); @@ -347,17 +266,17 @@ private List getEntryList(AccountType accountType) { * @param accountTypeList account's types which will be processed */ private void calculateEarliestAndLatestTimestamps(List accountTypeList) { - if (mReportStartTime != -1 && mReportEndTime != -1) { - mEarliestTransactionTimestamp = mReportStartTime; - mLatestTransactionTimestamp = mReportEndTime; + if (mReportPeriodStart != -1 && mReportPeriodEnd != -1) { + mEarliestTransactionTimestamp = mReportPeriodStart; + mLatestTransactionTimestamp = mReportPeriodEnd; return; } TransactionsDbAdapter dbAdapter = TransactionsDbAdapter.getInstance(); for (Iterator iter = accountTypeList.iterator(); iter.hasNext();) { AccountType type = iter.next(); - long earliest = dbAdapter.getTimestampOfEarliestTransaction(type, mCurrency.getCurrencyCode()); - long latest = dbAdapter.getTimestampOfLatestTransaction(type, mCurrency.getCurrencyCode()); + long earliest = dbAdapter.getTimestampOfEarliestTransaction(type, mCommodity.getCurrencyCode()); + long latest = dbAdapter.getTimestampOfLatestTransaction(type, mCommodity.getCurrencyCode()); if (earliest > 0 && latest > 0) { mEarliestTimestampsMap.put(type, earliest); mLatestTimestampsMap.put(type, latest); @@ -377,11 +296,41 @@ private void calculateEarliestAndLatestTimestamps(List accountTypeL mLatestTransactionTimestamp = timestamps.get(timestamps.size() - 1); } + @Override + public boolean requiresAccountTypeOptions() { + return false; + } + + @Override + protected void generateReport() { + LineData lineData = getData(new ArrayList<>(Arrays.asList(AccountType.INCOME, AccountType.EXPENSE))); + if (lineData != null) { + mChart.setData(lineData); + mChartDataPresent = true; + } else { + mChartDataPresent = false; + } + } + + @Override + protected void displayReport() { + if (!mChartDataPresent) { + mChart.getAxisLeft().setAxisMaxValue(10); + mChart.getAxisLeft().setDrawLabels(false); + mChart.getXAxis().setDrawLabels(false); + mChart.setTouchEnabled(false); + mSelectedValueTextView.setText(getResources().getString(R.string.label_chart_no_data)); + } else { + mChart.animateX(ANIMATION_DURATION); + } + mChart.invalidate(); + } + @Override public void onTimeRangeUpdated(long start, long end) { - if (mReportStartTime != start || mReportEndTime != end) { - mReportStartTime = start; - mReportEndTime = end; + if (mReportPeriodStart != start || mReportPeriodEnd != end) { + mReportPeriodStart = start; + mReportPeriodEnd = end; mChart.setData(getData(new ArrayList<>(Arrays.asList(AccountType.INCOME, AccountType.EXPENSE)))); mChart.invalidate(); } @@ -396,16 +345,6 @@ public void onGroupingUpdated(GroupInterval groupInterval) { } } - @Override - public void onAccountTypeUpdated(AccountType accountType) { - //nothing to see here, line chart shows both income and expense - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.chart_actions, menu); - } - @Override public void onPrepareOptionsMenu(Menu menu) { menu.findItem(R.id.menu_toggle_average_lines).setVisible(mChartDataPresent); @@ -451,11 +390,7 @@ public void onValueSelected(Entry e, int dataSetIndex, Highlight h) { String label = mChart.getData().getXVals().get(e.getXIndex()); double value = e.getVal(); double sum = mChart.getData().getDataSetByIndex(dataSetIndex).getYValueSum(); - mChartSliceInfo.setText(String.format(SELECTED_VALUE_PATTERN, label, value, value / sum * 100)); + mSelectedValueTextView.setText(String.format(SELECTED_VALUE_PATTERN, label, value, value / sum * 100)); } - @Override - public void onNothingSelected() { - mChartSliceInfo.setText(""); - } } diff --git a/app/src/main/java/org/gnucash/android/ui/report/PieChartFragment.java b/app/src/main/java/org/gnucash/android/ui/report/piechart/PieChartFragment.java similarity index 76% rename from app/src/main/java/org/gnucash/android/ui/report/PieChartFragment.java rename to app/src/main/java/org/gnucash/android/ui/report/piechart/PieChartFragment.java index c0e1ca4a6..fab2e24be 100644 --- a/app/src/main/java/org/gnucash/android/ui/report/PieChartFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/report/piechart/PieChartFragment.java @@ -15,44 +15,34 @@ * limitations under the License. */ -package org.gnucash.android.ui.report; +package org.gnucash.android.ui.report.piechart; import android.content.Context; import android.graphics.Color; import android.os.Bundle; import android.preference.PreferenceManager; import android.support.annotation.Nullable; -import android.support.v4.app.Fragment; -import android.support.v7.app.AppCompatActivity; -import android.view.LayoutInflater; import android.view.Menu; -import android.view.MenuInflater; import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; import com.github.mikephil.charting.charts.PieChart; import com.github.mikephil.charting.data.Entry; import com.github.mikephil.charting.data.PieData; import com.github.mikephil.charting.data.PieDataSet; import com.github.mikephil.charting.highlight.Highlight; -import com.github.mikephil.charting.listener.OnChartValueSelectedListener; import org.gnucash.android.R; -import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.model.Account; -import org.gnucash.android.model.AccountType; +import org.gnucash.android.ui.report.BaseReportFragment; +import org.gnucash.android.ui.report.ReportType; +import org.gnucash.android.ui.report.ReportsActivity; import java.util.ArrayList; import java.util.Collections; -import java.util.Currency; import java.util.List; -import java.util.Locale; import butterknife.Bind; -import butterknife.ButterKnife; import static com.github.mikephil.charting.components.Legend.LegendForm; import static com.github.mikephil.charting.components.Legend.LegendPosition; @@ -63,13 +53,10 @@ * @author Oleksandr Tyshkovets * @author Ngewi Fet */ -public class PieChartFragment extends Fragment implements OnChartValueSelectedListener, - ReportOptionsListener { +public class PieChartFragment extends BaseReportFragment { - public static final String SELECTED_VALUE_PATTERN = "%s - %.2f (%.2f %%)"; public static final String TOTAL_VALUE_LABEL_PATTERN = "%s\n%.2f %s"; private static final int ANIMATION_DURATION = 1800; - public static final int NO_DATA_COLOR = Color.LTGRAY; public static final int CENTER_TEXT_SIZE = 18; /** * The space in degrees between the chart slices @@ -81,52 +68,24 @@ public class PieChartFragment extends Fragment implements OnChartValueSelectedLi private static final double GROUPING_SMALLER_SLICES_THRESHOLD = 5; @Bind(R.id.pie_chart) PieChart mChart; - @Bind(R.id.selected_chart_slice) TextView mSelectedValueTextView; private AccountsDbAdapter mAccountsDbAdapter; - private AccountType mAccountType; - private boolean mChartDataPresent = true; private boolean mUseAccountColor = true; private boolean mGroupSmallerSlices = true; - private String mCurrencyCode; - - /** - * Start time for reporting period in millis - */ - private long mReportStartTime = -1; - - /** - * End time for reporting period in millis - */ - private long mReportEndTime = -1; - - - @Nullable - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_pie_chart, container, false); - ButterKnife.bind(this, view); - return view; - } - @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - ((AppCompatActivity)getActivity()).getSupportActionBar().setTitle(R.string.title_pie_chart); - setHasOptionsMenu(true); - mUseAccountColor = PreferenceManager.getDefaultSharedPreferences(getActivity()) .getBoolean(getString(R.string.key_use_account_color), false); mAccountsDbAdapter = AccountsDbAdapter.getInstance(); - mCurrencyCode = GnuCashApplication.getDefaultCurrencyCode(); mChart.setCenterTextSize(CENTER_TEXT_SIZE); mChart.setDescription(""); @@ -135,47 +94,49 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { mChart.getLegend().setWordWrapEnabled(true); mChart.getLegend().setPosition(LegendPosition.BELOW_CHART_CENTER); - ReportsActivity reportsActivity = (ReportsActivity) getActivity(); - mReportStartTime = reportsActivity.getReportStartTime(); - mReportEndTime = reportsActivity.getReportEndTime(); - mAccountType = reportsActivity.getAccountType(); + } - displayChart(); + @Override + public int getTitle() { + return R.string.title_pie_chart; } - /** - * Sets the app bar color - */ @Override - public void onResume() { - super.onResume(); - ((ReportsActivity)getActivity()).setAppBarColor(R.color.account_green); + public ReportType getReportType() { + return ReportType.PIE_CHART; } - /** - * Manages all actions about displaying the pie chart - */ - private void displayChart() { - mSelectedValueTextView.setText(R.string.label_select_pie_slice_to_see_details); - mChart.highlightValues(null); - mChart.clear(); + @Override + public int getLayoutResource() { + return R.layout.fragment_pie_chart; + } + @Override + protected void generateReport() { PieData pieData = getData(); if (pieData != null && pieData.getYValCount() != 0) { mChartDataPresent = true; mChart.setData(mGroupSmallerSlices ? groupSmallerSlices(pieData, getActivity()) : pieData); float sum = mChart.getData().getYValueSum(); String total = getResources().getString(R.string.label_chart_total); - String currencySymbol = Currency.getInstance(mCurrencyCode).getSymbol(Locale.getDefault()); + String currencySymbol = mCommodity.getSymbol(); mChart.setCenterText(String.format(TOTAL_VALUE_LABEL_PATTERN, total, sum, currencySymbol)); - mChart.animateXY(ANIMATION_DURATION, ANIMATION_DURATION); } else { mChartDataPresent = false; mChart.setCenterText(getResources().getString(R.string.label_chart_no_data)); mChart.setData(getEmptyData()); } + } + + @Override + protected void displayReport() { + if (mChartDataPresent){ + mChart.animateXY(ANIMATION_DURATION, ANIMATION_DURATION); + } + mSelectedValueTextView.setText(R.string.label_select_pie_slice_to_see_details); mChart.setTouchEnabled(mChartDataPresent); + mChart.highlightValues(null); mChart.invalidate(); } @@ -190,10 +151,10 @@ private PieData getData() { for (Account account : mAccountsDbAdapter.getSimpleAccountList()) { if (account.getAccountType() == mAccountType && !account.isPlaceholderAccount() - && account.getCurrency() == Currency.getInstance(mCurrencyCode)) { + && account.getCommodity().equals(mCommodity)) { double balance = mAccountsDbAdapter.getAccountsBalance(Collections.singletonList(account.getUID()), - mReportStartTime, mReportEndTime).asDouble(); + mReportPeriodStart, mReportPeriodEnd).asDouble(); if (balance > 0) { dataSet.addEntry(new Entry((float) balance, dataSet.getEntryCount())); int color; @@ -214,27 +175,6 @@ private PieData getData() { return new PieData(labels, dataSet); } - @Override - public void onTimeRangeUpdated(long start, long end) { - if (mReportStartTime != start || mReportEndTime != end) { - mReportStartTime = start; - mReportEndTime = end; - displayChart(); - } - } - - @Override - public void onGroupingUpdated(ReportsActivity.GroupInterval groupInterval) { - //nothing to see here, this doesn't make sense for a pie chart - } - - @Override - public void onAccountTypeUpdated(AccountType accountType) { - if (mAccountType != accountType) { - mAccountType = accountType; - displayChart(); - } - } /** * Returns a data object that represents situation when no user data available @@ -281,11 +221,6 @@ private void bubbleSort() { mChart.invalidate(); } - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.chart_actions, menu); - } - @Override public void onPrepareOptionsMenu(Menu menu) { menu.findItem(R.id.menu_order_by_size).setVisible(mChartDataPresent); @@ -320,7 +255,7 @@ public boolean onOptionsItemSelected(MenuItem item) { } case R.id.menu_group_other_slice: { mGroupSmallerSlices = !mGroupSmallerSlices; - displayChart(); + refresh(); return true; } @@ -372,9 +307,4 @@ public void onValueSelected(Entry e, int dataSetIndex, Highlight h) { float percent = value / mChart.getData().getYValueSum() * 100; mSelectedValueTextView.setText(String.format(SELECTED_VALUE_PATTERN, label, value, percent)); } - - @Override - public void onNothingSelected() { - mSelectedValueTextView.setText(""); - } } diff --git a/app/src/main/java/org/gnucash/android/ui/report/BalanceSheetFragment.java b/app/src/main/java/org/gnucash/android/ui/report/sheet/BalanceSheetFragment.java similarity index 66% rename from app/src/main/java/org/gnucash/android/ui/report/BalanceSheetFragment.java rename to app/src/main/java/org/gnucash/android/ui/report/sheet/BalanceSheetFragment.java index 79f77a5ee..ad459e301 100644 --- a/app/src/main/java/org/gnucash/android/ui/report/BalanceSheetFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/report/sheet/BalanceSheetFragment.java @@ -13,40 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.gnucash.android.ui.report; +package org.gnucash.android.ui.report.sheet; import android.database.Cursor; import android.graphics.Typeface; import android.os.Bundle; import android.support.annotation.Nullable; -import android.support.v4.app.Fragment; -import android.support.v7.app.AppCompatActivity; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.Menu; import android.view.View; -import android.view.ViewGroup; import android.widget.TableLayout; import android.widget.TextView; import org.gnucash.android.R; -import org.gnucash.android.db.AccountsDbAdapter; import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.Money; +import org.gnucash.android.ui.report.BaseReportFragment; +import org.gnucash.android.ui.report.ReportType; import org.gnucash.android.ui.transaction.TransactionsActivity; import java.util.ArrayList; import java.util.List; import butterknife.Bind; -import butterknife.ButterKnife; /** - * Fragment report as text + * Balance sheet report fragment * @author Ngewi Fet */ -public class BalanceSheetFragment extends Fragment { +public class BalanceSheetFragment extends BaseReportFragment { @Bind(R.id.table_assets) TableLayout mAssetsTableLayout; @Bind(R.id.table_liabilities) TableLayout mLiabilitiesTableLayout; @@ -54,47 +52,68 @@ public class BalanceSheetFragment extends Fragment { @Bind(R.id.total_liability_and_equity) TextView mNetWorth; - AccountsDbAdapter mAccountsDbAdapter = AccountsDbAdapter.getInstance(); - @Nullable + private Money mAssetsBalance; + private Money mLiabilitiesBalance; + private List mAssetAccountTypes; + private List mLiabilityAccountTypes; + private List mEquityAccountTypes; + + @Override + public int getLayoutResource() { + return R.layout.fragment_text_report; + } + @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_text_report, container, false); - ButterKnife.bind(this, view); - return view; + public int getTitle() { + return R.string.title_balance_sheet_report; + } + + @Override + public ReportType getReportType() { + return ReportType.TEXT; } @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); - ((AppCompatActivity)getActivity()).getSupportActionBar().setTitle(R.string.title_balance_sheet_report); - setHasOptionsMenu(true); - - List accountTypes = new ArrayList<>(); - accountTypes.add(AccountType.ASSET); - accountTypes.add(AccountType.CASH); - accountTypes.add(AccountType.BANK); - loadAccountViews(accountTypes, mAssetsTableLayout); - Money assetsBalance = mAccountsDbAdapter.getAccountBalance(accountTypes, -1, System.currentTimeMillis()); - - accountTypes.clear(); - accountTypes.add(AccountType.LIABILITY); - accountTypes.add(AccountType.CREDIT); - loadAccountViews(accountTypes, mLiabilitiesTableLayout); - Money liabilitiesBalance = mAccountsDbAdapter.getAccountBalance(accountTypes, -1, System.currentTimeMillis()); - - accountTypes.clear(); - accountTypes.add(AccountType.EQUITY); - loadAccountViews(accountTypes, mEquityTableLayout); - - TransactionsActivity.displayBalance(mNetWorth, assetsBalance.subtract(liabilitiesBalance)); + mAssetAccountTypes = new ArrayList<>(); + mAssetAccountTypes.add(AccountType.ASSET); + mAssetAccountTypes.add(AccountType.CASH); + mAssetAccountTypes.add(AccountType.BANK); + + mLiabilityAccountTypes = new ArrayList<>(); + mLiabilityAccountTypes.add(AccountType.LIABILITY); + mLiabilityAccountTypes.add(AccountType.CREDIT); + + mEquityAccountTypes = new ArrayList<>(); + mEquityAccountTypes.add(AccountType.EQUITY); } @Override - public void onResume() { - super.onResume(); - ((ReportsActivity)getActivity()).setAppBarColor(R.color.account_purple); + public boolean requiresAccountTypeOptions() { + return false; + } + + @Override + public boolean requiresTimeRangeOptions() { + return false; + } + + @Override + protected void generateReport() { + mAssetsBalance = mAccountsDbAdapter.getAccountBalance(mAssetAccountTypes, -1, System.currentTimeMillis()); + mLiabilitiesBalance = mAccountsDbAdapter.getAccountBalance(mLiabilityAccountTypes, -1, System.currentTimeMillis()); + } + + @Override + protected void displayReport() { + loadAccountViews(mAssetAccountTypes, mAssetsTableLayout); + loadAccountViews(mLiabilityAccountTypes, mLiabilitiesTableLayout); + loadAccountViews(mEquityAccountTypes, mEquityTableLayout); + + TransactionsActivity.displayBalance(mNetWorth, mAssetsBalance.subtract(mLiabilitiesBalance)); } @Override @@ -122,7 +141,7 @@ private void loadAccountViews(List accountTypes, TableLayout tableL Money balance = mAccountsDbAdapter.getAccountBalance(accountUID); View view = inflater.inflate(R.layout.row_balance_sheet, tableLayout, false); ((TextView)view.findViewById(R.id.account_name)).setText(name); - TextView balanceTextView = ((TextView) view.findViewById(R.id.account_balance)); + TextView balanceTextView = (TextView) view.findViewById(R.id.account_balance); TransactionsActivity.displayBalance(balanceTextView, balance); tableLayout.addView(view); } diff --git a/app/src/main/java/org/gnucash/android/ui/settings/AboutPreferenceFragment.java b/app/src/main/java/org/gnucash/android/ui/settings/AboutPreferenceFragment.java index 6194f0c38..72dedf2f1 100644 --- a/app/src/main/java/org/gnucash/android/ui/settings/AboutPreferenceFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/settings/AboutPreferenceFragment.java @@ -16,11 +16,11 @@ package org.gnucash.android.ui.settings; -import android.annotation.TargetApi; import android.os.Bundle; -import android.preference.Preference; -import android.preference.PreferenceFragment; import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceFragmentCompat; import org.gnucash.android.BuildConfig; import org.gnucash.android.R; @@ -32,14 +32,18 @@ * @author Ngewi Fet * */ -@TargetApi(11) -public class AboutPreferenceFragment extends PreferenceFragment{ - +public class AboutPreferenceFragment extends PreferenceFragmentCompat{ + + @Override + public void onCreatePreferences(Bundle bundle, String s) { + addPreferencesFromResource(R.xml.fragment_about_preferences); + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - addPreferencesFromResource(R.xml.fragment_about_preferences); - ActionBar actionBar = ((AppCompatPreferenceActivity) getActivity()).getSupportActionBar(); + + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); actionBar.setHomeButtonEnabled(true); actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setTitle(R.string.title_about_gnucash); diff --git a/app/src/main/java/org/gnucash/android/ui/settings/AccountPreferencesFragment.java b/app/src/main/java/org/gnucash/android/ui/settings/AccountPreferencesFragment.java index 5242df81a..7a35d542c 100644 --- a/app/src/main/java/org/gnucash/android/ui/settings/AccountPreferencesFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/settings/AccountPreferencesFragment.java @@ -16,24 +16,25 @@ package org.gnucash.android.ui.settings; -import android.annotation.TargetApi; import android.app.Activity; import android.app.AlertDialog; import android.content.DialogInterface; +import android.content.Intent; import android.database.Cursor; import android.os.Bundle; -import android.preference.ListPreference; -import android.preference.Preference; -import android.preference.PreferenceFragment; import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.preference.ListPreference; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceFragmentCompat; import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.CommoditiesDbAdapter; import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.model.Commodity; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; import org.gnucash.android.model.Money; import org.gnucash.android.ui.account.AccountsActivity; +import org.gnucash.android.ui.settings.dialog.DeleteAllAccountsConfirmationDialog; import java.util.ArrayList; import java.util.List; @@ -44,26 +45,24 @@ * @author Ngewi Fet * @author Oleksandr Tyshkovets */ -@TargetApi(11) -public class AccountPreferencesFragment extends PreferenceFragment { - - private Activity mActivity; +public class AccountPreferencesFragment extends PreferenceFragmentCompat implements + Preference.OnPreferenceChangeListener, Preference.OnPreferenceClickListener{ List mCurrencyEntries = new ArrayList<>(); List mCurrencyEntryValues = new ArrayList<>(); + @Override + public void onCreatePreferences(Bundle bundle, String s) { + addPreferencesFromResource(R.xml.fragment_account_preferences); + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - addPreferencesFromResource(R.xml.fragment_account_preferences); - ActionBar actionBar = ((AppCompatPreferenceActivity) getActivity()).getSupportActionBar(); - actionBar.setHomeButtonEnabled(true); - actionBar.setDisplayHomeAsUpEnabled(true); + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); actionBar.setTitle(R.string.title_account_preferences); - mActivity = getActivity(); - Cursor cursor = CommoditiesDbAdapter.getInstance().fetchAllRecords(DatabaseSchema.CommodityEntry.COLUMN_MNEMONIC + " ASC"); while(cursor.moveToNext()){ String code = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.CommodityEntry.COLUMN_MNEMONIC)); @@ -82,7 +81,7 @@ public void onResume() { Preference pref = findPreference(getString(R.string.key_default_currency)); String currencyName = CommoditiesDbAdapter.getInstance().getCommodity(defaultCurrency).getFullname(); pref.setSummary(currencyName); - pref.setOnPreferenceChangeListener((SettingsActivity) getActivity()); + pref.setOnPreferenceChangeListener(this); CharSequence[] entries = new CharSequence[mCurrencyEntries.size()]; CharSequence[] entryValues = new CharSequence[mCurrencyEntryValues.size()]; @@ -90,13 +89,13 @@ public void onResume() { ((ListPreference) pref).setEntryValues(mCurrencyEntryValues.toArray(entryValues)); Preference preference = findPreference(getString(R.string.key_import_accounts)); - preference.setOnPreferenceClickListener((SettingsActivity)getActivity()); + preference.setOnPreferenceClickListener(this); preference = findPreference(getString(R.string.key_delete_all_accounts)); preference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { @Override public boolean onPreferenceClick(Preference preference) { - deleteAllAccounts(); + showDeleteAccountsDialog(); return true; } }); @@ -112,7 +111,7 @@ public boolean onPreferenceClick(Preference preference) { .setPositiveButton(R.string.btn_create_accounts, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { - AccountsActivity.createDefaultAccounts(Money.DEFAULT_CURRENCY_CODE, mActivity); + AccountsActivity.createDefaultAccounts(Money.DEFAULT_CURRENCY_CODE, getActivity()); } }) .setNegativeButton(R.string.btn_cancel, new DialogInterface.OnClickListener() { @@ -129,9 +128,45 @@ public void onClick(DialogInterface dialogInterface, int i) { }); } - public void deleteAllAccounts(){ + @Override + public boolean onPreferenceClick(Preference preference) { + String key = preference.getKey(); + + if (key.equals(getString(R.string.key_import_accounts))){ + AccountsActivity.startXmlFileChooser(this); + return true; + } + + return false; + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + if (preference.getKey().equals(getString(R.string.key_default_currency))){ + GnuCashApplication.setDefaultCurrencyCode(newValue.toString()); + String fullname = CommoditiesDbAdapter.getInstance().getCommodity(newValue.toString()).getFullname(); + preference.setSummary(fullname); + return true; + } + return false; + } + + /** + * Show the dialog for deleting accounts + */ + public void showDeleteAccountsDialog(){ DeleteAllAccountsConfirmationDialog deleteConfirmationDialog = DeleteAllAccountsConfirmationDialog.newInstance(); - deleteConfirmationDialog.show(getFragmentManager(), "account_settings"); + deleteConfirmationDialog.show(getActivity().getSupportFragmentManager(), "account_settings"); + } + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode){ + case AccountsActivity.REQUEST_PICK_ACCOUNTS_FILE: + if (resultCode == Activity.RESULT_OK && data != null) { + AccountsActivity.importXmlFileFromIntent(getActivity(), data, null); + } + break; + } } } diff --git a/app/src/main/java/org/gnucash/android/ui/settings/AppCompatPreferenceActivity.java b/app/src/main/java/org/gnucash/android/ui/settings/AppCompatPreferenceActivity.java deleted file mode 100644 index c0cea92c3..000000000 --- a/app/src/main/java/org/gnucash/android/ui/settings/AppCompatPreferenceActivity.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (C) 2014 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.gnucash.android.ui.settings; - -import android.content.res.Configuration; -import android.os.Bundle; -import android.preference.PreferenceActivity; -import android.support.annotation.LayoutRes; -import android.support.annotation.Nullable; -import android.support.v7.app.ActionBar; -import android.support.v7.app.AppCompatDelegate; -import android.support.v7.widget.Toolbar; -import android.view.MenuInflater; -import android.view.View; -import android.view.ViewGroup; - -/** - * A {@link android.preference.PreferenceActivity} which implements and proxies the necessary calls - * to be used with AppCompat. - * - * This technique can be used with an {@link android.app.Activity} class, not just - * {@link android.preference.PreferenceActivity}. - */ -public abstract class AppCompatPreferenceActivity extends PreferenceActivity { - - private AppCompatDelegate mDelegate; - - @Override - protected void onCreate(Bundle savedInstanceState) { - getDelegate().installViewFactory(); - getDelegate().onCreate(savedInstanceState); - super.onCreate(savedInstanceState); - } - - @Override - protected void onPostCreate(Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - getDelegate().onPostCreate(savedInstanceState); - } - - public ActionBar getSupportActionBar() { - return getDelegate().getSupportActionBar(); - } - - public void setSupportActionBar(@Nullable Toolbar toolbar) { - getDelegate().setSupportActionBar(toolbar); - } - - @Override - public MenuInflater getMenuInflater() { - return getDelegate().getMenuInflater(); - } - - @Override - public void setContentView(@LayoutRes int layoutResID) { - getDelegate().setContentView(layoutResID); - } - - @Override - public void setContentView(View view) { - getDelegate().setContentView(view); - } - - @Override - public void setContentView(View view, ViewGroup.LayoutParams params) { - getDelegate().setContentView(view, params); - } - - @Override - public void addContentView(View view, ViewGroup.LayoutParams params) { - getDelegate().addContentView(view, params); - } - - @Override - protected void onPostResume() { - super.onPostResume(); - getDelegate().onPostResume(); - } - - @Override - protected void onTitleChanged(CharSequence title, int color) { - super.onTitleChanged(title, color); - getDelegate().setTitle(title); - } - - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); - getDelegate().onConfigurationChanged(newConfig); - } - - @Override - protected void onStop() { - super.onStop(); - getDelegate().onStop(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - getDelegate().onDestroy(); - } - - public void invalidateOptionsMenu() { - getDelegate().invalidateOptionsMenu(); - } - - private AppCompatDelegate getDelegate() { - if (mDelegate == null) { - mDelegate = AppCompatDelegate.create(this, null); - } - return mDelegate; - } -} \ No newline at end of file diff --git a/app/src/main/java/org/gnucash/android/ui/settings/BackupPreferenceFragment.java b/app/src/main/java/org/gnucash/android/ui/settings/BackupPreferenceFragment.java index 32ff184f3..b262a1578 100644 --- a/app/src/main/java/org/gnucash/android/ui/settings/BackupPreferenceFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/settings/BackupPreferenceFragment.java @@ -16,17 +16,49 @@ package org.gnucash.android.ui.settings; -import android.annotation.TargetApi; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentSender; import android.content.SharedPreferences; +import android.net.Uri; import android.os.Bundle; -import android.preference.Preference; -import android.preference.Preference.OnPreferenceChangeListener; -import android.preference.PreferenceFragment; -import android.preference.PreferenceManager; import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.preference.CheckBoxPreference; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceFragmentCompat; +import android.support.v7.preference.PreferenceManager; +import android.util.Log; +import android.widget.ArrayAdapter; +import android.widget.Toast; + +import com.dropbox.sync.android.DbxAccountManager; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GooglePlayServicesUtil; +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.drive.Drive; +import com.google.android.gms.drive.DriveFolder; +import com.google.android.gms.drive.MetadataChangeSet; import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.export.Exporter; +import org.gnucash.android.export.xml.GncXmlExporter; +import org.gnucash.android.importer.ImportAsyncTask; +import org.gnucash.android.ui.settings.dialog.OwnCloudDialogFragment; + +import java.io.File; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; /** @@ -34,18 +66,53 @@ * @author Ngewi Fet * */ -@TargetApi(11) -public class BackupPreferenceFragment extends PreferenceFragment implements OnPreferenceChangeListener{ - +public class BackupPreferenceFragment extends PreferenceFragmentCompat implements + Preference.OnPreferenceClickListener, Preference.OnPreferenceChangeListener { + + /** + * Collects references to the UI elements and binds click listeners + */ + private static final int REQUEST_LINK_TO_DBX = 0x11; + public static final int REQUEST_RESOLVE_CONNECTION = 0x12; + + /** + * Testing app key for DropBox API + */ + final static public String DROPBOX_APP_KEY = "dhjh8ke9wf05948"; + + /** + * Testing app secret for DropBox API + */ + final static public String DROPBOX_APP_SECRET = "h2t9fphj3nr4wkw"; + public static final String LOG_TAG = "BackupPrefFragment"; + + private DbxAccountManager mDbxAccountManager; + /** + * Client for Google Drive Sync + */ + public static GoogleApiClient mGoogleApiClient; + + + @Override + public void onCreatePreferences(Bundle bundle, String s) { + addPreferencesFromResource(R.xml.fragment_backup_preferences); + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - - addPreferencesFromResource(R.xml.fragment_backup_preferences); - ActionBar actionBar = ((AppCompatPreferenceActivity) getActivity()).getSupportActionBar(); + + ActionBar actionBar = ((AppCompatActivity)getActivity()).getSupportActionBar(); actionBar.setHomeButtonEnabled(true); actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setTitle(R.string.title_backup_prefs); + + String dropboxAppKey = getString(R.string.dropbox_app_key, DROPBOX_APP_KEY); + String dropboxAppSecret = getString(R.string.dropbox_app_secret, DROPBOX_APP_SECRET); + mDbxAccountManager = DbxAccountManager.getInstance(getActivity().getApplicationContext(), + dropboxAppKey, dropboxAppSecret); + + mGoogleApiClient = getGoogleApiClient(getActivity()); } @@ -71,21 +138,58 @@ public void onResume() { pref.setOnPreferenceChangeListener(this); pref = findPreference(getString(R.string.key_restore_backup)); - pref.setOnPreferenceClickListener((SettingsActivity)getActivity()); + pref.setOnPreferenceClickListener(this); pref = findPreference(getString(R.string.key_create_backup)); - pref.setOnPreferenceClickListener((SettingsActivity)getActivity()); + pref.setOnPreferenceClickListener(this); pref = findPreference(getString(R.string.key_dropbox_sync)); - pref.setOnPreferenceClickListener((SettingsActivity)getActivity()); - ((SettingsActivity)getActivity()).toggleDropboxPreference(pref); + pref.setOnPreferenceClickListener(this); + toggleDropboxPreference(pref); pref = findPreference(getString(R.string.key_google_drive_sync)); - pref.setOnPreferenceClickListener((SettingsActivity) getActivity()); - ((SettingsActivity)getActivity()).toggleGoogleDrivePreference(pref); + pref.setOnPreferenceClickListener(this); + toggleGoogleDrivePreference(pref); + + pref = findPreference(getString(R.string.key_owncloud_sync)); + pref.setOnPreferenceClickListener(this); + toggleOwnCloudPreference(pref); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + String key = preference.getKey(); + + if (key.equals(getString(R.string.key_restore_backup))){ + restoreBackup(); + } + + + if (key.equals(getString(R.string.key_dropbox_sync))){ + toggleDropboxSync(); + toggleDropboxPreference(preference); + } + + if (key.equals(getString(R.string.key_google_drive_sync))){ + toggleGoogleDriveSync(); + toggleGoogleDrivePreference(preference); + } + + if (key.equals(getString(R.string.key_owncloud_sync))){ + toggleOwnCloudSync(preference); + toggleOwnCloudPreference(preference); + } + + if (key.equals(getString(R.string.key_create_backup))){ + boolean result = GncXmlExporter.createBackup(); + int msg = result ? R.string.toast_backup_successful : R.string.toast_backup_failed; + Toast.makeText(getActivity(), msg, Toast.LENGTH_SHORT).show(); + } + + return false; } - /** + /** * Listens for changes to the preference and sets the preference summary to the new value * @param preference Preference which has been changed * @param newValue New value for the changed preference @@ -114,4 +218,212 @@ public boolean onPreferenceChange(Preference preference, Object newValue) { return true; } + + + /** + * Toggles the checkbox of the DropBox Sync preference if a DropBox account is linked + * @param pref DropBox Sync preference + */ + public void toggleDropboxPreference(Preference pref) { + ((CheckBoxPreference)pref).setChecked(mDbxAccountManager.hasLinkedAccount()); + } + + /** + * Toggles the checkbox of the ownCloud Sync preference if an ownCloud account is linked + * @param pref ownCloud Sync preference + */ + public void toggleOwnCloudPreference(Preference pref) { + SharedPreferences mPrefs = getActivity().getSharedPreferences(getString(R.string.owncloud_pref), Context.MODE_PRIVATE); + ((CheckBoxPreference)pref).setChecked(mPrefs.getBoolean(getString(R.string.owncloud_sync), false)); + } + + /** + * Toggles the checkbox of the GoogleDrive Sync preference if a Google Drive account is linked + * @param pref Google Drive Sync preference + */ + public void toggleGoogleDrivePreference(Preference pref){ + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + String appFolderId = sharedPreferences.getString(getString(R.string.key_google_drive_app_folder_id),null); + ((CheckBoxPreference)pref).setChecked(appFolderId != null); + } + + + /** + * Toggles the authorization state of a DropBox account. + * If a link exists, it is removed else DropBox authorization is started + */ + private void toggleDropboxSync() { + if (mDbxAccountManager.hasLinkedAccount()){ + mDbxAccountManager.unlink(); + } else { + mDbxAccountManager.startLink(getActivity(), REQUEST_LINK_TO_DBX); + } + } + + /** + * Toggles synchronization with Google Drive on or off + */ + private void toggleGoogleDriveSync(){ + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + final String appFolderId = sharedPreferences.getString(getString(R.string.key_google_drive_app_folder_id), null); + if (appFolderId != null){ + sharedPreferences.edit().remove(getString(R.string.key_google_drive_app_folder_id)).commit(); //commit (not apply) because we need it to be saved *now* + mGoogleApiClient.disconnect(); + } else { + mGoogleApiClient.connect(); + } + } + + /** + * Toggles synchronization with ownCloud on or off + */ + private void toggleOwnCloudSync(Preference pref){ + SharedPreferences mPrefs = getActivity().getSharedPreferences(getString(R.string.owncloud_pref), Context.MODE_PRIVATE); + + if (mPrefs.getBoolean(getString(R.string.owncloud_sync), false)) + mPrefs.edit().putBoolean(getString(R.string.owncloud_sync), false).apply(); + else { + OwnCloudDialogFragment ocDialog = OwnCloudDialogFragment.newInstance(pref); + ocDialog.show(getActivity().getSupportFragmentManager(), "owncloud_dialog"); + } + } + + + public static GoogleApiClient getGoogleApiClient(final Context context) { + return new GoogleApiClient.Builder(context) + .addApi(Drive.API) + .addScope(Drive.SCOPE_APPFOLDER) + .addScope(Drive.SCOPE_FILE) + .addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() { + @Override + public void onConnected(Bundle bundle) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + String appFolderId = sharedPreferences.getString(context.getString(R.string.key_google_drive_app_folder_id), null); + if (appFolderId == null) { + MetadataChangeSet changeSet = new MetadataChangeSet.Builder() + .setTitle(context.getString(R.string.app_name)).build(); + Drive.DriveApi.getRootFolder(mGoogleApiClient).createFolder( + mGoogleApiClient, changeSet).setResultCallback(new ResultCallback() { + @Override + public void onResult(DriveFolder.DriveFolderResult result) { + if (!result.getStatus().isSuccess()) { + Log.e(LOG_TAG, "Error creating the application folder"); + return; + } + + String folderId = result.getDriveFolder().getDriveId().toString(); + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(context.getString(R.string.key_google_drive_app_folder_id), + folderId).commit(); //commit because we need it to be saved *now* + } + }); + + } + Toast.makeText(context, R.string.toast_connected_to_google_drive, Toast.LENGTH_SHORT).show(); + } + + @Override + public void onConnectionSuspended(int i) { + Toast.makeText(context, "Connection to Google Drive suspended!", Toast.LENGTH_LONG).show(); + } + }) + .addOnConnectionFailedListener(new GoogleApiClient.OnConnectionFailedListener() { + @Override + public void onConnectionFailed(ConnectionResult connectionResult) { + Log.e(PreferenceActivity.class.getName(), "Connection to Google Drive failed"); + if (connectionResult.hasResolution() && context instanceof Activity) { + try { + Log.e(BackupPreferenceFragment.class.getName(), "Trying resolution of Google API connection failure"); + connectionResult.startResolutionForResult((Activity) context, REQUEST_RESOLVE_CONNECTION); + } catch (IntentSender.SendIntentException e) { + Log.e(BackupPreferenceFragment.class.getName(), e.getMessage()); + Toast.makeText(context, R.string.toast_unable_to_connect_to_google_drive, Toast.LENGTH_LONG).show(); + } + } else { + if (context instanceof Activity) + GooglePlayServicesUtil.getErrorDialog(connectionResult.getErrorCode(), (Activity) context, 0).show(); + } + } + }) + .build(); + } + + /** + * Opens a dialog for a user to select a backup to restore and then restores the backup + */ + private void restoreBackup() { + Log.i("Settings", "Opening GnuCash XML backups for restore"); + String bookUID = BooksDbAdapter.getInstance().getActiveBookUID(); + File[] backupFiles = new File(Exporter.getBackupFolderPath(bookUID)).listFiles(); + if (backupFiles == null || backupFiles.length == 0){ + android.support.v7.app.AlertDialog.Builder builder = new android.support.v7.app.AlertDialog.Builder(getActivity()) + .setTitle("No backups found") + .setMessage("There are no existing backup files to restore from") + .setNegativeButton(R.string.label_dismiss, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + builder.create().show(); + return; + } + + Arrays.sort(backupFiles); + List backupFilesList = Arrays.asList(backupFiles); + Collections.reverse(backupFilesList); + final File[] sortedBackupFiles = (File[]) backupFilesList.toArray(); + + final ArrayAdapter arrayAdapter = new ArrayAdapter<>(getActivity(), android.R.layout.select_dialog_singlechoice); + final DateFormat dateFormatter = SimpleDateFormat.getDateTimeInstance(); + for (File backupFile : sortedBackupFiles) { + long time = Exporter.getExportTime(backupFile.getName()); + if (time > 0) + arrayAdapter.add(dateFormatter.format(new Date(time))); + else //if no timestamp was found in the filename, just use the name + arrayAdapter.add(backupFile.getName()); + } + + AlertDialog.Builder restoreDialogBuilder = new AlertDialog.Builder(getActivity()); + restoreDialogBuilder.setTitle(R.string.title_select_backup_to_restore); + restoreDialogBuilder.setNegativeButton(R.string.alert_dialog_cancel, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + restoreDialogBuilder.setAdapter(arrayAdapter, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + File backupFile = sortedBackupFiles[which]; + new ImportAsyncTask(getActivity()).execute(Uri.fromFile(backupFile)); + } + }); + + restoreDialogBuilder.create().show(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + switch (requestCode){ + + case REQUEST_LINK_TO_DBX: + Preference preference = findPreference(getString(R.string.key_dropbox_sync)); + if (preference == null) //if we are in a preference header fragment, this may return null + break; + toggleDropboxPreference(preference); + break; + + case REQUEST_RESOLVE_CONNECTION: + if (resultCode == Activity.RESULT_OK) { + mGoogleApiClient.connect(); + Preference pref = findPreference(getString(R.string.key_dropbox_sync)); + if (pref == null) //if we are in a preference header fragment, this may return null + break; + toggleDropboxPreference(pref); + } + break; + } + } } diff --git a/app/src/main/java/org/gnucash/android/ui/settings/BookManagerFragment.java b/app/src/main/java/org/gnucash/android/ui/settings/BookManagerFragment.java new file mode 100644 index 000000000..6d7e391ad --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/settings/BookManagerFragment.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2016 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.ui.settings; + +import android.content.Context; +import android.content.DialogInterface; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.ListFragment; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.Loader; +import android.support.v4.widget.SimpleCursorAdapter; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AlertDialog; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.PopupMenu; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; + +import org.gnucash.android.R; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.DatabaseCursorLoader; +import org.gnucash.android.db.DatabaseHelper; +import org.gnucash.android.db.DatabaseSchema.BookEntry; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; +import org.gnucash.android.ui.account.AccountsActivity; +import org.gnucash.android.ui.common.Refreshable; +import org.gnucash.android.util.PreferencesHelper; + +/** + * Fragment for managing the books in the database + */ +public class BookManagerFragment extends ListFragment implements + LoaderManager.LoaderCallbacks, Refreshable{ + + private static String LOG_TAG = "BookManagerFragment"; + + SimpleCursorAdapter mCursorAdapter; + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_book_list, container, false); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mCursorAdapter = new BooksCursorAdapter(getActivity(), R.layout.cardview_book, + null, new String[]{BookEntry.COLUMN_DISPLAY_NAME, BookEntry.COLUMN_SOURCE_URI}, + new int[]{R.id.primary_text, R.id.secondary_text}); + + setListAdapter(mCursorAdapter); + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + assert actionBar != null; + actionBar.setHomeButtonEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setTitle(R.string.title_manage_books); + setHasOptionsMenu(true); + + getListView().setChoiceMode(ListView.CHOICE_MODE_NONE); + } + + @Override + public void onResume() { + super.onResume(); + refresh(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.book_list_actions, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()){ + case R.id.menu_create_book: + AccountsActivity.createDefaultAccounts(GnuCashApplication.getDefaultCurrencyCode(), getActivity()); + return true; + + default: + return false; + } + + } + + @Override + public void refresh() { + getLoaderManager().restartLoader(0, null, this); + } + + @Override + public void refresh(String uid) { + refresh(); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + Log.d(LOG_TAG, "Creating loader for books"); + return new BooksCursorLoader(getActivity()); + } + + @Override + public void onLoadFinished(Loader loader, Cursor data) { + Log.d(LOG_TAG, "Finished loading books from database"); + mCursorAdapter.swapCursor(data); + mCursorAdapter.notifyDataSetChanged(); + } + + @Override + public void onLoaderReset(Loader loader) { + Log.d(LOG_TAG, "Resetting books list loader"); + mCursorAdapter.swapCursor(null); + } + + private class BooksCursorAdapter extends SimpleCursorAdapter { + + public BooksCursorAdapter(Context context, int layout, Cursor c, String[] from, int[] to) { + super(context, layout, c, from, to, 0); + } + + @Override + public void bindView(View view, final Context context, Cursor cursor) { + super.bindView(view, context, cursor); + + final String bookUID = cursor.getString(cursor.getColumnIndexOrThrow(BookEntry.COLUMN_UID)); + + TextView lastSyncText = (TextView) view.findViewById(R.id.last_sync_time); + lastSyncText.setText(PreferencesHelper.getLastExportTime().toString()); + + TextView labelLastSync = (TextView) view.findViewById(R.id.label_last_sync); + labelLastSync.setText(R.string.label_last_export_time); + + //retrieve some book statistics + DatabaseHelper dbHelper = new DatabaseHelper(GnuCashApplication.getAppContext(), bookUID); + SQLiteDatabase db = dbHelper.getReadableDatabase(); + TransactionsDbAdapter trnAdapter = new TransactionsDbAdapter(db, new SplitsDbAdapter(db)); + int transactionCount = (int) trnAdapter.getRecordsCount(); + String transactionStats = getResources().getQuantityString(R.plurals.book_transaction_stats, transactionCount, transactionCount); + + AccountsDbAdapter accountsDbAdapter = new AccountsDbAdapter(db, trnAdapter); + int accountsCount = (int) accountsDbAdapter.getRecordsCount(); + String accountStats = getResources().getQuantityString(R.plurals.book_account_stats, accountsCount, accountsCount); + String stats = accountStats + ", " + transactionStats; + TextView statsText = (TextView) view.findViewById(R.id.secondary_text); + statsText.setText(stats); + + ImageView optionsMenu = (ImageView) view.findViewById(R.id.options_menu); + optionsMenu.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + PopupMenu popupMenu = new PopupMenu(context, v); + MenuInflater menuInflater = popupMenu.getMenuInflater(); + menuInflater.inflate(R.menu.book_context_menu, popupMenu.getMenu()); + popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()){ + case R.id.ctx_menu_sync_book: + //TODO implement sync + return false; + default: + return true; + } + } + }); + popupMenu.show(); + } + }); + + ImageView deleteBookBtn = (ImageView) view.findViewById(R.id.delete_book); + String activeBookUID = BooksDbAdapter.getInstance().getActiveBookUID(); + if (activeBookUID.equals(bookUID)) //we cannot delete the active book + deleteBookBtn.setVisibility(View.GONE); + else { + deleteBookBtn.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + //// TODO: extract strings + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getActivity()); + dialogBuilder.setTitle(getString(R.string.title_confirm_delete_book)) + .setIcon(R.drawable.ic_close_black_24dp) + .setMessage(getString(R.string.msg_all_book_data_will_be_deleted)); + dialogBuilder.setPositiveButton(getString(R.string.btn_delete_book), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + BooksDbAdapter.getInstance().deleteBook(bookUID); + refresh(); + } + }); + dialogBuilder.setNegativeButton(R.string.btn_cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + AlertDialog dialog = dialogBuilder.create(); + dialog.show(); //must be called before you can access buttons + dialog.getButton(AlertDialog.BUTTON_POSITIVE) + .setTextColor(getResources().getColor(R.color.account_red)); + + + } + }); + } + + view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + //do nothing if the active book is tapped + if (!BooksDbAdapter.getInstance().getActiveBookUID().equals(bookUID)) { + GnuCashApplication.loadBook(bookUID); + } + } + }); + } + } + + /** + * {@link DatabaseCursorLoader} for loading the book list from the database + * @author Ngewi Fet + */ + private static class BooksCursorLoader extends DatabaseCursorLoader { + public BooksCursorLoader(Context context){ + super(context); + } + + @Override + public Cursor loadInBackground() { + BooksDbAdapter booksDbAdapter = BooksDbAdapter.getInstance(); + Cursor cursor = booksDbAdapter.fetchAllRecords(); + + registerContentObserver(cursor); + return cursor; + } + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/settings/GeneralPreferenceFragment.java b/app/src/main/java/org/gnucash/android/ui/settings/GeneralPreferenceFragment.java index 18f8a9011..26f14fad2 100644 --- a/app/src/main/java/org/gnucash/android/ui/settings/GeneralPreferenceFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/settings/GeneralPreferenceFragment.java @@ -16,21 +16,18 @@ package org.gnucash.android.ui.settings; -import android.annotation.TargetApi; import android.app.Activity; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; -import android.preference.CheckBoxPreference; -import android.preference.Preference; -import android.preference.Preference.OnPreferenceChangeListener; -import android.preference.PreferenceFragment; -import android.preference.PreferenceManager; import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.preference.CheckBoxPreference; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceFragmentCompat; import android.widget.Toast; import org.gnucash.android.R; -import org.gnucash.android.app.GnuCashApplication; import org.gnucash.android.ui.common.UxArgument; import org.gnucash.android.ui.passcode.PasscodeLockScreenActivity; import org.gnucash.android.ui.passcode.PasscodePreferenceActivity; @@ -39,8 +36,8 @@ * Fragment for general preferences. Currently caters to the passcode and reporting preferences * @author Oleksandr Tyshkovets */ -@TargetApi(11) -public class GeneralPreferenceFragment extends PreferenceFragment implements OnPreferenceChangeListener{ +public class GeneralPreferenceFragment extends PreferenceFragmentCompat implements + android.support.v7.preference.Preference.OnPreferenceChangeListener, Preference.OnPreferenceClickListener { /** * Request code for retrieving passcode to store @@ -58,12 +55,16 @@ public class GeneralPreferenceFragment extends PreferenceFragment implements OnP private SharedPreferences.Editor mEditor; private CheckBoxPreference mCheckBoxPreference; + @Override + public void onCreatePreferences(Bundle bundle, String s) { + addPreferencesFromResource(R.xml.fragment_general_preferences); + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - addPreferencesFromResource(R.xml.fragment_general_preferences); - ActionBar actionBar = ((AppCompatPreferenceActivity) getActivity()).getSupportActionBar(); + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); actionBar.setHomeButtonEnabled(true); actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setTitle(R.string.title_general_prefs); @@ -73,14 +74,13 @@ public void onCreate(Bundle savedInstanceState) { public void onResume() { super.onResume(); - mEditor = PreferenceManager.getDefaultSharedPreferences(getActivity().getApplicationContext()).edit(); final Intent intent = new Intent(getActivity(), PasscodePreferenceActivity.class); mCheckBoxPreference = (CheckBoxPreference) findPreference(getString(R.string.key_enable_passcode)); mCheckBoxPreference.setTitle(mCheckBoxPreference.isChecked() ? getString(R.string.title_passcode_enabled) : getString(R.string.title_passcode_disabled)); - mCheckBoxPreference.setOnPreferenceChangeListener(new OnPreferenceChangeListener() { + mCheckBoxPreference.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { @Override public boolean onPreferenceChange(Preference preference, Object newValue) { if ((Boolean) newValue) { @@ -93,20 +93,37 @@ public boolean onPreferenceChange(Preference preference, Object newValue) { return true; } }); - findPreference(getString(R.string.key_change_passcode)) - .setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { - @Override - public boolean onPreferenceClick(Preference preference) { - startActivityForResult(intent, REQUEST_CHANGE_PASSCODE); - return true; - } - }); + findPreference(getString(R.string.key_change_passcode)).setOnPreferenceClickListener(this); + } + + @Override + public boolean onPreferenceClick(Preference preference) { + String key = preference.getKey(); + if (key.equals(getString(R.string.key_change_passcode))) { + startActivityForResult( + new Intent(getActivity(), PasscodePreferenceActivity.class), + REQUEST_CHANGE_PASSCODE + ); + return true; + } + return false; } @Override public boolean onPreferenceChange(Preference preference, Object newValue) { + if (preference.getKey().equals(getString(R.string.key_enable_passcode))) { + if ((Boolean) newValue) { + startActivityForResult(new Intent(getActivity(), PasscodePreferenceActivity.class), + GeneralPreferenceFragment.PASSCODE_REQUEST_CODE); + } else { + Intent passIntent = new Intent(getActivity(), PasscodeLockScreenActivity.class); + passIntent.putExtra(UxArgument.DISABLE_PASSCODE, UxArgument.DISABLE_PASSCODE); + startActivityForResult(passIntent, GeneralPreferenceFragment.REQUEST_DISABLE_PASSCODE); + } + } + if (preference.getKey().equals(getString(R.string.key_use_account_color))) { - PreferenceManager.getDefaultSharedPreferences(getActivity()) + getPreferenceManager().getSharedPreferences() .edit() .putBoolean(getString(R.string.key_use_account_color), Boolean.valueOf(newValue.toString())) .commit(); @@ -119,7 +136,7 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (mEditor == null){ - mEditor = PreferenceManager.getDefaultSharedPreferences(GnuCashApplication.getAppContext()).edit(); + mEditor = getPreferenceManager().getSharedPreferences().edit(); } switch (requestCode) { diff --git a/app/src/main/java/org/gnucash/android/ui/settings/PreferenceActivity.java b/app/src/main/java/org/gnucash/android/ui/settings/PreferenceActivity.java new file mode 100644 index 000000000..f5c87a9c9 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/settings/PreferenceActivity.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2015 Oleksandr Tyshkovets + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.ui.settings; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; +import android.support.v4.widget.SlidingPaneLayout; +import android.support.v7.app.ActionBar; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceFragmentCompat; +import android.support.v7.preference.PreferenceManager; +import android.view.MenuItem; +import android.view.View; + +import org.gnucash.android.R; +import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.model.Book; +import org.gnucash.android.ui.passcode.PasscodeLockActivity; + +import butterknife.Bind; +import butterknife.ButterKnife; + +/** + * Activity for unified preferences + */ +public class PreferenceActivity extends PasscodeLockActivity implements + PreferenceFragmentCompat.OnPreferenceStartFragmentCallback{ + + public static final String ACTION_MANAGE_BOOKS = "org.gnucash.android.intent.action.MANAGE_BOOKS"; + + @Bind(R.id.slidingpane_layout) SlidingPaneLayout mSlidingPaneLayout; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_settings); + + ButterKnife.bind(this); + + mSlidingPaneLayout.setPanelSlideListener(new SlidingPaneLayout.PanelSlideListener() { + @Override + public void onPanelSlide(View panel, float slideOffset) { + //nothing to see here, move along + } + + @Override + public void onPanelOpened(View panel) { + ActionBar actionBar = getSupportActionBar(); + assert actionBar != null; + actionBar.setTitle(R.string.title_settings); + } + + @Override + public void onPanelClosed(View panel) { + //nothing to see here, move along + } + }); + + String action = getIntent().getAction(); + if (action != null && action.equals(ACTION_MANAGE_BOOKS)){ + loadFragment(new BookManagerFragment()); + mSlidingPaneLayout.closePane(); + } else { + mSlidingPaneLayout.openPane(); + loadFragment(new GeneralPreferenceFragment()); + } + + ActionBar actionBar = getSupportActionBar(); + assert actionBar != null; + actionBar.setTitle(R.string.title_settings); + actionBar.setHomeButtonEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + } + + @TargetApi(Build.VERSION_CODES.KITKAT) //for one of the exceptions caught + @Override + public boolean onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref) { + String key = pref.getKey(); + Fragment fragment = null; + try { + Class clazz = Class.forName(pref.getFragment()); + fragment = (Fragment) clazz.newInstance(); + } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) { + e.printStackTrace(); + //if we do not have a matching class, do nothing + return false; + } + loadFragment(fragment); + mSlidingPaneLayout.closePane(); + return false; + } + + /** + * Load the provided fragment into the right pane, replacing the previous one + * @param fragment BaseReportFragment instance + */ + private void loadFragment(Fragment fragment) { + FragmentManager fragmentManager = getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager + .beginTransaction(); + + fragmentTransaction.replace(R.id.fragment_container, fragment); + fragmentTransaction.commit(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + android.app.FragmentManager fm = getFragmentManager(); + if (fm.getBackStackEntryCount() > 0) { + fm.popBackStack(); + } else { + finish(); + } + } else { + finish(); + } + return true; + + default: + return false; + } + } + + /** + * Returns the shared preferences file for the currently active book. + * Should be used instead of {@link PreferenceManager#getDefaultSharedPreferences(Context)} + * @return Shared preferences file + */ + public static SharedPreferences getActiveBookSharedPreferences(){ + return getBookSharedPreferences(BooksDbAdapter.getInstance().getActiveBookUID()); + } + + /** + * Return the {@link SharedPreferences} for a specific book + * @param bookUID GUID of the book + * @return Shared preferences + */ + public static SharedPreferences getBookSharedPreferences(String bookUID){ + Context context = GnuCashApplication.getAppContext(); + return context.getSharedPreferences(bookUID, Context.MODE_PRIVATE); + } + + @Override + public void onBackPressed() { + if (mSlidingPaneLayout.isOpen()) + super.onBackPressed(); + else + mSlidingPaneLayout.openPane(); + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/settings/PreferenceHeadersFragment.java b/app/src/main/java/org/gnucash/android/ui/settings/PreferenceHeadersFragment.java new file mode 100644 index 000000000..c55ef98fb --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/settings/PreferenceHeadersFragment.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.ui.settings; + +import android.os.Bundle; +import android.support.v7.preference.PreferenceFragmentCompat; + +import org.gnucash.android.R; + +/** + * Fragment for displaying preference headers + * @author Ngewi Fet + */ +public class PreferenceHeadersFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle bundle, String s) { + addPreferencesFromResource(R.xml.preference_fragment_headers); + } + +} diff --git a/app/src/main/java/org/gnucash/android/ui/settings/SettingsActivity.java b/app/src/main/java/org/gnucash/android/ui/settings/SettingsActivity.java deleted file mode 100644 index 6cd389c43..000000000 --- a/app/src/main/java/org/gnucash/android/ui/settings/SettingsActivity.java +++ /dev/null @@ -1,649 +0,0 @@ -/* - * Copyright (c) 2012 - 2015 Ngewi Fet - * Copyright (c) 2014 - 2015 Oleksandr Tyshkovets - * Copyright (c) 2014 Yongxin Wang - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.gnucash.android.ui.settings; - -import android.annotation.TargetApi; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.IntentSender; -import android.content.SharedPreferences; -import android.database.Cursor; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.preference.CheckBoxPreference; -import android.preference.ListPreference; -import android.preference.Preference; -import android.preference.Preference.OnPreferenceChangeListener; -import android.preference.PreferenceManager; -import android.support.v7.app.ActionBar; -import android.util.Log; -import android.view.MenuItem; -import android.widget.ArrayAdapter; -import android.widget.Toast; - -import com.crashlytics.android.Crashlytics; -import com.dropbox.sync.android.DbxAccountManager; -import com.google.android.gms.common.ConnectionResult; -import com.google.android.gms.common.GooglePlayServicesUtil; -import com.google.android.gms.common.api.GoogleApiClient; -import com.google.android.gms.common.api.ResultCallback; -import com.google.android.gms.drive.Drive; -import com.google.android.gms.drive.DriveFolder; -import com.google.android.gms.drive.MetadataChangeSet; - -import org.gnucash.android.R; -import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.CommoditiesDbAdapter; -import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.db.TransactionsDbAdapter; -import org.gnucash.android.export.Exporter; -import org.gnucash.android.export.xml.GncXmlExporter; -import org.gnucash.android.importer.ImportAsyncTask; -import org.gnucash.android.model.Transaction; -import org.gnucash.android.ui.account.AccountsActivity; -import org.gnucash.android.ui.common.UxArgument; -import org.gnucash.android.ui.passcode.PasscodeLockScreenActivity; -import org.gnucash.android.ui.passcode.PasscodePreferenceActivity; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Currency; -import java.util.Date; -import java.util.List; -import java.util.Timer; -import java.util.TimerTask; - -/** - * Activity for displaying settings and information about the application - * @author Ngewi Fet - * @author Oleksandr Tyshkovets - * @author Yongxin Wang - */ -public class SettingsActivity extends AppCompatPreferenceActivity - implements OnPreferenceChangeListener, Preference.OnPreferenceClickListener{ - - public static final String LOG_TAG = "SettingsActivity"; - - /** - * Allowed delay between two consecutive taps of a setting for it to be considered a double tap - * Used on Android v2.3.3 or lower devices where dialogs cannot be instantiated easily in settings - */ - public static final int DOUBLE_TAP_DELAY = 2000; - - /** - * Testing app key for DropBox API - */ - final static public String DROPBOX_APP_KEY = "dhjh8ke9wf05948"; - - /** - * Testing app secret for DropBox API - */ - final static public String DROPBOX_APP_SECRET = "h2t9fphj3nr4wkw"; - - /** - * Collects references to the UI elements and binds click listeners - */ - public static final int REQUEST_LINK_TO_DBX = 0x11; - public static final int REQUEST_RESOLVE_CONNECTION = 0x12; - - /** - * Counts the number of times the preference for deleting all accounts has been clicked. - * It is reset every time the SettingsActivity is resumed. - * Only useful on devices with API level < 11 - */ - private int mDeleteAccountsClickCount; - - /** - * Counts the number of times the preference for deleting all transactions has been clicked. - * It is reset every time the SettingsActivity is resumed. - * Only useful on devices with API level < 11 - */ - private int mDeleteTransactionsClickCount; - private DbxAccountManager mDbxAccountManager; - /** - * Client for Google Drive Sync - */ - public static GoogleApiClient mGoogleApiClient; - - - /** - * Constructs the headers to display in the header list when the Settings activity is first opened - * Only available on Honeycomb and above - */ - @TargetApi(11) - @Override - public void onBuildHeaders(List
target) { - loadHeadersFromResource(R.xml.preference_headers, target); - } - - @SuppressWarnings("deprecation") - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - String dropboxAppKey = getString(R.string.dropbox_app_key, DROPBOX_APP_KEY); - String dropboxAppSecret = getString(R.string.dropbox_app_secret, DROPBOX_APP_SECRET); - mDbxAccountManager = DbxAccountManager.getInstance(getApplicationContext(), - dropboxAppKey, dropboxAppSecret); - - mGoogleApiClient = getGoogleApiClient(this); - - //retrieve version from Manifest and set it - ActionBar actionBar = getSupportActionBar(); - actionBar.setTitle(R.string.title_settings); - actionBar.setHomeButtonEnabled(true); - actionBar.setDisplayHomeAsUpEnabled(true); - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB){ - addPreferencesFromResource(R.xml.fragment_general_preferences); - addPreferencesFromResource(R.xml.fragment_account_preferences); - addPreferencesFromResource(R.xml.fragment_transaction_preferences); - addPreferencesFromResource(R.xml.fragment_backup_preferences); - addPreferencesFromResource(R.xml.fragment_about_preferences); - setDefaultCurrencyListener(); - - Preference pref = findPreference(getString(R.string.key_import_accounts)); - pref.setOnPreferenceClickListener(this); - - pref = findPreference(getString(R.string.key_restore_backup)); - pref.setOnPreferenceClickListener(this); - - pref = findPreference(getString(R.string.key_use_double_entry)); - pref.setOnPreferenceChangeListener(this); - - pref = findPreference(getString(R.string.key_delete_all_transactions)); - pref.setOnPreferenceClickListener(this); - - pref = findPreference(getString(R.string.key_delete_all_accounts)); - pref.setOnPreferenceClickListener(this); - - pref = findPreference(getString(R.string.key_about_gnucash)); - pref.setOnPreferenceClickListener(this); - - pref = findPreference(getString(R.string.key_change_passcode)); - pref.setOnPreferenceClickListener(this); - - pref = findPreference(getString(R.string.key_dropbox_sync)); - toggleDropboxPreference(pref); - pref.setOnPreferenceClickListener(this); - - pref = findPreference(getString(R.string.key_google_drive_sync)); - pref.setOnPreferenceClickListener(this); - toggleGoogleDrivePreference(pref); - - pref = findPreference(getString(R.string.key_create_backup)); - pref.setOnPreferenceClickListener(this); - - pref = findPreference(getString(R.string.key_enable_passcode)); - pref.setOnPreferenceChangeListener(this); - pref.setTitle(((CheckBoxPreference) pref).isChecked() - ? getString(R.string.title_passcode_enabled) - : getString(R.string.title_passcode_disabled) - ); - - pref = findPreference(getString(R.string.key_change_passcode)); - pref.setOnPreferenceClickListener(this); - } - } - - @Override - protected void onResume() { - super.onResume(); - mDeleteAccountsClickCount = 0; - mDeleteTransactionsClickCount = 0; - } - - @Override - protected void onPause() { - super.onPause(); - GnuCashApplication.PASSCODE_SESSION_INIT_TIME = System.currentTimeMillis(); - } - - @TargetApi(11) - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { - android.app.FragmentManager fm = getFragmentManager(); - if (fm.getBackStackEntryCount() > 0) { - fm.popBackStack(); - } else { - finish(); - } - } else { - finish(); - } - return true; - - default: - return false; - } - } - - @Override - public boolean onPreferenceChange(Preference preference, Object newValue) { - if (preference.getKey().equals(getString(R.string.key_default_currency))){ - GnuCashApplication.setDefaultCurrencyCode(newValue.toString()); - String fullname = CommoditiesDbAdapter.getInstance().getCommodity(newValue.toString()).getFullname(); - preference.setSummary(fullname); - } else if (preference.getKey().equals(getString(R.string.key_enable_passcode))) { - if ((Boolean) newValue) { - startActivityForResult(new Intent(this, PasscodePreferenceActivity.class), - GeneralPreferenceFragment.PASSCODE_REQUEST_CODE); - } else { - Intent passIntent = new Intent(this, PasscodeLockScreenActivity.class); - passIntent.putExtra(UxArgument.DISABLE_PASSCODE, UxArgument.DISABLE_PASSCODE); - startActivityForResult(passIntent, GeneralPreferenceFragment.REQUEST_DISABLE_PASSCODE); - } - } else if (preference.getKey().equals(getString(R.string.key_use_double_entry))){ - setImbalanceAccountsHidden((Boolean) newValue); - } - - return true; - } - - @Override - protected boolean isValidFragment(String fragmentName) { - return BackupPreferenceFragment.class.getName().equals(fragmentName) - || AccountPreferencesFragment.class.getName().equals(fragmentName) - || GeneralPreferenceFragment.class.getName().equals(fragmentName) - || TransactionsPreferenceFragment.class.getName().equals(fragmentName) - || AboutPreferenceFragment.class.getName().equals(fragmentName); - } - - /** - * Hide all imbalance accounts when double-entry mode is disabled - * @param useDoubleEntry flag if double entry is enabled or not - */ - public void setImbalanceAccountsHidden(boolean useDoubleEntry) { - String isHidden = useDoubleEntry ? "0" : "1"; - AccountsDbAdapter accountsDbAdapter = AccountsDbAdapter.getInstance(); - List currencies = accountsDbAdapter.getCurrenciesInUse(); - for (Currency currency : currencies) { - String uid = accountsDbAdapter.getImbalanceAccountUID(currency); - if (uid != null){ - accountsDbAdapter.updateRecord(uid, DatabaseSchema.AccountEntry.COLUMN_HIDDEN, isHidden); - } - } - } - - /** - * Load the commodities from the database and set the options on the list preference - * Also sets this activity as a listener for preference changes - */ - private void setDefaultCurrencyListener() { - CommoditiesDbAdapter commoditiesDbAdapter = CommoditiesDbAdapter.getInstance(); - List currencyEntries = new ArrayList<>((int)commoditiesDbAdapter.getRecordsCount()); - List currencyEntryValues = new ArrayList<>((int)commoditiesDbAdapter.getRecordsCount()); - Cursor cursor = commoditiesDbAdapter.fetchAllRecords(DatabaseSchema.CommodityEntry.COLUMN_MNEMONIC + " ASC"); - while(cursor.moveToNext()){ - String code = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.CommodityEntry.COLUMN_MNEMONIC)); - String name = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.CommodityEntry.COLUMN_FULLNAME)); - - currencyEntries.add(code + " - " + name); - currencyEntryValues.add(code); - } - cursor.close(); - - CharSequence[] entries = new CharSequence[currencyEntries.size()]; - CharSequence[] entryValues = new CharSequence[currencyEntryValues.size()]; - - @SuppressWarnings("deprecation") - Preference pref = findPreference(getString(R.string.key_default_currency)); - pref.setSummary(GnuCashApplication.getDefaultCurrencyCode()); - pref.setOnPreferenceChangeListener(this); - - ((ListPreference) pref).setEntries(currencyEntries.toArray(entries)); - ((ListPreference) pref).setEntryValues(currencyEntryValues.toArray(entryValues)); - } - - @Override - public boolean onPreferenceClick(Preference preference) { - String key = preference.getKey(); - - if (key.equals(getString(R.string.key_import_accounts))){ - AccountsActivity.startXmlFileChooser(this); - return true; - } - - if (key.equals(getString(R.string.key_restore_backup))){ - restoreBackup(); - } - - if (key.equals(getString(R.string.key_about_gnucash))){ - AccountsActivity.showWhatsNewDialog(this); - return true; - } - - if (key.equals(getString(R.string.key_dropbox_sync))){ - toggleDropboxSync(); - toggleDropboxPreference(preference); - } - - if (key.equals(getString(R.string.key_google_drive_sync))){ - toggleGoogleDriveSync(); - toggleGoogleDrivePreference(preference); - } - - if (key.equals(getString(R.string.key_create_backup))){ - boolean result = GncXmlExporter.createBackup(); - int msg = result ? R.string.toast_backup_successful : R.string.toast_backup_failed; - Toast.makeText(this, msg, Toast.LENGTH_SHORT).show(); - } - - //since we cannot get a support FragmentManager in the SettingsActivity pre H0NEYCOMB, - //we will just use 2 taps within 2 seconds as confirmation - if (key.equals(getString(R.string.key_delete_all_accounts))){ - mDeleteAccountsClickCount++; - if (mDeleteAccountsClickCount < 2){ - Toast.makeText(this, R.string.toast_tap_again_to_confirm_delete, Toast.LENGTH_SHORT).show(); - } else { - GncXmlExporter.createBackup(); //create backup before deleting everything - AccountsDbAdapter accountsDbAdapter = AccountsDbAdapter.getInstance(); - accountsDbAdapter.deleteAllRecords(); - Toast.makeText(this, R.string.toast_all_accounts_deleted, Toast.LENGTH_LONG).show(); - } - Timer timer = new Timer(); - timer.schedule(new ResetCounter(), DOUBLE_TAP_DELAY); - return true; - } - - if (key.equals(getString(R.string.key_delete_all_transactions))){ - mDeleteTransactionsClickCount++; - if (mDeleteTransactionsClickCount < 2){ - Toast.makeText(this, R.string.toast_tap_again_to_confirm_delete, Toast.LENGTH_SHORT).show(); - } else { - GncXmlExporter.createBackup(); //create backup before deleting everything - List openingBalances = new ArrayList(); - boolean preserveOpeningBalances = GnuCashApplication.shouldSaveOpeningBalances(false); - if (preserveOpeningBalances) { - AccountsDbAdapter accountsDbAdapter = AccountsDbAdapter.getInstance(); - openingBalances = accountsDbAdapter.getAllOpeningBalanceTransactions(); - } - TransactionsDbAdapter transactionsDbAdapter = TransactionsDbAdapter.getInstance(); - transactionsDbAdapter.deleteAllRecords(); - - if (preserveOpeningBalances) { - transactionsDbAdapter.bulkAddRecords(openingBalances); - } - Toast.makeText(this, R.string.toast_all_transactions_deleted, Toast.LENGTH_LONG).show(); - } - Timer timer = new Timer(); - timer.schedule(new ResetCounter(), DOUBLE_TAP_DELAY); - return true; - } - - if (key.equals(getString(R.string.key_change_passcode))){ - startActivityForResult(new Intent(this, PasscodePreferenceActivity.class), - GeneralPreferenceFragment.REQUEST_CHANGE_PASSCODE); - return true; - } - - return false; - } - - /** - * Toggles the authorization state of a DropBox account. - * If a link exists, it is removed else DropBox authorization is started - */ - private void toggleDropboxSync() { - if (mDbxAccountManager.hasLinkedAccount()){ - mDbxAccountManager.unlink(); - } else { - mDbxAccountManager.startLink(this, REQUEST_LINK_TO_DBX); - } - } - - /** - * Toggles synchronization with Google Drive on or off - */ - private void toggleGoogleDriveSync(){ - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); - final String appFolderId = sharedPreferences.getString(getString(R.string.key_google_drive_app_folder_id), null); - if (appFolderId != null){ - sharedPreferences.edit().remove(getString(R.string.key_google_drive_app_folder_id)).commit(); //commit (not apply) because we need it to be saved *now* - mGoogleApiClient.disconnect(); - } else { - mGoogleApiClient.connect(); - } - } - - /** - * Toggles the checkbox of the DropBox Sync preference if a DropBox account is linked - * @param pref DropBox Sync preference - */ - public void toggleDropboxPreference(Preference pref) { - ((CheckBoxPreference)pref).setChecked(mDbxAccountManager.hasLinkedAccount()); - } - - /** - * Toggles the checkbox of the GoogleDrive Sync preference if a Google Drive account is linked - * @param pref Google Drive Sync preference - */ - public void toggleGoogleDrivePreference(Preference pref){ - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); - String appFolderId = sharedPreferences.getString(getString(R.string.key_google_drive_app_folder_id),null); - ((CheckBoxPreference)pref).setChecked(appFolderId != null); - } - - - public static GoogleApiClient getGoogleApiClient(final Context context) { - return new GoogleApiClient.Builder(context) - .addApi(Drive.API) - .addScope(Drive.SCOPE_APPFOLDER) - .addScope(Drive.SCOPE_FILE) - .addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() { - @Override - public void onConnected(Bundle bundle) { - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - String appFolderId = sharedPreferences.getString(context.getString(R.string.key_google_drive_app_folder_id), null); - if (appFolderId == null) { - MetadataChangeSet changeSet = new MetadataChangeSet.Builder() - .setTitle(context.getString(R.string.app_name)).build(); - Drive.DriveApi.getRootFolder(mGoogleApiClient).createFolder( - mGoogleApiClient, changeSet).setResultCallback(new ResultCallback() { - @Override - public void onResult(DriveFolder.DriveFolderResult result) { - if (!result.getStatus().isSuccess()) { - Log.e(LOG_TAG, "Error creating the application folder"); - return; - } - - String folderId = result.getDriveFolder().getDriveId().toString(); - PreferenceManager.getDefaultSharedPreferences(context) - .edit().putString(context.getString(R.string.key_google_drive_app_folder_id), - folderId).commit(); //commit because we need it to be saved *now* - } - }); - - } - Toast.makeText(context, "Connected to Google Drive", Toast.LENGTH_SHORT).show(); - } - - @Override - public void onConnectionSuspended(int i) { - Toast.makeText(context, "Connection to Google Drive suspended!", Toast.LENGTH_LONG).show(); - } - }) - .addOnConnectionFailedListener(new GoogleApiClient.OnConnectionFailedListener() { - @Override - public void onConnectionFailed(ConnectionResult connectionResult) { - Log.e(SettingsActivity.class.getName(), "Connection to Google Drive failed"); - if (connectionResult.hasResolution() && context instanceof Activity) { - try { - Log.e(SettingsActivity.class.getName(), "Trying resolution of Google API connection failure"); - connectionResult.startResolutionForResult((Activity) context, REQUEST_RESOLVE_CONNECTION); - } catch (IntentSender.SendIntentException e) { - Log.e(SettingsActivity.class.getName(), e.getMessage()); - Toast.makeText(context, "Unable to link to Google Drive", Toast.LENGTH_LONG).show(); - } - } else { - if (context instanceof Activity) - GooglePlayServicesUtil.getErrorDialog(connectionResult.getErrorCode(), (Activity) context, 0).show(); - } - } - }) - .build(); - } - - /** - * Resets the tap counter for preferences which need to be double-tapped - */ - private class ResetCounter extends TimerTask { - - @Override - public void run() { - mDeleteAccountsClickCount = 0; - mDeleteTransactionsClickCount = 0; - } - } - - /** - * Opens a dialog for a user to select a backup to restore and then restores the backup - */ - public void restoreBackup() { - Log.i("Settings", "Opening GnuCash XML backups for restore"); - File[] backupFiles = new File(Exporter.BACKUP_FOLDER_PATH).listFiles(); - if (backupFiles == null){ - Toast.makeText(this, R.string.toast_backup_folder_not_found, Toast.LENGTH_LONG).show(); - new File(Exporter.BACKUP_FOLDER_PATH).mkdirs(); - return; - } - - Arrays.sort(backupFiles); - List backupFilesList = Arrays.asList(backupFiles); - Collections.reverse(backupFilesList); - final File[] sortedBackupFiles = (File[]) backupFilesList.toArray(); - - final ArrayAdapter arrayAdapter = new ArrayAdapter<>(this, android.R.layout.select_dialog_singlechoice); - final DateFormat dateFormatter = SimpleDateFormat.getDateTimeInstance(); - for (File backupFile : sortedBackupFiles) { - long time = Exporter.getExportTime(backupFile.getName()); - if (time > 0) - arrayAdapter.add(dateFormatter.format(new Date(time))); - else //if no timestamp was found in the filename, just use the name - arrayAdapter.add(backupFile.getName()); - } - - AlertDialog.Builder restoreDialogBuilder = new AlertDialog.Builder(this); - restoreDialogBuilder.setTitle(R.string.title_select_backup_to_restore); - restoreDialogBuilder.setNegativeButton(R.string.alert_dialog_cancel, - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - dialog.dismiss(); - } - }); - restoreDialogBuilder.setAdapter(arrayAdapter, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - File backupFile = sortedBackupFiles[which]; - new ImportAsyncTask(SettingsActivity.this).execute(Uri.fromFile(backupFile)); - } - }); - - restoreDialogBuilder.create().show(); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - switch (requestCode) { - case AccountsActivity.REQUEST_PICK_ACCOUNTS_FILE: - if (resultCode == Activity.RESULT_OK && data != null) { - AccountsActivity.importXmlFileFromIntent(this, data, null); - } - break; - case GeneralPreferenceFragment.PASSCODE_REQUEST_CODE: - if (resultCode == Activity.RESULT_OK && data != null) { - PreferenceManager.getDefaultSharedPreferences(getApplicationContext()) - .edit() - .putString(UxArgument.PASSCODE, data.getStringExtra(UxArgument.PASSCODE)) - .commit(); - PreferenceManager.getDefaultSharedPreferences(getApplicationContext()) - .edit() - .putBoolean(UxArgument.ENABLED_PASSCODE, true) - .commit(); - Toast.makeText(this, R.string.toast_passcode_set, Toast.LENGTH_SHORT).show(); - findPreference(getString(R.string.key_enable_passcode)).setTitle(getString(R.string.title_passcode_enabled)); - } - if (resultCode == Activity.RESULT_CANCELED) { - PreferenceManager.getDefaultSharedPreferences(getApplicationContext()) - .edit() - .putBoolean(UxArgument.ENABLED_PASSCODE, false) - .commit(); - ((CheckBoxPreference) findPreference(getString(R.string.key_enable_passcode))).setChecked(false); - findPreference(getString(R.string.key_enable_passcode)).setTitle(getString(R.string.title_passcode_disabled)); - } - break; - - case GeneralPreferenceFragment.REQUEST_DISABLE_PASSCODE: - boolean flag = resultCode != Activity.RESULT_OK; - PreferenceManager.getDefaultSharedPreferences(getApplicationContext()) - .edit() - .putBoolean(UxArgument.ENABLED_PASSCODE, flag) - .commit(); - ((CheckBoxPreference) findPreference(getString(R.string.key_enable_passcode))).setChecked(flag); - break; - - case GeneralPreferenceFragment.REQUEST_CHANGE_PASSCODE: - if (resultCode == Activity.RESULT_OK && data != null) { - PreferenceManager.getDefaultSharedPreferences(getApplicationContext()) - .edit() - .putString(UxArgument.PASSCODE, data.getStringExtra(UxArgument.PASSCODE)) - .commit(); - PreferenceManager.getDefaultSharedPreferences(getApplicationContext()) - .edit() - .putBoolean(UxArgument.ENABLED_PASSCODE, true) - .commit(); - Toast.makeText(this, R.string.toast_passcode_set, Toast.LENGTH_SHORT).show(); - findPreference(getString(R.string.key_enable_passcode)).setTitle(getString(R.string.title_passcode_enabled)); - } - break; - - case REQUEST_LINK_TO_DBX: - Preference preference = findPreference(getString(R.string.key_dropbox_sync)); - if (preference == null) //if we are in a preference header fragment, this may return null - break; - toggleDropboxPreference(preference); - break; - - case REQUEST_RESOLVE_CONNECTION: - if (resultCode == RESULT_OK) { - mGoogleApiClient.connect(); - Preference pref = findPreference(getString(R.string.key_dropbox_sync)); - if (pref == null) //if we are in a preference header fragment, this may return null - break; - toggleDropboxPreference(pref); - } - break; - } - } -} diff --git a/app/src/main/java/org/gnucash/android/ui/settings/TransactionsPreferenceFragment.java b/app/src/main/java/org/gnucash/android/ui/settings/TransactionsPreferenceFragment.java index b379eeacf..b047430cb 100644 --- a/app/src/main/java/org/gnucash/android/ui/settings/TransactionsPreferenceFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/settings/TransactionsPreferenceFragment.java @@ -16,44 +16,55 @@ package org.gnucash.android.ui.settings; -import android.annotation.TargetApi; import android.content.SharedPreferences; import android.os.Bundle; -import android.preference.Preference; -import android.preference.Preference.OnPreferenceChangeListener; -import android.preference.PreferenceFragment; -import android.preference.PreferenceManager; import android.support.v7.app.ActionBar; import android.support.v7.app.AppCompatActivity; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceFragmentCompat; +import android.support.v7.preference.SwitchPreferenceCompat; import org.gnucash.android.R; +import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.ui.settings.dialog.DeleteAllTransactionsConfirmationDialog; + +import java.util.Currency; +import java.util.List; /** * Fragment for displaying transaction preferences * @author Ngewi Fet * */ -@TargetApi(11) -public class TransactionsPreferenceFragment extends PreferenceFragment implements OnPreferenceChangeListener{ +public class TransactionsPreferenceFragment extends PreferenceFragmentCompat implements Preference.OnPreferenceChangeListener { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - - addPreferencesFromResource(R.xml.fragment_transaction_preferences); - ActionBar actionBar = ((AppCompatPreferenceActivity) getActivity()).getSupportActionBar(); + + getPreferenceManager().setSharedPreferencesName(BooksDbAdapter.getInstance().getActiveBookUID()); + + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); actionBar.setHomeButtonEnabled(true); actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setTitle(R.string.title_transaction_preferences); } - - + + @Override + public void onCreatePreferences(Bundle bundle, String s) { + addPreferencesFromResource(R.xml.fragment_transaction_preferences); + } + @Override public void onResume() { super.onResume(); - SharedPreferences manager = PreferenceManager.getDefaultSharedPreferences(getActivity()); - String defaultTransactionType = manager.getString(getString(R.string.key_default_transaction_type), "DEBIT"); + SharedPreferences sharedPreferences = getPreferenceManager().getSharedPreferences(); + String defaultTransactionType = sharedPreferences.getString( + getString(R.string.key_default_transaction_type), + getString(R.string.label_debit)); Preference pref = findPreference(getString(R.string.key_default_transaction_type)); setLocalizedSummary(pref, defaultTransactionType); pref.setOnPreferenceChangeListener(this); @@ -61,21 +72,33 @@ public void onResume() { pref = findPreference(getString(R.string.key_use_double_entry)); pref.setOnPreferenceChangeListener(this); - Preference preference = findPreference(getString(R.string.key_delete_all_transactions)); + String keyCompactView = getString(R.string.key_use_compact_list); + SwitchPreferenceCompat switchPref = (SwitchPreferenceCompat) findPreference(keyCompactView); + switchPref.setChecked(sharedPreferences.getBoolean(keyCompactView, false)); + + String keySaveBalance = getString(R.string.key_save_opening_balances); + switchPref = (SwitchPreferenceCompat) findPreference(keySaveBalance); + switchPref.setChecked(sharedPreferences.getBoolean(keySaveBalance, false)); + + String keyDoubleEntry = getString(R.string.key_use_double_entry); + switchPref = (SwitchPreferenceCompat) findPreference(keyDoubleEntry); + switchPref.setChecked(sharedPreferences.getBoolean(keyDoubleEntry, true)); + + Preference preference = findPreference(getString(R.string.key_delete_all_transactions)); preference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { @Override public boolean onPreferenceClick(Preference preference) { - deleteAllTransactions(); + showDeleteTransactionsDialog(); return true; } }); } - @Override public boolean onPreferenceChange(Preference preference, Object newValue) { if (preference.getKey().equals(getString(R.string.key_use_double_entry))){ - ((SettingsActivity)getActivity()).setImbalanceAccountsHidden((Boolean)newValue); + boolean useDoubleEntry = (Boolean) newValue; + setImbalanceAccountsHidden(useDoubleEntry); } else { setLocalizedSummary(preference, newValue.toString()); } @@ -85,12 +108,28 @@ public boolean onPreferenceChange(Preference preference, Object newValue) { /** * Deletes all transactions in the system */ - public void deleteAllTransactions(){ + public void showDeleteTransactionsDialog(){ DeleteAllTransactionsConfirmationDialog deleteTransactionsConfirmationDialog = DeleteAllTransactionsConfirmationDialog.newInstance(); - deleteTransactionsConfirmationDialog.show(getFragmentManager(), "transaction_settings"); + deleteTransactionsConfirmationDialog.show(getActivity().getSupportFragmentManager(), "transaction_settings"); } + + /** + * Hide all imbalance accounts when double-entry mode is disabled + * @param useDoubleEntry flag if double entry is enabled or not + */ + private void setImbalanceAccountsHidden(boolean useDoubleEntry) { + String isHidden = useDoubleEntry ? "0" : "1"; + AccountsDbAdapter accountsDbAdapter = AccountsDbAdapter.getInstance(); + List currencies = accountsDbAdapter.getCurrenciesInUse(); + for (Currency currency : currencies) { + String uid = accountsDbAdapter.getImbalanceAccountUID(currency); + if (uid != null){ + accountsDbAdapter.updateRecord(uid, DatabaseSchema.AccountEntry.COLUMN_HIDDEN, isHidden); + } + } + } /** * Localizes the label for DEBIT/CREDIT in the settings summary * @param preference Preference whose summary is to be localized @@ -98,8 +137,7 @@ public void deleteAllTransactions(){ */ private void setLocalizedSummary(Preference preference, String value){ String localizedLabel = value.equals("DEBIT") ? getString(R.string.label_debit) : getActivity().getString(R.string.label_credit); - Preference pref = findPreference(getString(R.string.key_default_transaction_type)); - pref.setSummary(localizedLabel); + preference.setSummary(localizedLabel); } } diff --git a/app/src/main/java/org/gnucash/android/ui/settings/DeleteAllAccountsConfirmationDialog.java b/app/src/main/java/org/gnucash/android/ui/settings/dialog/DeleteAllAccountsConfirmationDialog.java similarity index 94% rename from app/src/main/java/org/gnucash/android/ui/settings/DeleteAllAccountsConfirmationDialog.java rename to app/src/main/java/org/gnucash/android/ui/settings/dialog/DeleteAllAccountsConfirmationDialog.java index 11001b531..198b5b817 100644 --- a/app/src/main/java/org/gnucash/android/ui/settings/DeleteAllAccountsConfirmationDialog.java +++ b/app/src/main/java/org/gnucash/android/ui/settings/dialog/DeleteAllAccountsConfirmationDialog.java @@ -14,19 +14,19 @@ * limitations under the License. */ -package org.gnucash.android.ui.settings; +package org.gnucash.android.ui.settings.dialog; import android.annotation.TargetApi; import android.app.AlertDialog; import android.app.Dialog; -import android.app.DialogFragment; import android.content.Context; import android.content.DialogInterface; import android.os.Bundle; +import android.support.v4.app.DialogFragment; import android.widget.Toast; import org.gnucash.android.R; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.export.xml.GncXmlExporter; import org.gnucash.android.ui.homescreen.WidgetConfigurationActivity; diff --git a/app/src/main/java/org/gnucash/android/ui/settings/DeleteAllTransactionsConfirmationDialog.java b/app/src/main/java/org/gnucash/android/ui/settings/dialog/DeleteAllTransactionsConfirmationDialog.java similarity index 89% rename from app/src/main/java/org/gnucash/android/ui/settings/DeleteAllTransactionsConfirmationDialog.java rename to app/src/main/java/org/gnucash/android/ui/settings/dialog/DeleteAllTransactionsConfirmationDialog.java index 22ae73078..6b41f7a8b 100644 --- a/app/src/main/java/org/gnucash/android/ui/settings/DeleteAllTransactionsConfirmationDialog.java +++ b/app/src/main/java/org/gnucash/android/ui/settings/dialog/DeleteAllTransactionsConfirmationDialog.java @@ -14,22 +14,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.gnucash.android.ui.settings; +package org.gnucash.android.ui.settings.dialog; -import android.annotation.TargetApi; import android.app.AlertDialog; import android.app.Dialog; -import android.app.DialogFragment; import android.content.Context; import android.content.DialogInterface; import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.v4.app.DialogFragment; import android.util.Log; import android.widget.Toast; import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.export.xml.GncXmlExporter; import org.gnucash.android.model.Transaction; import org.gnucash.android.ui.homescreen.WidgetConfigurationActivity; @@ -43,7 +44,6 @@ * @author ngewif * @author Yongxin Wang */ -@TargetApi(11) public class DeleteAllTransactionsConfirmationDialog extends DialogFragment { public static DeleteAllTransactionsConfirmationDialog newInstance() { @@ -52,7 +52,7 @@ public static DeleteAllTransactionsConfirmationDialog newInstance() { } @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { + @NonNull public Dialog onCreateDialog(Bundle savedInstanceState) { return new AlertDialog.Builder(getActivity()) .setIcon(android.R.drawable.ic_delete) .setTitle(R.string.title_confirm_delete).setMessage(R.string.msg_delete_all_transactions_confirmation) @@ -73,7 +73,7 @@ public void onClick(DialogInterface dialog, int whichButton) { Log.i("DeleteDialog", String.format("Deleted %d transactions successfully", count)); if (preserveOpeningBalances) { - transactionsDbAdapter.bulkAddRecords(openingBalances); + transactionsDbAdapter.bulkAddRecords(openingBalances, DatabaseAdapter.UpdateMethod.insert); } Toast.makeText(context, R.string.toast_all_transactions_deleted, Toast.LENGTH_SHORT).show(); WidgetConfigurationActivity.updateAllWidgets(getActivity()); diff --git a/app/src/main/java/org/gnucash/android/ui/settings/dialog/OwnCloudDialogFragment.java b/app/src/main/java/org/gnucash/android/ui/settings/dialog/OwnCloudDialogFragment.java new file mode 100644 index 000000000..aa64c05c4 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/settings/dialog/OwnCloudDialogFragment.java @@ -0,0 +1,250 @@ +package org.gnucash.android.ui.settings.dialog; + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.app.DialogFragment; +import android.support.v7.preference.CheckBoxPreference; +import android.support.v7.preference.Preference; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import com.owncloud.android.lib.common.OwnCloudClient; +import com.owncloud.android.lib.common.OwnCloudClientFactory; +import com.owncloud.android.lib.common.OwnCloudCredentialsFactory; +import com.owncloud.android.lib.common.operations.OnRemoteOperationListener; +import com.owncloud.android.lib.common.operations.RemoteOperation; +import com.owncloud.android.lib.common.operations.RemoteOperationResult; +import com.owncloud.android.lib.resources.files.FileUtils; +import com.owncloud.android.lib.resources.status.GetRemoteStatusOperation; +import com.owncloud.android.lib.resources.users.GetRemoteUserNameOperation; + +import org.gnucash.android.R; + +/** + * A fragment for adding an ownCloud account. + */ +public class OwnCloudDialogFragment extends DialogFragment { + + /** + * Dialog positive button. Ok to save and validate the data + */ + private Button mOkButton; + + /** + * Cancel button + */ + private Button mCancelButton; + + /** + * ownCloud vars + */ + private String mOC_server; + private String mOC_username; + private String mOC_password; + private String mOC_dir; + + private EditText mServer; + private EditText mUsername; + private EditText mPassword; + private EditText mDir; + + private TextView mServerError; + private TextView mUsernameError; + private TextView mDirError; + + private SharedPreferences mPrefs; + private Context mContext; + + private static CheckBoxPreference ocCheckBox; + + /** + * Use this factory method to create a new instance of + * this fragment using the provided parameters. + * @return A new instance of fragment OwnCloudDialogFragment. + */ + public static OwnCloudDialogFragment newInstance(Preference pref) { + OwnCloudDialogFragment fragment = new OwnCloudDialogFragment(); + ocCheckBox = pref == null ? null : (CheckBoxPreference) pref; + return fragment; + } + + public OwnCloudDialogFragment() { + // Required empty public constructor + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(DialogFragment.STYLE_NO_TITLE, 0); + + mContext = getActivity(); + mPrefs = mContext.getSharedPreferences(getString(R.string.owncloud_pref), Context.MODE_PRIVATE); + + mOC_server = mPrefs.getString(getString(R.string.key_owncloud_server), getString(R.string.owncloud_server)); + mOC_username = mPrefs.getString(getString(R.string.key_owncloud_username), null); + mOC_password = mPrefs.getString(getString(R.string.key_owncloud_password), null); + mOC_dir = mPrefs.getString(getString(R.string.key_owncloud_dir), getString(R.string.app_name)); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + // Inflate the layout for this fragment + View view = inflater.inflate(R.layout.dialog_owncloud_account, container, false); + + mServer = (EditText) view.findViewById(R.id.owncloud_hostname); + mUsername = (EditText) view.findViewById(R.id.owncloud_username); + mPassword = (EditText) view.findViewById(R.id.owncloud_password); + mDir = (EditText) view.findViewById(R.id.owncloud_dir); + + mServer.setText(mOC_server); + mDir.setText(mOC_dir); + mPassword.setText(mOC_password); // TODO: Remove - debugging only + mUsername.setText(mOC_username); + + mServerError = (TextView) view.findViewById(R.id.owncloud_hostname_invalid); + mUsernameError = (TextView) view.findViewById(R.id.owncloud_username_invalid); + mDirError = (TextView) view.findViewById(R.id.owncloud_dir_invalid); + mServerError.setVisibility(View.GONE); + mUsernameError.setVisibility(View.GONE); + mDirError.setVisibility(View.GONE); + + mCancelButton = (Button) view.findViewById(R.id.btn_cancel); + mOkButton = (Button) view.findViewById(R.id.btn_save); + mOkButton.setText(R.string.btn_test); + + return view; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + setListeners(); + } + + private void saveButton() { + if (mDirError.getText().toString().equals(getString(R.string.owncloud_dir_ok)) && + mUsernameError.getText().toString().equals(getString(R.string.owncloud_user_ok)) && + mServerError.getText().toString().equals(getString(R.string.owncloud_server_ok))) + mOkButton.setText(R.string.btn_save); + else + mOkButton.setText(R.string.btn_test); + } + + private void save() { + SharedPreferences.Editor edit = mPrefs.edit(); + edit.clear(); + edit.putString(getString(R.string.key_owncloud_server), mOC_server); + edit.putString(getString(R.string.key_owncloud_username), mOC_username); + edit.putString(getString(R.string.key_owncloud_password), mOC_password); + edit.putString(getString(R.string.key_owncloud_dir), mOC_dir); + edit.putBoolean(getString(R.string.owncloud_sync), true); + edit.apply(); + + if (ocCheckBox != null) ocCheckBox.setChecked(true); + + dismiss(); + } + + private void checkData() { + mServerError.setVisibility(View.GONE); + mUsernameError.setVisibility(View.GONE); + mDirError.setVisibility(View.GONE); + + mOC_server = mServer.getText().toString().trim(); + mOC_username = mUsername.getText().toString().trim(); + mOC_password = mPassword.getText().toString().trim(); + mOC_dir = mDir.getText().toString().trim(); + + Uri serverUri = Uri.parse(mOC_server); + OwnCloudClient mClient = OwnCloudClientFactory.createOwnCloudClient(serverUri, mContext, true); + mClient.setCredentials( + OwnCloudCredentialsFactory.newBasicCredentials(mOC_username, mOC_password) + ); + + final Handler mHandler = new Handler(); + + OnRemoteOperationListener listener = new OnRemoteOperationListener() { + @Override + public void onRemoteOperationFinish(RemoteOperation caller, RemoteOperationResult result) { + if (!result.isSuccess()) { + Log.e("OC", result.getLogMessage(), result.getException()); + + if (caller instanceof GetRemoteStatusOperation) { + mServerError.setText(getString(R.string.owncloud_server_invalid)); + mServerError.setVisibility(View.VISIBLE); + + } else if (caller instanceof GetRemoteUserNameOperation && + mServerError.getText().toString().equals(getString(R.string.owncloud_server_ok))) { + mUsernameError.setText(getString(R.string.owncloud_user_invalid)); + mUsernameError.setVisibility(View.VISIBLE); + } + } else { + if (caller instanceof GetRemoteStatusOperation) { + mServerError.setText(getString(R.string.owncloud_server_ok)); + mServerError.setVisibility(View.VISIBLE); + } else if (caller instanceof GetRemoteUserNameOperation) { + mUsernameError.setText(getString(R.string.owncloud_user_ok)); + mUsernameError.setVisibility(View.VISIBLE); + } + } + saveButton(); + } + }; + + GetRemoteStatusOperation g = new GetRemoteStatusOperation(mContext); + g.execute(mClient, listener, mHandler); + + GetRemoteUserNameOperation gu = new GetRemoteUserNameOperation(); + gu.execute(mClient, listener, mHandler); + + if (FileUtils.isValidPath(mOC_dir, false)) { + mDirError.setText(getString(R.string.owncloud_dir_ok)); + mDirError.setVisibility(View.VISIBLE); + } else { + mDirError.setText(getString(R.string.owncloud_dir_invalid)); + mDirError.setVisibility(View.VISIBLE); + } + saveButton(); + } + + /** + * Binds click listeners for the dialog buttons + */ + private void setListeners(){ + + mCancelButton.setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View v) { + dismiss(); + } + }); + + mOkButton.setOnClickListener(new View.OnClickListener() { + + @Override + public void onClick(View v) { + // If data didn't change + if(mOkButton.getText().toString().equals(getString(R.string.btn_save)) && + mOC_server.equals(mServer.getText().toString().trim()) && + mOC_username.equals(mUsername.getText().toString().trim()) && + mOC_password.equals(mPassword.getText().toString().trim()) && + mOC_dir.equals(mDir.getText().toString().trim()) + ) + save(); + else + checkData(); + } + }); + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/util/OnTransactionClickedListener.java b/app/src/main/java/org/gnucash/android/ui/transaction/OnTransactionClickedListener.java similarity index 96% rename from app/src/main/java/org/gnucash/android/ui/util/OnTransactionClickedListener.java rename to app/src/main/java/org/gnucash/android/ui/transaction/OnTransactionClickedListener.java index 9be539055..ba1c02f27 100644 --- a/app/src/main/java/org/gnucash/android/ui/util/OnTransactionClickedListener.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/OnTransactionClickedListener.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.gnucash.android.ui.util; +package org.gnucash.android.ui.transaction; /** * Interface for implemented by activities which wish to be notified when diff --git a/app/src/main/java/org/gnucash/android/ui/util/OnTransferFundsListener.java b/app/src/main/java/org/gnucash/android/ui/transaction/OnTransferFundsListener.java similarity index 95% rename from app/src/main/java/org/gnucash/android/ui/util/OnTransferFundsListener.java rename to app/src/main/java/org/gnucash/android/ui/transaction/OnTransferFundsListener.java index fb1c93d85..069b79431 100644 --- a/app/src/main/java/org/gnucash/android/ui/util/OnTransferFundsListener.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/OnTransferFundsListener.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.gnucash.android.ui.util; +package org.gnucash.android.ui.transaction; import org.gnucash.android.model.Money; diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/ScheduledActionsActivity.java b/app/src/main/java/org/gnucash/android/ui/transaction/ScheduledActionsActivity.java index 594228dc2..3a14b6658 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/ScheduledActionsActivity.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/ScheduledActionsActivity.java @@ -22,7 +22,6 @@ import android.support.v4.app.FragmentStatePagerAdapter; import android.support.v4.view.PagerAdapter; import android.support.v4.view.ViewPager; -import android.support.v7.widget.Toolbar; import org.gnucash.android.R; import org.gnucash.android.model.ScheduledAction; @@ -39,15 +38,19 @@ public class ScheduledActionsActivity extends BaseDrawerActivity { ViewPager mViewPager; + @Override + public int getContentView() { + return R.layout.activity_scheduled_events; + } + + @Override + public int getTitleRes() { + return R.string.nav_menu_scheduled_actions; + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_scheduled_events); - setUpDrawer(); - - Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - getSupportActionBar().setTitle(R.string.nav_menu_scheduled_actions); TabLayout tabLayout = (TabLayout) findViewById(R.id.tab_layout); tabLayout.addTab(tabLayout.newTab().setText(R.string.title_scheduled_transactions)); diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/ScheduledActionsListFragment.java b/app/src/main/java/org/gnucash/android/ui/transaction/ScheduledActionsListFragment.java index 5d60d8b32..ccff3c1bc 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/ScheduledActionsListFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/ScheduledActionsListFragment.java @@ -51,8 +51,8 @@ import org.gnucash.android.app.GnuCashApplication; import org.gnucash.android.db.DatabaseCursorLoader; import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.db.ScheduledActionDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.ScheduledActionDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.export.ExportParams; import org.gnucash.android.model.ScheduledAction; import org.gnucash.android.model.Transaction; @@ -267,7 +267,7 @@ public void onListItemClick(ListView l, View v, int position, long id) { //this should actually never happen, but has happened once. So perform check for the future if (transaction.getSplits().size() == 0){ - Toast.makeText(getActivity(), "The selected transaction has no splits and cannot be opened", Toast.LENGTH_SHORT).show(); + Toast.makeText(getActivity(), R.string.toast_transaction_has_no_splits_and_cannot_open, Toast.LENGTH_SHORT).show(); return; } @@ -466,7 +466,7 @@ public void bindView(View view, Context context, Cursor cursor) { amountTextView.setText(transaction.getSplits().get(0).getValue().formattedString()); } } else { - amountTextView.setText(transaction.getSplits().size() + " splits"); + amountTextView.setText(getString(R.string.label_split_count, transaction.getSplits().size())); } TextView descriptionTextView = (TextView) view.findViewById(R.id.secondary_text); @@ -478,7 +478,7 @@ public void bindView(View view, Context context, Cursor cursor) { if (endTime > 0 && endTime < System.currentTimeMillis()){ ((TextView)view.findViewById(R.id.primary_text)).setTextColor(getResources().getColor(android.R.color.darker_gray)); descriptionTextView.setText(getString(R.string.label_scheduled_action_ended, - DateFormat.getInstance().format(new Date(scheduledAction.getLastRun())))); + DateFormat.getInstance().format(new Date(scheduledAction.getLastRunTime())))); } else { descriptionTextView.setText(scheduledAction.getRepeatString()); } @@ -573,7 +573,7 @@ public void bindView(View view, Context context, Cursor cursor) { if (endTime > 0 && endTime < System.currentTimeMillis()){ ((TextView)view.findViewById(R.id.primary_text)).setTextColor(getResources().getColor(android.R.color.darker_gray)); descriptionTextView.setText(getString(R.string.label_scheduled_action_ended, - DateFormat.getInstance().format(new Date(scheduledAction.getLastRun())))); + DateFormat.getInstance().format(new Date(scheduledAction.getLastRunTime())))); } else { descriptionTextView.setText(scheduledAction.getRepeatString()); } @@ -618,7 +618,7 @@ public Cursor loadInBackground() { Cursor c = mDatabaseAdapter.fetchAllRecords( DatabaseSchema.ScheduledActionEntry.COLUMN_TYPE + "=?", - new String[]{ScheduledAction.ActionType.BACKUP.name()}); + new String[]{ScheduledAction.ActionType.BACKUP.name()}, null); registerContentObserver(c); return c; diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/SplitEditorFragment.java b/app/src/main/java/org/gnucash/android/ui/transaction/SplitEditorFragment.java index 143bec10f..8339c6bfc 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/SplitEditorFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/SplitEditorFragment.java @@ -47,9 +47,9 @@ import net.objecthunter.exp4j.ExpressionBuilder; import org.gnucash.android.R; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.CommoditiesDbAdapter; import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.BaseModel; import org.gnucash.android.model.Commodity; @@ -60,7 +60,6 @@ import org.gnucash.android.ui.common.FormActivity; import org.gnucash.android.ui.common.UxArgument; import org.gnucash.android.ui.transaction.dialog.TransferFundsDialogFragment; -import org.gnucash.android.ui.util.OnTransferFundsListener; import org.gnucash.android.ui.util.widget.CalculatorEditText; import org.gnucash.android.ui.util.widget.CalculatorKeyboard; import org.gnucash.android.ui.util.widget.TransactionTypeSwitch; @@ -93,8 +92,6 @@ public class SplitEditorFragment extends Fragment { private BigDecimal mBaseAmount = BigDecimal.ZERO; - private ArrayList mRemovedSplitUIDs = new ArrayList<>(); - CalculatorKeyboard mCalculatorKeyboard; BalanceTextWatcher mImbalanceWatcher = new BalanceTextWatcher(); @@ -132,13 +129,9 @@ public void onActivityCreated(Bundle savedInstanceState) { //we are editing splits for a new transaction. // But the user may have already created some splits before. Let's check - List splitStrings = getArguments().getStringArrayList(UxArgument.SPLIT_LIST); - List splitList = new ArrayList<>(); - if (splitStrings != null) { - for (String splitString : splitStrings) { - splitList.add(Split.parseSplit(splitString)); - } - } + + List splitList = getArguments().getParcelableArrayList(UxArgument.SPLIT_LIST); + assert splitList != null; initArgs(); if (!splitList.isEmpty()) { @@ -265,7 +258,6 @@ private void setListeners(Split split){ removeSplitButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - mRemovedSplitUIDs.add(splitUidTextView.getText().toString()); mSplitsLinearLayout.removeView(splitView); mSplitItemViewList.remove(splitView); mImbalanceWatcher.afterTextChanged(null); @@ -378,14 +370,8 @@ private void saveSplits() { return; } - List splitList = extractSplitsFromView(); - ArrayList splitStrings = new ArrayList<>(); - for (Split split : splitList) { - splitStrings.add(split.toCsv()); - } Intent data = new Intent(); - data.putStringArrayListExtra(UxArgument.SPLIT_LIST, splitStrings); - data.putStringArrayListExtra(UxArgument.REMOVED_SPLITS, mRemovedSplitUIDs); + data.putParcelableArrayListExtra(UxArgument.SPLIT_LIST, extractSplitsFromView()); getActivity().setResult(Activity.RESULT_OK, data); getActivity().finish(); @@ -395,8 +381,8 @@ private void saveSplits() { * Extracts the input from the views and builds {@link org.gnucash.android.model.Split}s to correspond to the input. * @return List of {@link org.gnucash.android.model.Split}s represented in the view */ - private List extractSplitsFromView(){ - List splitList = new ArrayList<>(); + private ArrayList extractSplitsFromView(){ + ArrayList splitList = new ArrayList<>(); for (View splitView : mSplitItemViewList) { SplitViewHolder viewHolder = (SplitViewHolder) splitView.getTag(); if (viewHolder.splitAmountEditText.getValue() == null) @@ -413,7 +399,7 @@ private List extractSplitsFromView(){ split.setType(viewHolder.splitTypeSwitch.getTransactionType()); split.setUID(viewHolder.splitUidTextView.getText().toString().trim()); if (viewHolder.quantity != null) - split.setQuantity(viewHolder.quantity.absolute()); + split.setQuantity(viewHolder.quantity.abs()); splitList.add(split); } return splitList; diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/TransactionDetailActivity.java b/app/src/main/java/org/gnucash/android/ui/transaction/TransactionDetailActivity.java index 364966a04..2d90c3c44 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/TransactionDetailActivity.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/TransactionDetailActivity.java @@ -15,9 +15,9 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.ScheduledActionDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.ScheduledActionDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.Money; import org.gnucash.android.model.ScheduledAction; import org.gnucash.android.model.Split; @@ -118,7 +118,7 @@ private void bindViews(){ Transaction transaction = transactionsDbAdapter.getRecord(mTransactionUID); mTransactionDescription.setText(transaction.getDescription()); - mTransactionAccount.setText("in " + AccountsDbAdapter.getInstance().getAccountFullName(mAccountUID)); + mTransactionAccount.setText(getString(R.string.label_inside_account_with_name, AccountsDbAdapter.getInstance().getAccountFullName(mAccountUID))); AccountsDbAdapter accountsDbAdapter = AccountsDbAdapter.getInstance(); diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/TransactionFormFragment.java b/app/src/main/java/org/gnucash/android/ui/transaction/TransactionFormFragment.java index 2735a38d5..67e34d4bb 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/TransactionFormFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/TransactionFormFragment.java @@ -24,15 +24,14 @@ import android.database.Cursor; import android.inputmethodservice.KeyboardView; import android.os.Bundle; -import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.v4.app.Fragment; -import android.support.v4.app.FragmentManager; import android.support.v4.widget.SimpleCursorAdapter; import android.support.v7.app.ActionBar; import android.support.v7.app.AppCompatActivity; import android.text.format.DateUtils; -import android.text.format.Time; import android.util.Log; +import android.util.Pair; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -51,22 +50,25 @@ import android.widget.TextView; import android.widget.Toast; -import com.codetroopers.betterpickers.calendardatepicker.CalendarDatePickerDialog; -import com.codetroopers.betterpickers.radialtimepicker.RadialTimePickerDialog; +import com.codetroopers.betterpickers.calendardatepicker.CalendarDatePickerDialogFragment; +import com.codetroopers.betterpickers.radialtimepicker.RadialTimePickerDialogFragment; import com.codetroopers.betterpickers.recurrencepicker.EventRecurrence; import com.codetroopers.betterpickers.recurrencepicker.EventRecurrenceFormatter; -import com.codetroopers.betterpickers.recurrencepicker.RecurrencePickerDialog; +import com.codetroopers.betterpickers.recurrencepicker.RecurrencePickerDialogFragment; import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.CommoditiesDbAdapter; import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.db.ScheduledActionDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.PricesDbAdapter; +import org.gnucash.android.db.adapter.ScheduledActionDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.Commodity; import org.gnucash.android.model.Money; +import org.gnucash.android.model.Recurrence; import org.gnucash.android.model.ScheduledAction; import org.gnucash.android.model.Split; import org.gnucash.android.model.Transaction; @@ -74,15 +76,15 @@ import org.gnucash.android.ui.common.FormActivity; import org.gnucash.android.ui.common.UxArgument; import org.gnucash.android.ui.homescreen.WidgetConfigurationActivity; +import org.gnucash.android.ui.settings.PreferenceActivity; import org.gnucash.android.ui.transaction.dialog.TransferFundsDialogFragment; -import org.gnucash.android.ui.util.OnTransferFundsListener; import org.gnucash.android.ui.util.RecurrenceParser; +import org.gnucash.android.ui.util.RecurrenceViewClickListener; import org.gnucash.android.ui.util.widget.CalculatorEditText; import org.gnucash.android.ui.util.widget.TransactionTypeSwitch; import org.gnucash.android.util.QualifiedAccountNameCursorAdapter; import java.math.BigDecimal; -import java.math.RoundingMode; import java.text.DateFormat; import java.text.ParseException; import java.util.ArrayList; @@ -91,7 +93,6 @@ import java.util.Date; import java.util.GregorianCalendar; import java.util.List; -import java.util.Locale; import butterknife.Bind; import butterknife.ButterKnife; @@ -101,10 +102,9 @@ * @author Ngewi Fet */ public class TransactionFormFragment extends Fragment implements - CalendarDatePickerDialog.OnDateSetListener, RadialTimePickerDialog.OnTimeSetListener, - RecurrencePickerDialog.OnRecurrenceSetListener, OnTransferFundsListener { + CalendarDatePickerDialogFragment.OnDateSetListener, RadialTimePickerDialogFragment.OnTimeSetListener, + RecurrencePickerDialogFragment.OnRecurrenceSetListener, OnTransferFundsListener { - private static final String FRAGMENT_TAG_RECURRENCE_PICKER = "recurrence_picker"; private static final int REQUEST_SPLIT_EDITOR = 0x11; /** @@ -120,7 +120,7 @@ public class TransactionFormFragment extends Fragment implements /** * Adapter for transfer account spinner */ - private SimpleCursorAdapter mCursorAdapter; + private QualifiedAccountNameCursorAdapter mAccountCursorAdapter; /** * Cursor for transfer account spinner @@ -265,7 +265,7 @@ public void onClick(View v) { private void startTransferFunds() { Currency fromCurrency = Currency.getInstance(mTransactionsDbAdapter.getAccountCurrencyCode(mAccountUID)); long id = mTransferAccountSpinner.getSelectedItemId(); - String targetCurrency = mAccountsDbAdapter.getCurrencyCode((mAccountsDbAdapter.getUID(id))); + String targetCurrency = mAccountsDbAdapter.getCurrencyCode(mAccountsDbAdapter.getUID(id)); if (fromCurrency.equals(Currency.getInstance(targetCurrency)) || !mAmountEditText.isInputModified() @@ -275,7 +275,7 @@ private void startTransferFunds() { BigDecimal amountBigd = mAmountEditText.getValue(); if (amountBigd.equals(BigDecimal.ZERO)) return; - Money amount = new Money(amountBigd, Commodity.getInstance(fromCurrency.getCurrencyCode())).absolute(); + Money amount = new Money(amountBigd, Commodity.getInstance(fromCurrency.getCurrencyCode())).abs(); TransferFundsDialogFragment fragment = TransferFundsDialogFragment.getInstance(amount, targetCurrency, this); @@ -293,7 +293,7 @@ public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); setHasOptionsMenu(true); - SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + SharedPreferences sharedPrefs = PreferenceActivity.getActiveBookSharedPreferences(); mUseDoubleEntry = sharedPrefs.getBoolean(getString(R.string.key_use_double_entry), false); if (!mUseDoubleEntry){ mDoubleEntryLayout.setVisibility(View.GONE); @@ -538,7 +538,7 @@ private void initalizeViews() { mTime = mDate = Calendar.getInstance(); mTransactionTypeSwitch.setAccountType(mAccountType); - String typePref = PreferenceManager.getDefaultSharedPreferences(getActivity()).getString(getString(R.string.key_default_transaction_type), "DEBIT"); + String typePref = PreferenceActivity.getActiveBookSharedPreferences().getString(getString(R.string.key_default_transaction_type), "DEBIT"); mTransactionTypeSwitch.setChecked(TransactionType.valueOf(typePref)); String code = GnuCashApplication.getDefaultCurrencyCode(); @@ -581,8 +581,8 @@ private void updateTransferAccountsList(){ } mCursor = mAccountsDbAdapter.fetchAccountsOrderedByFullName(conditions, new String[]{mAccountUID, AccountType.ROOT.name()}); - mCursorAdapter = new QualifiedAccountNameCursorAdapter(getActivity(), mCursor); - mTransferAccountSpinner.setAdapter(mCursorAdapter); + mAccountCursorAdapter = new QualifiedAccountNameCursorAdapter(getActivity(), mCursor); + mTransferAccountSpinner.setAdapter(mAccountCursorAdapter); } /** @@ -590,7 +590,7 @@ private void updateTransferAccountsList(){ */ private void openSplitEditor(){ if (mAmountEditText.getValue() == null){ - Toast.makeText(getActivity(), "Please enter an amount to split", Toast.LENGTH_SHORT).show(); + Toast.makeText(getActivity(), R.string.toast_enter_amount_to_split, Toast.LENGTH_SHORT).show(); return; } @@ -612,13 +612,8 @@ private void openSplitEditor(){ intent.putExtra(UxArgument.FORM_TYPE, FormActivity.FormType.SPLIT_EDITOR.name()); intent.putExtra(UxArgument.SELECTED_ACCOUNT_UID, mAccountUID); intent.putExtra(UxArgument.AMOUNT_STRING, baseAmountString); - if (mSplitsList != null) { - ArrayList splitStrings = new ArrayList<>(); - for (Split split : mSplitsList) { - splitStrings.add(split.toCsv()); - } - intent.putStringArrayListExtra(UxArgument.SPLIT_LIST, splitStrings); - } + intent.putParcelableArrayListExtra(UxArgument.SPLIT_LIST, (ArrayList) extractSplitsFromView()); + startActivityForResult(intent, REQUEST_SPLIT_EDITOR); } @@ -645,7 +640,7 @@ public void onClick(View v) { int year = calendar.get(Calendar.YEAR); int monthOfYear = calendar.get(Calendar.MONTH); int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH); - CalendarDatePickerDialog datePickerDialog = CalendarDatePickerDialog.newInstance( + CalendarDatePickerDialogFragment datePickerDialog = CalendarDatePickerDialogFragment.newInstance( TransactionFormFragment.this, year, monthOfYear, dayOfMonth); datePickerDialog.show(getFragmentManager(), "date_picker_fragment"); @@ -667,37 +662,14 @@ public void onClick(View v) { Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(timeMillis); - RadialTimePickerDialog timePickerDialog = RadialTimePickerDialog.newInstance( + RadialTimePickerDialogFragment timePickerDialog = RadialTimePickerDialogFragment.newInstance( TransactionFormFragment.this, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true); timePickerDialog.show(getFragmentManager(), "time_picker_dialog_fragment"); } }); - mRecurrenceTextView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - FragmentManager fm = getActivity().getSupportFragmentManager(); - Bundle b = new Bundle(); - Time t = new Time(); - t.setToNow(); - b.putLong(RecurrencePickerDialog.BUNDLE_START_TIME_MILLIS, t.toMillis(false)); - b.putString(RecurrencePickerDialog.BUNDLE_TIME_ZONE, t.timezone); - - // may be more efficient to serialize and pass in EventRecurrence - b.putString(RecurrencePickerDialog.BUNDLE_RRULE, mRecurrenceRule); - - RecurrencePickerDialog rpd = (RecurrencePickerDialog) fm.findFragmentByTag( - FRAGMENT_TAG_RECURRENCE_PICKER); - if (rpd != null) { - rpd.dismiss(); - } - rpd = new RecurrencePickerDialog(); - rpd.setArguments(b); - rpd.setOnRecurrenceSetListener(TransactionFormFragment.this); - rpd.show(fm, FRAGMENT_TAG_RECURRENCE_PICKER); - } - }); + mRecurrenceTextView.setOnClickListener(new RecurrenceViewClickListener((AppCompatActivity) getActivity(), mRecurrenceRule, this)); } /** @@ -705,131 +677,163 @@ public void onClick(View view) { * @param accountId Database ID of the transfer account */ private void setSelectedTransferAccount(long accountId){ - for (int pos = 0; pos < mCursorAdapter.getCount(); pos++) { - if (mCursorAdapter.getItemId(pos) == accountId){ - final int position = pos; - mTransferAccountSpinner.postDelayed(new Runnable() { - @Override - public void run() { - mTransferAccountSpinner.setSelection(position); - } - }, 100); - break; - } - } + int position = mAccountCursorAdapter.getPosition(mAccountsDbAdapter.getUID(accountId)); + if (position >= 0) + mTransferAccountSpinner.setSelection(position); } + /** + * Returns a list of splits based on the input in the transaction form. + * This only gets the splits from the simple view, and not those from the Split Editor. + * If the Split Editor has been used and there is more than one split, then it returns {@link #mSplitsList} + * @return List of splits in the view or {@link #mSplitsList} is there are more than 2 splits in the transaction + */ + private List extractSplitsFromView(){ + if (mTransactionTypeSwitch.getVisibility() != View.VISIBLE){ + return mSplitsList; + } + + BigDecimal amountBigd = mAmountEditText.getValue(); + String baseCurrencyCode = mTransactionsDbAdapter.getAccountCurrencyCode(mAccountUID); + Money value = new Money(amountBigd, Commodity.getInstance(baseCurrencyCode)).abs(); + Money quantity = new Money(value); + + String transferAcctUID = getTransferAccountUID(); + CommoditiesDbAdapter cmdtyDbAdapter = CommoditiesDbAdapter.getInstance(); + + if (isMultiCurrencyTransaction()){ //if multi-currency transaction + String transferCurrencyCode = mAccountsDbAdapter.getCurrencyCode(transferAcctUID); + String commodityUID = cmdtyDbAdapter.getCommodityUID(baseCurrencyCode); + String targetCmdtyUID = cmdtyDbAdapter.getCommodityUID(transferCurrencyCode); + + Pair pricePair = PricesDbAdapter.getInstance() + .getPrice(commodityUID, targetCmdtyUID); + + if (pricePair.first > 0 && pricePair.second > 0) { + quantity = quantity.multiply(pricePair.first.intValue()) + .divide(pricePair.second.intValue()) + .withCurrency(cmdtyDbAdapter.getRecord(targetCmdtyUID)); + } + } + + Split split1 = new Split(value, mAccountUID); + split1.setType(mTransactionTypeSwitch.getTransactionType()); + Split split2 = new Split(value, quantity, transferAcctUID); + split2.setType(mTransactionTypeSwitch.getTransactionType().invert()); + + List splitList = new ArrayList<>(); + splitList.add(split1); + splitList.add(split2); + + return splitList; + } + + /** + * Returns the GUID of the currently selected transfer account. + * If double-entry is disabled, this method returns the GUID of the imbalance account for the currently active account + * @return GUID of transfer account + */ + private @NonNull String getTransferAccountUID() { + String transferAcctUID; + if (mUseDoubleEntry) { + long transferAcctId = mTransferAccountSpinner.getSelectedItemId(); + transferAcctUID = mAccountsDbAdapter.getUID(transferAcctId); + } else { + String baseCurrencyCode = mTransactionsDbAdapter.getAccountCurrencyCode(mAccountUID); + transferAcctUID = mAccountsDbAdapter.getOrCreateImbalanceAccountUID(Currency.getInstance(baseCurrencyCode)); + } + return transferAcctUID; + } + + /** + * Extracts a transaction from the input in the form fragment + * @return New transaction object containing all info in the form + */ + private @NonNull Transaction extractTransactionFromView(){ + Calendar cal = new GregorianCalendar( + mDate.get(Calendar.YEAR), + mDate.get(Calendar.MONTH), + mDate.get(Calendar.DAY_OF_MONTH), + mTime.get(Calendar.HOUR_OF_DAY), + mTime.get(Calendar.MINUTE), + mTime.get(Calendar.SECOND)); + String description = mDescriptionEditText.getText().toString(); + String notes = mNotesEditText.getText().toString(); + String currencyCode = mAccountsDbAdapter.getAccountCurrencyCode(mAccountUID); + Commodity commodity = CommoditiesDbAdapter.getInstance().getCommodity(currencyCode); + + List splits = extractSplitsFromView(); + + Transaction transaction = new Transaction(description); + transaction.setTime(cal.getTimeInMillis()); + transaction.setCommodity(commodity); + transaction.setCurrencyCode(currencyCode); + transaction.setNote(notes); + transaction.setSplits(splits); + transaction.setExported(false); //not necessary as exports use timestamps now. Because, legacy + + return transaction; + } + + /** + * Checks whether the split editor has been used for editing this transaction. + *

The Split Editor is considered to have been used if the transaction type switch is not visible

+ * @return {@code true} if split editor was used, {@code false} otherwise + */ + private boolean splitEditorUsed(){ + return mTransactionTypeSwitch.getVisibility() != View.VISIBLE; + } + + /** + * Checks if this is a multi-currency transaction being created/edited + *

A multi-currency transaction is one in which the main account and transfer account have different currencies.
+ * Single-entry transactions cannot be multi-currency

+ * @return {@code true} if multi-currency transaction, {@code false} otherwise + */ + private boolean isMultiCurrencyTransaction(){ + if (!mUseDoubleEntry) + return false; + + String transferAcctUID = mAccountsDbAdapter.getUID(mTransferAccountSpinner.getSelectedItemId()); + String currencyCode = mAccountsDbAdapter.getAccountCurrencyCode(mAccountUID); + String transferCurrencyCode = mAccountsDbAdapter.getCurrencyCode(transferAcctUID); + + return !currencyCode.equals(transferCurrencyCode); + } + /** * Collects information from the fragment views and uses it to create * and save a transaction */ private void saveNewTransaction() { mAmountEditText.getCalculatorKeyboard().hideCustomKeyboard(); - Calendar cal = new GregorianCalendar( - mDate.get(Calendar.YEAR), - mDate.get(Calendar.MONTH), - mDate.get(Calendar.DAY_OF_MONTH), - mTime.get(Calendar.HOUR_OF_DAY), - mTime.get(Calendar.MINUTE), - mTime.get(Calendar.SECOND)); - String description = mDescriptionEditText.getText().toString(); - String notes = mNotesEditText.getText().toString(); - BigDecimal amountBigd = mAmountEditText.getValue(); - - if (amountBigd == null){ //if for whatever reason we cannot process the amount - Toast.makeText(getActivity(), R.string.toast_transanction_amount_required, - Toast.LENGTH_SHORT).show(); - return; - } - Currency currency = Currency.getInstance(mTransactionsDbAdapter.getAccountCurrencyCode(mAccountUID)); - Money amount = new Money(amountBigd, Commodity.getInstance(currency.getCurrencyCode())).absolute(); + //determine whether we need to do currency conversion - if (mSplitsList.size() == 1){ //means split editor was opened but no split was added - String transferAcctUID; - if (mUseDoubleEntry) { - long transferAcctId = mTransferAccountSpinner.getSelectedItemId(); - transferAcctUID = mAccountsDbAdapter.getUID(transferAcctId); - } else { - transferAcctUID = mAccountsDbAdapter.getOrCreateImbalanceAccountUID(currency); - } - mSplitsList.add(mSplitsList.get(0).createPair(transferAcctUID)); + if (isMultiCurrencyTransaction() && !splitEditorUsed() && !mCurrencyConversionDone){ + startTransferFunds(); + return; } - //capture any edits which were done directly (not using split editor) - if (mSplitsList.size() == 2 && mSplitsList.get(0).isPairOf(mSplitsList.get(1)) - //we also check that at least one of the splits belongs to this account, otherwise the account was changed in the splits and the value would be zero - && (mSplitsList.get(0).getAccountUID().equals(mAccountUID) || mSplitsList.get(1).getAccountUID().equals(mAccountUID))) { - //if it is a simple transfer where the editor was not used, then respect the button - for (Split split : mSplitsList) { - if (split.getAccountUID().equals(mAccountUID)){ - split.setType(mTransactionTypeSwitch.getTransactionType()); - split.setValue(amount); - split.setQuantity(amount); - } else { - split.setType(mTransactionTypeSwitch.getTransactionType().invert()); - if (mSplitQuantity != null) - split.setQuantity(mSplitQuantity); - else - split.setQuantity(amount); - split.setValue(amount); - } - } + Transaction transaction = extractTransactionFromView(); + if (mEditMode) { //if editing an existing transaction + transaction.setUID(mTransaction.getUID()); } + mTransaction = transaction; mAccountsDbAdapter.beginTransaction(); - try { - if (mTransaction != null) { //if editing an existing transaction - mTransaction.setSplits(mSplitsList); - mTransaction.setDescription(description); - } else { - mTransaction = new Transaction(description); - - //****************** amount entered in the simple interface (not using splits Editor) ************************ - if (mSplitsList.isEmpty()) { - Split split = new Split(amount, mAccountUID); - split.setType(mTransactionTypeSwitch.getTransactionType()); - mTransaction.addSplit(split); - - String transferAcctUID; - long transferAcctId = mTransferAccountSpinner.getSelectedItemId(); - if (mUseDoubleEntry && transferAcctId > 0) { - transferAcctUID = mAccountsDbAdapter.getUID(transferAcctId); - } else { - transferAcctUID = mAccountsDbAdapter.getOrCreateImbalanceAccountUID(currency); - } - Split pair = split.createPair(transferAcctUID); - if (mSplitQuantity != null) - pair.setQuantity(mSplitQuantity); - else { - if (!mAccountsDbAdapter.getCurrencyCode(transferAcctUID).equals(currency.getCurrencyCode())){ - startTransferFunds(); - mTransaction = null; - return; - } - } - mTransaction.addSplit(pair); - } else { //split editor was used to enter splits - mTransaction.setSplits(mSplitsList); - } - } - String currencyCode = mAccountsDbAdapter.getAccountCurrencyCode(mAccountUID); - mTransaction.setCurrencyCode(currencyCode); - Commodity commodity = CommoditiesDbAdapter.getInstance().getCommodity(currencyCode); - mTransaction.setCommodity(commodity); - mTransaction.setTime(cal.getTimeInMillis()); - mTransaction.setNote(notes); - - // set as not exported because we have just edited it - mTransaction.setExported(false); - mTransactionsDbAdapter.addRecord(mTransaction); + try { + // 1) mTransactions may be existing or non-existing + // 2) when mTransactions exists in the db, the splits may exist or not exist in the db + // So replace is chosen. + mTransactionsDbAdapter.addRecord(mTransaction, DatabaseAdapter.UpdateMethod.replace); if (mSaveTemplateCheckbox.isChecked()) {//template is automatically checked when a transaction is scheduled if (!mEditMode) { //means it was new transaction, so a new template Transaction templateTransaction = new Transaction(mTransaction, true); templateTransaction.setTemplate(true); - mTransactionsDbAdapter.addRecord(templateTransaction); + mTransactionsDbAdapter.addRecord(templateTransaction, DatabaseAdapter.UpdateMethod.replace); scheduleRecurringTransaction(templateTransaction.getUID()); } else scheduleRecurringTransaction(mTransaction.getUID()); @@ -859,33 +863,29 @@ private void saveNewTransaction() { private void scheduleRecurringTransaction(String transactionUID) { ScheduledActionDbAdapter scheduledActionDbAdapter = ScheduledActionDbAdapter.getInstance(); - List events = RecurrenceParser.parse(mEventRecurrence, - ScheduledAction.ActionType.TRANSACTION); + Recurrence recurrence = RecurrenceParser.parse(mEventRecurrence); + + ScheduledAction scheduledAction = new ScheduledAction(ScheduledAction.ActionType.TRANSACTION); + scheduledAction.setRecurrence(recurrence); String scheduledActionUID = getArguments().getString(UxArgument.SCHEDULED_ACTION_UID); if (scheduledActionUID != null) { //if we are editing an existing schedule - if ( events.size() == 1) { - ScheduledAction scheduledAction = events.get(0); + if (recurrence == null){ + scheduledActionDbAdapter.deleteRecord(scheduledActionUID); + } else { scheduledAction.setUID(scheduledActionUID); scheduledActionDbAdapter.updateRecurrenceAttributes(scheduledAction); - Toast.makeText(getActivity(), "Updated transaction schedule", Toast.LENGTH_SHORT).show(); - return; - } else { - //if user changed scheduled action so that more than one new schedule would be saved, - // then remove the old one - ScheduledActionDbAdapter.getInstance().deleteRecord(scheduledActionUID); + Toast.makeText(getActivity(), R.string.toast_updated_transaction_recurring_schedule, Toast.LENGTH_SHORT).show(); + } + } else { + if (recurrence != null) { + scheduledAction.setActionUID(transactionUID); + scheduledActionDbAdapter.addRecord(scheduledAction, DatabaseAdapter.UpdateMethod.replace); + Toast.makeText(getActivity(), R.string.toast_scheduled_recurring_transaction, Toast.LENGTH_SHORT).show(); } } - for (ScheduledAction event : events) { - event.setActionUID(transactionUID); - scheduledActionDbAdapter.addRecord(event); - - Log.i("TransactionFormFragment", event.toString()); - } - Toast.makeText(getActivity(), R.string.toast_scheduled_recurring_transaction, Toast.LENGTH_SHORT).show(); - } @@ -939,25 +939,19 @@ public boolean onOptionsItemSelected(MenuItem item) { */ private boolean canSave(){ return (mAmountEditText.isInputValid()) - || (mUseDoubleEntry && mTransferAccountSpinner.getCount() > 0); + && (mUseDoubleEntry && mTransferAccountSpinner.getCount() > 0); } /** * Called by the split editor fragment to notify of finished editing * @param splitList List of splits produced in the fragment */ - public void setSplitList(List splitList, List removedSplitUIDs){ + public void setSplitList(List splitList){ mSplitsList = splitList; Money balance = Transaction.computeBalance(mAccountUID, mSplitsList); mAmountEditText.setValue(balance.asBigDecimal()); mTransactionTypeSwitch.setChecked(balance.isNegative()); - //once we set the split list, do not allow direct editing of the total - if (mSplitsList.size() > 1){ - toggleAmountInputEntryMode(false); - setDoubleEntryViewsVisibility(View.GONE); - mOpenSplitEditor.setVisibility(View.VISIBLE); - } } @@ -977,7 +971,7 @@ private void finish(int resultCode) { } @Override - public void onDateSet(CalendarDatePickerDialog calendarDatePickerDialog, int year, int monthOfYear, int dayOfMonth) { + public void onDateSet(CalendarDatePickerDialogFragment calendarDatePickerDialog, int year, int monthOfYear, int dayOfMonth) { Calendar cal = new GregorianCalendar(year, monthOfYear, dayOfMonth); mDateTextView.setText(DATE_FORMATTER.format(cal.getTime())); mDate.set(Calendar.YEAR, year); @@ -986,7 +980,7 @@ public void onDateSet(CalendarDatePickerDialog calendarDatePickerDialog, int yea } @Override - public void onTimeSet(RadialTimePickerDialog radialTimePickerDialog, int hourOfDay, int minute) { + public void onTimeSet(RadialTimePickerDialogFragment radialTimePickerDialog, int hourOfDay, int minute) { Calendar cal = new GregorianCalendar(0, 0, 0, hourOfDay, minute); mTimeTextView.setText(TIME_FORMATTER.format(cal.getTime())); mTime.set(Calendar.HOUR_OF_DAY, hourOfDay); @@ -1013,8 +1007,14 @@ public static String stripCurrencyFormatting(String s){ return stripped; } + /** + * Flag for checking where the TransferFunds dialog has already been displayed to the user + */ + boolean mCurrencyConversionDone = false; + @Override public void transferComplete(Money amount) { + mCurrencyConversionDone = true; mSplitQuantity = amount; } @@ -1040,13 +1040,13 @@ public void onRecurrenceSet(String rrule) { @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { if (resultCode == Activity.RESULT_OK){ - List splits = data.getStringArrayListExtra(UxArgument.SPLIT_LIST); - List splitList = new ArrayList<>(); - for (String splitCsv : splits) { - splitList.add(Split.parseSplit(splitCsv)); - } - List removedSplits = data.getStringArrayListExtra(UxArgument.REMOVED_SPLITS); - setSplitList(splitList, removedSplits); + List splitList = data.getParcelableArrayListExtra(UxArgument.SPLIT_LIST); + setSplitList(splitList); + + //once split editor has been used and saved, only allow editing through it + toggleAmountInputEntryMode(false); + setDoubleEntryViewsVisibility(View.GONE); + mOpenSplitEditor.setVisibility(View.VISIBLE); } } } diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/TransactionsActivity.java b/app/src/main/java/org/gnucash/android/ui/transaction/TransactionsActivity.java index b2beebf76..7a071296f 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/TransactionsActivity.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/TransactionsActivity.java @@ -32,7 +32,6 @@ import android.support.v4.app.FragmentStatePagerAdapter; import android.support.v4.view.PagerAdapter; import android.support.v4.view.ViewPager; -import android.support.v7.widget.Toolbar; import android.text.format.DateUtils; import android.util.Log; import android.util.SparseArray; @@ -47,30 +46,27 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.Account; import org.gnucash.android.model.Money; +import org.gnucash.android.ui.account.AccountsActivity; +import org.gnucash.android.ui.account.AccountsListFragment; +import org.gnucash.android.ui.account.OnAccountClickedListener; import org.gnucash.android.ui.common.BaseDrawerActivity; import org.gnucash.android.ui.common.FormActivity; +import org.gnucash.android.ui.common.Refreshable; import org.gnucash.android.ui.common.UxArgument; -import org.gnucash.android.ui.account.AccountsActivity; -import org.gnucash.android.ui.account.AccountsListFragment; import org.gnucash.android.ui.util.AccountBalanceTask; -import org.gnucash.android.ui.util.OnAccountClickedListener; -import org.gnucash.android.ui.util.OnTransactionClickedListener; -import org.gnucash.android.ui.util.Refreshable; import org.gnucash.android.util.QualifiedAccountNameCursorAdapter; import org.joda.time.LocalDate; import java.math.BigDecimal; -import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; import butterknife.Bind; -import butterknife.ButterKnife; /** * Activity for displaying, creating and editing transactions @@ -115,9 +111,9 @@ public class TransactionsActivity extends BaseDrawerActivity implements */ private Cursor mAccountsCursor = null; - @Bind(R.id.pager) ViewPager mViewPager; - @Bind(R.id.spinner_toolbar) Spinner mToolbarSpinner; - @Bind(R.id.tab_layout) TabLayout mTabLayout; + @Bind(R.id.pager) ViewPager mViewPager; + @Bind(R.id.toolbar_spinner) Spinner mToolbarSpinner; + @Bind(R.id.tab_layout) TabLayout mTabLayout; @Bind(R.id.transactions_sum) TextView mSumTextView; @Bind(R.id.fab_create_transaction) FloatingActionButton mCreateFloatingButton; @@ -274,18 +270,22 @@ public void refresh(){ setTitleIndicatorColor(); } - @Override + @Override + public int getContentView() { + return R.layout.activity_transactions; + } + + @Override + public int getTitleRes() { + return R.string.title_transactions; + } + + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_transactions); - setUpDrawer(); - Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); - setSupportActionBar(toolbar); getSupportActionBar().setDisplayShowTitleEnabled(false); - ButterKnife.bind(this); - mAccountUID = getIntent().getStringExtra(UxArgument.SELECTED_ACCOUNT_UID); mAccountsDbAdapter = AccountsDbAdapter.getInstance(); @@ -381,7 +381,7 @@ private void setupActionBarNavigation() { mAccountsCursor = mAccountsDbAdapter.fetchAllRecordsOrderedByFullName(); SpinnerAdapter mSpinnerAdapter = new QualifiedAccountNameCursorAdapter( - getSupportActionBar().getThemedContext(), mAccountsCursor); + getSupportActionBar().getThemedContext(), mAccountsCursor, R.layout.account_spinner_item); mToolbarSpinner.setAdapter(mSpinnerAdapter); mToolbarSpinner.setOnItemSelectedListener(mTransactionListNavigationListener); diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/TransactionsListFragment.java b/app/src/main/java/org/gnucash/android/ui/transaction/TransactionsListFragment.java index 9b0a8c416..8d27d592d 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/TransactionsListFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/TransactionsListFragment.java @@ -21,7 +21,7 @@ import android.content.res.Configuration; import android.database.Cursor; import android.os.Bundle; -import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v4.app.LoaderManager.LoaderCallbacks; import android.support.v4.content.Loader; @@ -31,7 +31,6 @@ import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.PopupMenu; import android.support.v7.widget.RecyclerView; -import android.text.format.DateUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; @@ -44,26 +43,24 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; import org.gnucash.android.db.DatabaseCursorLoader; import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.db.SplitsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.SplitsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.model.Money; import org.gnucash.android.model.Split; import org.gnucash.android.model.Transaction; import org.gnucash.android.ui.common.FormActivity; +import org.gnucash.android.ui.common.Refreshable; import org.gnucash.android.ui.common.UxArgument; import org.gnucash.android.ui.homescreen.WidgetConfigurationActivity; +import org.gnucash.android.ui.settings.PreferenceActivity; import org.gnucash.android.ui.transaction.dialog.BulkMoveDialogFragment; import org.gnucash.android.ui.util.CursorRecyclerAdapter; -import org.gnucash.android.ui.util.Refreshable; import org.gnucash.android.ui.util.widget.EmptyRecyclerView; -import org.joda.time.LocalDate; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.Date; import java.util.List; import butterknife.Bind; @@ -85,6 +82,7 @@ public class TransactionsListFragment extends Fragment implements private TransactionsDbAdapter mTransactionsDbAdapter; private String mAccountUID; + private boolean mUseCompactView = false; private TransactionRecyclerAdapter mTransactionRecyclerAdapter; @Bind(R.id.transaction_recycler_view) EmptyRecyclerView mRecyclerView; @@ -97,9 +95,21 @@ public void onCreate(Bundle savedInstanceState) { Bundle args = getArguments(); mAccountUID = args.getString(UxArgument.SELECTED_ACCOUNT_UID); + mUseCompactView = PreferenceActivity.getActiveBookSharedPreferences() + .getBoolean(getActivity().getString(R.string.key_use_compact_list), !GnuCashApplication.isDoubleEntryEnabled()); + //if there was a local override of the global setting, respect it + if (savedInstanceState != null) + mUseCompactView = savedInstanceState.getBoolean(getString(R.string.key_use_compact_list), mUseCompactView); + mTransactionsDbAdapter = TransactionsDbAdapter.getInstance(); } - + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(getString(R.string.key_use_compact_list), mUseCompactView); + } + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -149,7 +159,6 @@ public void refresh(String accountUID){ @Override public void refresh(){ getLoaderManager().restartLoader(0, null, this); - } @Override @@ -167,16 +176,28 @@ public void onListItemClick(long id) { // mTransactionEditListener.editTransaction(mTransactionsDbAdapter.getUID(id)); } - @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { inflater.inflate(R.menu.transactions_list_actions, menu); } + @Override + public void onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + MenuItem item = menu.findItem(R.id.menu_compact_trn_view); + item.setChecked(mUseCompactView); + item.setEnabled(GnuCashApplication.isDoubleEntryEnabled()); //always compact for single-entry + } + @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { - default: + case R.id.menu_compact_trn_view: + item.setChecked(!item.isChecked()); + mUseCompactView = !mUseCompactView; + refresh(); + return true; + default: return super.onOptionsItemSelected(item); } } @@ -224,51 +245,40 @@ public Cursor loadInBackground() { public class TransactionRecyclerAdapter extends CursorRecyclerAdapter{ + public static final int ITEM_TYPE_COMPACT = 0x111; + public static final int ITEM_TYPE_FULL = 0x100; + public TransactionRecyclerAdapter(Cursor cursor) { super(cursor); } @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + int layoutRes = viewType == ITEM_TYPE_COMPACT ? R.layout.cardview_compact_transaction : R.layout.cardview_transaction; View v = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.cardview_transaction, parent, false); + .inflate(layoutRes, parent, false); return new ViewHolder(v); } + @Override + public int getItemViewType(int position) { + return mUseCompactView ? ITEM_TYPE_COMPACT : ITEM_TYPE_FULL; + } @Override public void onBindViewHolderCursor(ViewHolder holder, Cursor cursor) { holder.transactionId = cursor.getLong(cursor.getColumnIndexOrThrow(DatabaseSchema.TransactionEntry._ID)); String description = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.TransactionEntry.COLUMN_DESCRIPTION)); - holder.transactionDescription.setText(description); + holder.primaryText.setText(description); final String transactionUID = cursor.getString(cursor.getColumnIndexOrThrow(DatabaseSchema.TransactionEntry.COLUMN_UID)); Money amount = mTransactionsDbAdapter.getBalance(transactionUID, mAccountUID); TransactionsActivity.displayBalance(holder.transactionAmount, amount); - List splits = SplitsDbAdapter.getInstance().getSplitsForTransaction(transactionUID); - String text = ""; - - if (splits.size() == 2 && splits.get(0).isPairOf(splits.get(1))){ - for (Split split : splits) { - if (!split.getAccountUID().equals(mAccountUID)){ - text = AccountsDbAdapter.getInstance().getFullyQualifiedAccountName(split.getAccountUID()); - break; - } - } - } - - if (splits.size() > 2){ - text = splits.size() + " splits"; - } - holder.transactionNote.setText(text); - long dateMillis = cursor.getLong(cursor.getColumnIndexOrThrow(DatabaseSchema.TransactionEntry.COLUMN_TIMESTAMP)); String dateText = TransactionsActivity.getPrettyDateFormat(getActivity(), dateMillis); - holder.transactionDate.setText(dateText); - final long id = holder.transactionId; holder.itemView.setOnClickListener(new View.OnClickListener() { @Override @@ -277,33 +287,57 @@ public void onClick(View v) { } }); - holder.editTransaction.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - Intent intent = new Intent(getActivity(), FormActivity.class); - intent.putExtra(UxArgument.FORM_TYPE, FormActivity.FormType.TRANSACTION.name()); - intent.putExtra(UxArgument.SELECTED_TRANSACTION_UID, transactionUID); - intent.putExtra(UxArgument.SELECTED_ACCOUNT_UID, mAccountUID); - startActivity(intent); + if (mUseCompactView) { + holder.secondaryText.setText(dateText); + } else { + + List splits = SplitsDbAdapter.getInstance().getSplitsForTransaction(transactionUID); + String text = ""; + + if (splits.size() == 2 && splits.get(0).isPairOf(splits.get(1))) { + for (Split split : splits) { + if (!split.getAccountUID().equals(mAccountUID)) { + text = AccountsDbAdapter.getInstance().getFullyQualifiedAccountName(split.getAccountUID()); + break; + } + } } - }); + if (splits.size() > 2) { + text = splits.size() + " splits"; + } + holder.secondaryText.setText(text); + holder.transactionDate.setText(dateText); + + holder.editTransaction.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Intent intent = new Intent(getActivity(), FormActivity.class); + intent.putExtra(UxArgument.FORM_TYPE, FormActivity.FormType.TRANSACTION.name()); + intent.putExtra(UxArgument.SELECTED_TRANSACTION_UID, transactionUID); + intent.putExtra(UxArgument.SELECTED_ACCOUNT_UID, mAccountUID); + startActivity(intent); + } + }); + } } public class ViewHolder extends RecyclerView.ViewHolder implements PopupMenu.OnMenuItemClickListener{ - @Bind(R.id.primary_text) public TextView transactionDescription; - @Bind(R.id.secondary_text) public TextView transactionNote; + @Bind(R.id.primary_text) public TextView primaryText; + @Bind(R.id.secondary_text) public TextView secondaryText; @Bind(R.id.transaction_amount) public TextView transactionAmount; - @Bind(R.id.transaction_date) public TextView transactionDate; - @Bind(R.id.edit_transaction) public ImageView editTransaction; @Bind(R.id.options_menu) public ImageView optionsMenu; + //these views are not used in the compact view, hence the nullability + @Nullable @Bind(R.id.transaction_date) public TextView transactionDate; + @Nullable @Bind(R.id.edit_transaction) public ImageView editTransaction; + long transactionId; public ViewHolder(View itemView) { super(itemView); ButterKnife.bind(this, itemView); - + primaryText.setTextSize(18); optionsMenu.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { @@ -329,7 +363,7 @@ public boolean onMenuItemClick(MenuItem item) { Transaction transaction = mTransactionsDbAdapter.getRecord(transactionId); Transaction duplicate = new Transaction(transaction, true); duplicate.setTime(System.currentTimeMillis()); - mTransactionsDbAdapter.addRecord(duplicate); + mTransactionsDbAdapter.addRecord(duplicate, DatabaseAdapter.UpdateMethod.insert); refresh(); return true; diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/dialog/BulkMoveDialogFragment.java b/app/src/main/java/org/gnucash/android/ui/transaction/dialog/BulkMoveDialogFragment.java index 2122c96c9..b18c9e496 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/dialog/BulkMoveDialogFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/dialog/BulkMoveDialogFragment.java @@ -29,13 +29,13 @@ import android.widget.Toast; import org.gnucash.android.R; -import org.gnucash.android.db.AccountsDbAdapter; import org.gnucash.android.db.DatabaseSchema; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; +import org.gnucash.android.ui.common.Refreshable; import org.gnucash.android.ui.common.UxArgument; import org.gnucash.android.ui.homescreen.WidgetConfigurationActivity; import org.gnucash.android.ui.transaction.TransactionsActivity; -import org.gnucash.android.ui.util.Refreshable; import org.gnucash.android.util.QualifiedAccountNameCursorAdapter; /** @@ -154,13 +154,14 @@ public void onClick(View v) { } long dstAccountId = mDestinationAccountSpinner.getSelectedItemId(); + String dstAccountUID = AccountsDbAdapter.getInstance().getUID(dstAccountId); TransactionsDbAdapter trxnAdapter = TransactionsDbAdapter.getInstance(); - if (!trxnAdapter.getAccountCurrencyCode(dstAccountId).equals(trxnAdapter.getAccountCurrencyCode(mOriginAccountUID))) { + if (!trxnAdapter.getAccountCurrencyCode(dstAccountUID).equals(trxnAdapter.getAccountCurrencyCode(mOriginAccountUID))) { Toast.makeText(getActivity(), R.string.toast_incompatible_currency, Toast.LENGTH_LONG).show(); return; } String srcAccountUID = ((TransactionsActivity) getActivity()).getCurrentAccountUID(); - String dstAccountUID = AccountsDbAdapter.getInstance().getUID(dstAccountId); + for (long trxnId : mTransactionIds) { trxnAdapter.moveTransaction(trxnAdapter.getUID(trxnId), srcAccountUID, dstAccountUID); } diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/dialog/TransactionsDeleteConfirmationDialogFragment.java b/app/src/main/java/org/gnucash/android/ui/transaction/dialog/TransactionsDeleteConfirmationDialogFragment.java index 3b9bcdbbb..7c6f04cd0 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/dialog/TransactionsDeleteConfirmationDialogFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/dialog/TransactionsDeleteConfirmationDialogFragment.java @@ -24,12 +24,13 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.db.AccountsDbAdapter; -import org.gnucash.android.db.TransactionsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; +import org.gnucash.android.db.adapter.DatabaseAdapter; +import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.export.xml.GncXmlExporter; import org.gnucash.android.model.Transaction; +import org.gnucash.android.ui.common.Refreshable; import org.gnucash.android.ui.common.UxArgument; -import org.gnucash.android.ui.util.Refreshable; import org.gnucash.android.ui.homescreen.WidgetConfigurationActivity; import java.util.ArrayList; @@ -74,7 +75,7 @@ public void onClick(DialogInterface dialog, int whichButton) { transactionsDbAdapter.deleteAllRecords(); if (preserveOpeningBalances) { - transactionsDbAdapter.bulkAddRecords(openingBalances); + transactionsDbAdapter.bulkAddRecords(openingBalances, DatabaseAdapter.UpdateMethod.insert); } } else { transactionsDbAdapter.deleteRecord(rowId); diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/dialog/TransferFundsDialogFragment.java b/app/src/main/java/org/gnucash/android/ui/transaction/dialog/TransferFundsDialogFragment.java index 4a48753fd..fcbe8766b 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/dialog/TransferFundsDialogFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/transaction/dialog/TransferFundsDialogFragment.java @@ -35,19 +35,19 @@ import android.widget.TextView; import org.gnucash.android.R; -import org.gnucash.android.db.CommoditiesDbAdapter; -import org.gnucash.android.db.PricesDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.PricesDbAdapter; import org.gnucash.android.model.Commodity; import org.gnucash.android.model.Money; import org.gnucash.android.model.Price; +import org.gnucash.android.ui.transaction.OnTransferFundsListener; import org.gnucash.android.ui.transaction.TransactionsActivity; -import org.gnucash.android.ui.util.OnTransferFundsListener; +import org.gnucash.android.util.AmountParser; import java.math.BigDecimal; import java.text.DecimalFormat; import java.text.NumberFormat; import java.text.ParseException; -import java.text.ParsePosition; import java.util.Currency; import butterknife.Bind; @@ -204,7 +204,7 @@ private void transferFunds() { if (mExchangeRateRadioButton.isChecked()) { BigDecimal rate; try { - rate = parseAmount(mExchangeRateInput.getText().toString()); + rate = AmountParser.parse(mExchangeRateInput.getText().toString()); } catch (ParseException e) { mExchangeRateInputLayout.setError(getString(R.string.error_invalid_exchange_rate)); return; @@ -218,7 +218,7 @@ private void transferFunds() { if (mConvertedAmountRadioButton.isChecked()) { BigDecimal amount; try { - amount = parseAmount(mConvertedAmountInput.getText().toString()); + amount = AmountParser.parse(mConvertedAmountInput.getText().toString()); } catch (ParseException e) { mConvertedAmountInputLayout.setError(getString(R.string.error_invalid_amount)); return; @@ -240,19 +240,6 @@ private void transferFunds() { dismiss(); } - private BigDecimal parseAmount(String amount) throws ParseException { - DecimalFormat formatter = (DecimalFormat) NumberFormat.getNumberInstance(); - formatter.setParseBigDecimal(true); - ParsePosition parsePosition = new ParsePosition(0); - BigDecimal parsedAmount = (BigDecimal) formatter.parse(amount, parsePosition); - - // Ensure any mistyping by the user is caught instead of partially parsed - if (parsePosition.getIndex() < amount.length()) - throw new ParseException("Parse error", parsePosition.getErrorIndex()); - - return parsedAmount; - } - /** * Hides the error message from mConvertedAmountInputLayout and mExchangeRateInputLayout * when the user edits their content. diff --git a/app/src/main/java/org/gnucash/android/ui/util/AccountBalanceTask.java b/app/src/main/java/org/gnucash/android/ui/util/AccountBalanceTask.java index ab1dccf0f..e16b21da3 100644 --- a/app/src/main/java/org/gnucash/android/ui/util/AccountBalanceTask.java +++ b/app/src/main/java/org/gnucash/android/ui/util/AccountBalanceTask.java @@ -23,7 +23,7 @@ import com.crashlytics.android.Crashlytics; -import org.gnucash.android.db.AccountsDbAdapter; +import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.model.Money; import org.gnucash.android.ui.transaction.TransactionsActivity; diff --git a/app/src/main/java/org/gnucash/android/ui/util/CursorRecyclerAdapter.java b/app/src/main/java/org/gnucash/android/ui/util/CursorRecyclerAdapter.java index d71a7f792..93303aa06 100644 --- a/app/src/main/java/org/gnucash/android/ui/util/CursorRecyclerAdapter.java +++ b/app/src/main/java/org/gnucash/android/ui/util/CursorRecyclerAdapter.java @@ -159,7 +159,7 @@ public void changeCursor(Cursor cursor) { * closed. * * @param newCursor The new cursor to be used. - * @return Returns the previously set Cursor, or null if there wasa not one. + * @return Returns the previously set Cursor, or null if there was not one. * If the given new Cursor is the same instance is the previously set * Cursor, null is also returned. */ @@ -279,7 +279,7 @@ public void setFilterQueryProvider(FilterQueryProvider filterQueryProvider) { * @see ContentObserver#onChange(boolean) */ protected void onContentChanged() { - + notifyDataSetChanged(); } private class ChangeObserver extends ContentObserver { diff --git a/app/src/main/java/org/gnucash/android/ui/util/RecurrenceParser.java b/app/src/main/java/org/gnucash/android/ui/util/RecurrenceParser.java index 660419344..6309db40d 100644 --- a/app/src/main/java/org/gnucash/android/ui/util/RecurrenceParser.java +++ b/app/src/main/java/org/gnucash/android/ui/util/RecurrenceParser.java @@ -20,11 +20,11 @@ import com.codetroopers.betterpickers.recurrencepicker.EventRecurrence; -import org.gnucash.android.model.ScheduledAction; +import org.gnucash.android.model.PeriodType; +import org.gnucash.android.model.Recurrence; -import java.util.ArrayList; +import java.sql.Timestamp; import java.util.Calendar; -import java.util.List; /** * Parses {@link EventRecurrence}s to generate @@ -40,98 +40,62 @@ public class RecurrenceParser { public static final long MONTH_MILLIS = 30*DAY_MILLIS; public static final long YEAR_MILLIS = 12*MONTH_MILLIS; - /** - * Parses an event recurrence to produce {@link org.gnucash.android.model.ScheduledAction}s for each recurrence. - *

Each {@link org.gnucash.android.model.ScheduledAction} represents just one simple repeating schedule, e.g. every Monday. - * If there are multiple schedules in the recurrence e.g. every Monday and Tuesday, then two ScheduledEvents will be generated

- * @param eventRecurrence Event recurrence pattern obtained from dialog - * @param actionType Type of event recurrence - * @return List of ScheduledEvents + * Parse an {@link EventRecurrence} into a {@link Recurrence} object + * @param eventRecurrence EventRecurrence object + * @return Recurrence object */ - public static List parse(EventRecurrence eventRecurrence, ScheduledAction.ActionType actionType){ - long period; - List scheduledActionList = new ArrayList(); + public static Recurrence parse(EventRecurrence eventRecurrence){ if (eventRecurrence == null) - return scheduledActionList; + return null; + PeriodType periodType; switch(eventRecurrence.freq){ - case EventRecurrence.DAILY: { - if (eventRecurrence.interval == 0) //I assume this is a bug from the picker library - period = DAY_MILLIS; - else - period = eventRecurrence.interval * DAY_MILLIS; - - ScheduledAction scheduledAction = new ScheduledAction(actionType); - scheduledAction.setPeriod(period); - parseEndTime(eventRecurrence, scheduledAction); - scheduledActionList.add(scheduledAction); - } + case EventRecurrence.DAILY: + periodType = PeriodType.DAY; break; - case EventRecurrence.WEEKLY: { - if (eventRecurrence.interval == 0) - period = WEEK_MILLIS; - else - period = eventRecurrence.interval * WEEK_MILLIS; - for (int day : eventRecurrence.byday) { - ScheduledAction scheduledAction = new ScheduledAction(actionType); - scheduledAction.setPeriod(period); - - scheduledAction.setStartTime(nextDayOfWeek(day2CalendarDay(day)).getTimeInMillis()); - parseEndTime(eventRecurrence, scheduledAction); - scheduledActionList.add(scheduledAction); - } - } - break; - - case EventRecurrence.MONTHLY: { - if (eventRecurrence.interval == 0) - period = MONTH_MILLIS; - else - period = eventRecurrence.interval * MONTH_MILLIS; - ScheduledAction event = new ScheduledAction(actionType); - event.setPeriod(period); - Calendar now = Calendar.getInstance(); - now.add(Calendar.MONTH, 1); - event.setStartTime(now.getTimeInMillis()); - parseEndTime(eventRecurrence, event); - - scheduledActionList.add(event); - } + case EventRecurrence.WEEKLY: + periodType = PeriodType.WEEK; break; - case EventRecurrence.YEARLY: { - if (eventRecurrence.interval == 0) - period = YEAR_MILLIS; - else - period = eventRecurrence.interval * YEAR_MILLIS; - ScheduledAction event = new ScheduledAction(actionType); - event.setPeriod(period); - Calendar now = Calendar.getInstance(); - now.add(Calendar.YEAR, 1); - event.setStartTime(now.getTimeInMillis()); - parseEndTime(eventRecurrence, event); - scheduledActionList.add(event); - } + case EventRecurrence.MONTHLY: + periodType = PeriodType.MONTH; + break; + + case EventRecurrence.YEARLY: + periodType = PeriodType.YEAR; + break; + + default: + periodType = PeriodType.MONTH; break; } - return scheduledActionList; + + int interval = eventRecurrence.interval == 0 ? 1 : eventRecurrence.interval; //bug from betterpickers library sometimes returns 0 as the interval + periodType.setMultiplier(interval); + Recurrence recurrence = new Recurrence(periodType); + parseEndTime(eventRecurrence, recurrence); + recurrence.setByDay(parseByDay(eventRecurrence.byday)); + if (eventRecurrence.startDate != null) + recurrence.setPeriodStart(new Timestamp(eventRecurrence.startDate.toMillis(false))); + + return recurrence; } /** * Parses the end time from an EventRecurrence object and sets it to the scheduledEvent. * The end time is specified in the dialog either by number of occurences or a date. * @param eventRecurrence Event recurrence pattern obtained from dialog - * @param scheduledAction ScheduledEvent to be to updated + * @param recurrence Recurrence event to set the end period to */ - private static void parseEndTime(EventRecurrence eventRecurrence, ScheduledAction scheduledAction) { + private static void parseEndTime(EventRecurrence eventRecurrence, Recurrence recurrence) { if (eventRecurrence.until != null && eventRecurrence.until.length() > 0) { Time endTime = new Time(); endTime.parse(eventRecurrence.until); - scheduledAction.setEndTime(endTime.toMillis(false)); + recurrence.setPeriodEnd(new Timestamp(endTime.toMillis(false))); } else if (eventRecurrence.count > 0){ - scheduledAction.setTotalFrequency(eventRecurrence.count); + recurrence.setPeriodEnd(eventRecurrence.count); } } @@ -150,6 +114,51 @@ private static Calendar nextDayOfWeek(int dow) { return date; } + /** + * Parses an array of byday values to return the string concatenation of days of the week. + *

Currently only supports byDay values for weeks

+ * @param byday Array of byday values + * @return String concat of days of the week or null if {@code byday} was empty + */ + private static String parseByDay(int[] byday){ + if (byday == null || byday.length == 0){ + return null; + } + //todo: parse for month and year as well, when our dialog supports those + StringBuilder builder = new StringBuilder(); + for (int day : byday) { + switch (day) + { + case EventRecurrence.SU: + builder.append("SU"); + break; + case EventRecurrence.MO: + builder.append("MO"); + break; + case EventRecurrence.TU: + builder.append("TU"); + break; + case EventRecurrence.WE: + builder.append("WE"); + break; + case EventRecurrence.TH: + builder.append("TH"); + break; + case EventRecurrence.FR: + builder.append("FR"); + break; + case EventRecurrence.SA: + builder.append("SA"); + break; + default: + throw new RuntimeException("bad day of week: " + day); + } + builder.append(","); + } + builder.deleteCharAt(builder.length()-1); + return builder.toString(); + } + /** * Converts one of the SU, MO, etc. constants to the Calendar.SUNDAY * constants. btw, I think we should switch to those here too, to diff --git a/app/src/main/java/org/gnucash/android/ui/util/RecurrenceViewClickListener.java b/app/src/main/java/org/gnucash/android/ui/util/RecurrenceViewClickListener.java new file mode 100644 index 000000000..457e9184c --- /dev/null +++ b/app/src/main/java/org/gnucash/android/ui/util/RecurrenceViewClickListener.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2015 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gnucash.android.ui.util; + +import android.os.Bundle; +import android.support.v4.app.FragmentManager; +import android.support.v7.app.AppCompatActivity; +import android.text.format.Time; +import android.view.View; + +import com.codetroopers.betterpickers.recurrencepicker.RecurrencePickerDialogFragment; +import com.codetroopers.betterpickers.recurrencepicker.RecurrencePickerDialogFragment.OnRecurrenceSetListener; + +/** + * Shows the recurrence dialog when the recurrence view is clicked + */ +public class RecurrenceViewClickListener implements View.OnClickListener{ + private static final String FRAGMENT_TAG_RECURRENCE_PICKER = "recurrence_picker"; + + AppCompatActivity mActivity; + String mRecurrenceRule; + OnRecurrenceSetListener mRecurrenceSetListener; + + public RecurrenceViewClickListener(AppCompatActivity activity, String recurrenceRule, + OnRecurrenceSetListener recurrenceSetListener){ + this.mActivity = activity; + this.mRecurrenceRule = recurrenceRule; + this.mRecurrenceSetListener = recurrenceSetListener; + } + + @Override + public void onClick(View v) { + FragmentManager fm = mActivity.getSupportFragmentManager(); + Bundle b = new Bundle(); + Time t = new Time(); + t.setToNow(); + b.putLong(RecurrencePickerDialogFragment.BUNDLE_START_TIME_MILLIS, t.toMillis(false)); + b.putString(RecurrencePickerDialogFragment.BUNDLE_TIME_ZONE, t.timezone); + + // may be more efficient to serialize and pass in EventRecurrence + b.putString(RecurrencePickerDialogFragment.BUNDLE_RRULE, mRecurrenceRule); + + RecurrencePickerDialogFragment rpd = (RecurrencePickerDialogFragment) fm.findFragmentByTag( + FRAGMENT_TAG_RECURRENCE_PICKER); + if (rpd != null) { + rpd.dismiss(); + } + rpd = new RecurrencePickerDialogFragment(); + rpd.setArguments(b); + rpd.setOnRecurrenceSetListener(mRecurrenceSetListener); + rpd.show(fm, FRAGMENT_TAG_RECURRENCE_PICKER); + } +} diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/dialog/DatePickerDialogFragment.java b/app/src/main/java/org/gnucash/android/ui/util/dialog/DatePickerDialogFragment.java similarity index 98% rename from app/src/main/java/org/gnucash/android/ui/transaction/dialog/DatePickerDialogFragment.java rename to app/src/main/java/org/gnucash/android/ui/util/dialog/DatePickerDialogFragment.java index f2a70e9db..b7ef99e0f 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/dialog/DatePickerDialogFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/util/dialog/DatePickerDialogFragment.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.gnucash.android.ui.transaction.dialog; +package org.gnucash.android.ui.util.dialog; import android.app.DatePickerDialog; import android.app.DatePickerDialog.OnDateSetListener; diff --git a/app/src/main/java/org/gnucash/android/ui/report/dialog/DateRangePickerDialogFragment.java b/app/src/main/java/org/gnucash/android/ui/util/dialog/DateRangePickerDialogFragment.java similarity index 97% rename from app/src/main/java/org/gnucash/android/ui/report/dialog/DateRangePickerDialogFragment.java rename to app/src/main/java/org/gnucash/android/ui/util/dialog/DateRangePickerDialogFragment.java index 7681e7a08..954c10f2e 100644 --- a/app/src/main/java/org/gnucash/android/ui/report/dialog/DateRangePickerDialogFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/util/dialog/DateRangePickerDialogFragment.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.gnucash.android.ui.report.dialog; +package org.gnucash.android.ui.util.dialog; import android.app.Dialog; import android.os.Bundle; @@ -83,7 +83,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa .inMode(CalendarPickerView.SelectionMode.RANGE) .withSelectedDate(today); - mDoneButton.setText("Done"); + mDoneButton.setText(R.string.done_label); mDoneButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { diff --git a/app/src/main/java/org/gnucash/android/ui/transaction/dialog/TimePickerDialogFragment.java b/app/src/main/java/org/gnucash/android/ui/util/dialog/TimePickerDialogFragment.java similarity index 98% rename from app/src/main/java/org/gnucash/android/ui/transaction/dialog/TimePickerDialogFragment.java rename to app/src/main/java/org/gnucash/android/ui/util/dialog/TimePickerDialogFragment.java index 700f3865f..06dbc250a 100644 --- a/app/src/main/java/org/gnucash/android/ui/transaction/dialog/TimePickerDialogFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/util/dialog/TimePickerDialogFragment.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.gnucash.android.ui.transaction.dialog; +package org.gnucash.android.ui.util.dialog; import android.app.Dialog; import android.app.TimePickerDialog; diff --git a/app/src/main/java/org/gnucash/android/ui/util/widget/CalculatorEditText.java b/app/src/main/java/org/gnucash/android/ui/util/widget/CalculatorEditText.java index b2a4925ef..69a778906 100644 --- a/app/src/main/java/org/gnucash/android/ui/util/widget/CalculatorEditText.java +++ b/app/src/main/java/org/gnucash/android/ui/util/widget/CalculatorEditText.java @@ -19,6 +19,7 @@ import android.content.Context; import android.content.res.TypedArray; import android.inputmethodservice.KeyboardView; +import android.support.annotation.Nullable; import android.support.annotation.XmlRes; import android.text.Editable; import android.text.InputType; @@ -39,10 +40,12 @@ import org.gnucash.android.app.GnuCashApplication; import org.gnucash.android.model.Commodity; import org.gnucash.android.ui.common.FormActivity; +import org.gnucash.android.util.AmountParser; import java.math.BigDecimal; import java.text.DecimalFormat; import java.text.NumberFormat; +import java.text.ParseException; import java.util.Locale; /** @@ -149,7 +152,7 @@ public void onClick(View v) { setOnLongClickListener(new View.OnLongClickListener() { @Override public boolean onLongClick(View v) { - if (v != null) + if (v != null && !isInEditMode()) ((InputMethodManager) GnuCashApplication.getAppContext() .getSystemService(Activity.INPUT_METHOD_SERVICE)) .hideSoftInputFromWindow(v.getWindowToken(), 0); @@ -208,16 +211,16 @@ public void setCalculatorKeyboardView(KeyboardView calculatorKeyboardView) { * Returns the XML resource ID describing the calculator keys layout * @return XML resource ID */ - public int getCalculatorKeysLayout() { + public @XmlRes int getCalculatorKeysLayout() { return mCalculatorKeysLayout; } /** * Sets the XML resource describing the layout of the calculator keys - * @param mCalculatorKeysLayout XML resource ID + * @param calculatorKeysLayout XML resource ID */ - public void setCalculatorKeysLayout(@XmlRes int mCalculatorKeysLayout) { - this.mCalculatorKeysLayout = mCalculatorKeysLayout; + public void setCalculatorKeysLayout(@XmlRes int calculatorKeysLayout) { + this.mCalculatorKeysLayout = calculatorKeysLayout; bindListeners(mCalculatorKeyboardView); } @@ -283,8 +286,8 @@ public String evaluate(){ * @return @{code true} if the input is valid, {@code false} otherwise */ public boolean isInputValid(){ - evaluate(); - return getText().length() > 0 && getError() == null; + String text = evaluate(); + return !text.isEmpty() && getError() == null; } /** @@ -309,18 +312,13 @@ public boolean isInputModified(){ * Performs an evaluation of the expression first * @return BigDecimal value */ - public BigDecimal getValue(){ + public @Nullable BigDecimal getValue(){ evaluate(); - String amountString = getCleanString(); - if (amountString.isEmpty()) - return null; try { //catch any exceptions in the conversion e.g. if a string with only "-" is entered - return new BigDecimal(amountString); - } catch (NumberFormatException e){ - String msg = "Error parsing amount string " + amountString + " from CalculatorEditText"; + return AmountParser.parse(getText().toString()); + } catch (ParseException e){ + String msg = "Error parsing amount string " + getText() + " from CalculatorEditText"; Log.i(getClass().getSimpleName(), msg, e); - Crashlytics.log(msg); - Crashlytics.logException(e); return null; } } @@ -340,7 +338,7 @@ public void setValue(BigDecimal amount){ formatter.setGroupingUsed(false); String resultString = formatter.format(newAmount.doubleValue()); - setText(resultString); + super.setText(resultString); setSelection(resultString.length()); } } diff --git a/app/src/main/java/org/gnucash/android/ui/util/widget/TransactionTypeSwitch.java b/app/src/main/java/org/gnucash/android/ui/util/widget/TransactionTypeSwitch.java index c8fc18840..ead7c15b6 100644 --- a/app/src/main/java/org/gnucash/android/ui/util/widget/TransactionTypeSwitch.java +++ b/app/src/main/java/org/gnucash/android/ui/util/widget/TransactionTypeSwitch.java @@ -20,7 +20,6 @@ import android.support.v7.widget.SwitchCompat; import android.util.AttributeSet; import android.widget.CompoundButton; -import android.widget.EditText; import android.widget.TextView; import org.gnucash.android.R; diff --git a/app/src/main/java/org/gnucash/android/ui/wizard/CurrencySelectFragment.java b/app/src/main/java/org/gnucash/android/ui/wizard/CurrencySelectFragment.java index 00cfe08fe..fb0203bbe 100644 --- a/app/src/main/java/org/gnucash/android/ui/wizard/CurrencySelectFragment.java +++ b/app/src/main/java/org/gnucash/android/ui/wizard/CurrencySelectFragment.java @@ -27,7 +27,7 @@ import com.tech.freak.wizardpager.ui.PageFragmentCallbacks; import org.gnucash.android.R; -import org.gnucash.android.db.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; import org.gnucash.android.util.CommoditiesCursorAdapter; import butterknife.ButterKnife; diff --git a/app/src/main/java/org/gnucash/android/ui/wizard/FirstRunWizardActivity.java b/app/src/main/java/org/gnucash/android/ui/wizard/FirstRunWizardActivity.java index 717328493..7254ef5f9 100644 --- a/app/src/main/java/org/gnucash/android/ui/wizard/FirstRunWizardActivity.java +++ b/app/src/main/java/org/gnucash/android/ui/wizard/FirstRunWizardActivity.java @@ -37,9 +37,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.Button; -import android.widget.Toast; -import com.crashlytics.android.Crashlytics; import com.tech.freak.wizardpager.model.AbstractWizardModel; import com.tech.freak.wizardpager.model.ModelCallbacks; import com.tech.freak.wizardpager.model.Page; @@ -50,12 +48,10 @@ import org.gnucash.android.R; import org.gnucash.android.app.GnuCashApplication; -import org.gnucash.android.importer.ImportAsyncTask; +import org.gnucash.android.db.adapter.BooksDbAdapter; import org.gnucash.android.ui.account.AccountsActivity; import org.gnucash.android.ui.util.TaskDelegate; -import java.io.FileNotFoundException; -import java.io.InputStream; import java.util.ArrayList; import java.util.List; @@ -209,7 +205,10 @@ private void createAccountsAndFinish() { AccountsActivity.removeFirstRunFlag(); if (mAccountOptions.equals(getString(R.string.wizard_option_create_default_accounts))){ + //save the UID of the active book, and then delete it after successful import + String bookUID = BooksDbAdapter.getInstance().getActiveBookUID(); AccountsActivity.createDefaultAccounts(mCurrencyCode, FirstRunWizardActivity.this); + BooksDbAdapter.getInstance().deleteBook(bookUID); //a default book is usually created finish(); } else if (mAccountOptions.equals(getString(R.string.wizard_option_import_my_accounts))){ AccountsActivity.startXmlFileChooser(this); diff --git a/app/src/main/java/org/gnucash/android/util/AmountParser.java b/app/src/main/java/org/gnucash/android/util/AmountParser.java new file mode 100644 index 000000000..f873a780f --- /dev/null +++ b/app/src/main/java/org/gnucash/android/util/AmountParser.java @@ -0,0 +1,32 @@ +package org.gnucash.android.util; + +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.text.ParseException; +import java.text.ParsePosition; + +/** + * Parses amounts as String into BigDecimal. + */ +public class AmountParser { + /** + * Parses {@code amount} and returns it as a BigDecimal. + * + * @param amount String with the amount to parse. + * @return The amount parsed as a BigDecimal. + * @throws ParseException if the full string couldn't be parsed as an amount. + */ + public static BigDecimal parse(String amount) throws ParseException { + DecimalFormat formatter = (DecimalFormat) NumberFormat.getNumberInstance(); + formatter.setParseBigDecimal(true); + ParsePosition parsePosition = new ParsePosition(0); + BigDecimal parsedAmount = (BigDecimal) formatter.parse(amount, parsePosition); + + // Ensure any mistyping by the user is caught instead of partially parsed + if ((parsedAmount == null) || (parsePosition.getIndex() < amount.length())) + throw new ParseException("Parse error", parsePosition.getErrorIndex()); + + return parsedAmount; + } +} \ No newline at end of file diff --git a/app/src/main/java/org/gnucash/android/util/CommoditiesCursorAdapter.java b/app/src/main/java/org/gnucash/android/util/CommoditiesCursorAdapter.java index 0a5e246cd..ddc548950 100644 --- a/app/src/main/java/org/gnucash/android/util/CommoditiesCursorAdapter.java +++ b/app/src/main/java/org/gnucash/android/util/CommoditiesCursorAdapter.java @@ -21,15 +21,12 @@ import android.support.annotation.LayoutRes; import android.support.v4.widget.SimpleCursorAdapter; import android.text.TextUtils; -import android.view.ContextMenu; import android.view.View; import android.widget.TextView; -import org.gnucash.android.db.CommoditiesDbAdapter; +import org.gnucash.android.db.adapter.CommoditiesDbAdapter; import org.gnucash.android.db.DatabaseSchema; -import java.util.Currency; - /** * Cursor adapter for displaying list of commodities. *

You should provide the layout and the layout should contain a view with the id {@code android:id/text1}, diff --git a/app/src/main/java/org/gnucash/android/util/PreferencesHelper.java b/app/src/main/java/org/gnucash/android/util/PreferencesHelper.java index a6d9d19aa..d58f2e7dc 100644 --- a/app/src/main/java/org/gnucash/android/util/PreferencesHelper.java +++ b/app/src/main/java/org/gnucash/android/util/PreferencesHelper.java @@ -15,10 +15,12 @@ */ package org.gnucash.android.util; -import android.preference.PreferenceManager; +import android.content.Context; import android.util.Log; import org.gnucash.android.app.GnuCashApplication; +import org.gnucash.android.db.adapter.BooksDbAdapter; +import org.gnucash.android.ui.settings.PreferenceActivity; import java.sql.Timestamp; @@ -37,18 +39,34 @@ private PreferencesHelper() {} */ protected static final String LOG_TAG = "PreferencesHelper"; - private static final String PREFERENCE_LAST_EXPORT_TIME_KEY = "last_export_time"; + /** + * Preference key for saving the last export time + */ + public static final String PREFERENCE_LAST_EXPORT_TIME_KEY = "last_export_time"; /** - * Set the last export time in UTC time zone. - * A new export operations will fetch transactions based on this value. + * Set the last export time in UTC time zone of the currently active Book in the application. + * This method calls through to {@link #setLastExportTime(Timestamp, String)} * * @param lastExportTime the last export time to set. + * @see #setLastExportTime(Timestamp, String) */ public static void setLastExportTime(Timestamp lastExportTime) { + final String utcString = TimestampHelper.getUtcStringFromTimestamp(lastExportTime); + Log.v(LOG_TAG, "Saving last export time for the currently active book"); + setLastExportTime(lastExportTime, BooksDbAdapter.getInstance().getActiveBookUID()); + } + + /** + * Set the last export time in UTC time zone for a specific book. + * This value vill be used during export to determine new transactions since the last export + * + * @param lastExportTime the last export time to set. + */ + public static void setLastExportTime(Timestamp lastExportTime, String bookUID) { final String utcString = TimestampHelper.getUtcStringFromTimestamp(lastExportTime); Log.d(LOG_TAG, "Storing '" + utcString + "' as lastExportTime in Android Preferences."); - PreferenceManager.getDefaultSharedPreferences(GnuCashApplication.getAppContext()) + GnuCashApplication.getAppContext().getSharedPreferences(bookUID, Context.MODE_PRIVATE) .edit() .putString(PREFERENCE_LAST_EXPORT_TIME_KEY, utcString) .apply(); @@ -60,7 +78,7 @@ public static void setLastExportTime(Timestamp lastExportTime) { * @return A {@link Timestamp} with the time. */ public static Timestamp getLastExportTime() { - final String utcString = PreferenceManager.getDefaultSharedPreferences(GnuCashApplication.getAppContext()) + final String utcString = PreferenceActivity.getActiveBookSharedPreferences() .getString(PREFERENCE_LAST_EXPORT_TIME_KEY, TimestampHelper.getUtcStringFromTimestamp(TimestampHelper.getTimestampFromEpochZero())); Log.d(LOG_TAG, "Retrieving '" + utcString + "' as lastExportTime from Android Preferences."); diff --git a/app/src/main/java/org/gnucash/android/util/QualifiedAccountNameCursorAdapter.java b/app/src/main/java/org/gnucash/android/util/QualifiedAccountNameCursorAdapter.java index c2183a25c..a2b310932 100644 --- a/app/src/main/java/org/gnucash/android/util/QualifiedAccountNameCursorAdapter.java +++ b/app/src/main/java/org/gnucash/android/util/QualifiedAccountNameCursorAdapter.java @@ -18,12 +18,16 @@ import android.content.Context; import android.database.Cursor; +import android.support.annotation.LayoutRes; +import android.support.annotation.NonNull; import android.support.v4.widget.SimpleCursorAdapter; import android.text.TextUtils; import android.view.View; import android.widget.TextView; +import org.gnucash.android.R; import org.gnucash.android.db.DatabaseSchema; +import org.gnucash.android.db.adapter.AccountsDbAdapter; /** * Cursor adapter which looks up the fully qualified account name and returns that instead of just the simple name. @@ -33,11 +37,30 @@ */ public class QualifiedAccountNameCursorAdapter extends SimpleCursorAdapter { + /** + * Initialize the Cursor adapter for account names using default spinner views + * @param context Application context + * @param cursor Cursor to accounts + */ public QualifiedAccountNameCursorAdapter(Context context, Cursor cursor) { super(context, android.R.layout.simple_spinner_item, cursor, new String[]{DatabaseSchema.AccountEntry.COLUMN_FULL_NAME}, new int[]{android.R.id.text1}, 0); - setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + setDropDownViewResource(R.layout.account_spinner_dropdown_item); + } + + /** + * Overloaded constructor. Specifies the view to use for displaying selected spinner text + * @param context Application context + * @param cursor Cursor to account data + * @param selectedSpinnerItem Layout resource for selected item text + */ + public QualifiedAccountNameCursorAdapter(Context context, Cursor cursor, + @LayoutRes int selectedSpinnerItem) { + super(context, selectedSpinnerItem, cursor, + new String[]{DatabaseSchema.AccountEntry.COLUMN_FULL_NAME}, + new int[]{android.R.id.text1}, 0); + setDropDownViewResource(R.layout.account_spinner_dropdown_item); } @Override @@ -46,4 +69,19 @@ public void bindView(View view, Context context, Cursor cursor) { TextView textView = (TextView) view.findViewById(android.R.id.text1); textView.setEllipsize(TextUtils.TruncateAt.MIDDLE); } + + /** + * Returns the position of a given account in the adapter + * @param accountUID GUID of the account + * @return Position of the account or -1 if the account is not found + */ + public int getPosition(@NonNull String accountUID){ + long accountId = AccountsDbAdapter.getInstance().getID(accountUID); + for (int pos = 0; pos < getCount(); pos++) { + if (getItemId(pos) == accountId){ + return pos; + } + } + return -1; + } } diff --git a/app/src/main/java/org/gnucash/android/util/RecursiveMoveFiles.java b/app/src/main/java/org/gnucash/android/util/RecursiveMoveFiles.java new file mode 100644 index 000000000..04ece4f85 --- /dev/null +++ b/app/src/main/java/org/gnucash/android/util/RecursiveMoveFiles.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2016 Ngewi Fet + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.gnucash.android.util; + +import android.util.Log; + +import org.gnucash.android.db.MigrationHelper; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; + +/** + * Moves all files from one directory into another. + * The destination directory is assumed to already exist + */ +public class RecursiveMoveFiles implements Runnable { + File mSource; + File mDestination; + + /** + * Constructor, specify origin and target directories + * @param src Source directory/file. If directory, all files within it will be moved + * @param dst Destination directory/file. If directory, it should already exist + */ + public RecursiveMoveFiles(File src, File dst){ + mSource = src; + mDestination = dst; + } + + /** + * Copy file from one location to another. + * Does not support copying of directories + * @param src Source file + * @param dst Destination of the file + * @return {@code true} if the file was successfully copied, {@code false} otherwise + * @throws IOException + */ + private boolean copy(File src, File dst) throws IOException { + FileChannel inChannel = new FileInputStream(src).getChannel(); + FileChannel outChannel = new FileOutputStream(dst).getChannel(); + try { + long bytesCopied = inChannel.transferTo(0, inChannel.size(), outChannel); + return bytesCopied >= src.length(); + } finally { + if (inChannel != null) + inChannel.close(); + outChannel.close(); + } + } + + /** + * Recursively copy files from one location to another and deletes the origin files after copy. + * If the source file is a directory, all of the files in it will be moved. + * This method will create the destination directory if the {@code src} is also a directory + * @param src origin file + * @param dst destination file or directory + * @return number of files copied (excluding parent directory) + */ + private int recursiveMove(File src, File dst){ + int copyCount = 0; + if (src.isDirectory()){ + dst.mkdirs(); //we assume it works everytime. Great, right? + for (File file : src.listFiles()) { + File target = new File(dst, file.getName()); + copyCount += recursiveMove(file, target); + } + src.delete(); + } else { + try { + if(copy(src, dst)) + src.delete(); + } catch (IOException e) { + Log.d(MigrationHelper.LOG_TAG, "Error moving file: " + src.getAbsolutePath()); + } + } + Log.d("RecursiveMoveFiles", String.format("Moved %d files from %s to %s", copyCount, src.getPath(), dst.getPath())); + return copyCount; + } + + @Override + public void run() { + recursiveMove(mSource, mDestination); + } +} diff --git a/app/src/main/java/org/gnucash/android/util/TimestampHelper.java b/app/src/main/java/org/gnucash/android/util/TimestampHelper.java index 407f187c0..c67b557f4 100644 --- a/app/src/main/java/org/gnucash/android/util/TimestampHelper.java +++ b/app/src/main/java/org/gnucash/android/util/TimestampHelper.java @@ -27,6 +27,8 @@ */ public final class TimestampHelper { + public static final Timestamp EPOCH_ZERO_TIMESTAMP = new Timestamp(0); + /** * Should be not instantiated. */ @@ -56,7 +58,7 @@ public static String getUtcStringFromTimestamp(Timestamp timestamp) { * @return A {@link Timestamp} with time in milliseconds equals to zero. */ public static Timestamp getTimestampFromEpochZero() { - return new Timestamp(0); + return EPOCH_ZERO_TIMESTAMP; } /** diff --git a/app/src/main/res/drawable-hdpi/ic_arrow_drop_down_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_arrow_drop_down_white_24dp.png new file mode 100644 index 000000000..4c6076df7 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_arrow_drop_down_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_clear_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_clear_black_24dp.png new file mode 100644 index 000000000..1a9cd75a0 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_clear_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_dashboard_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_dashboard_black_24dp.png new file mode 100644 index 000000000..b832916f5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_dashboard_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_nav_header.png b/app/src/main/res/drawable-hdpi/ic_nav_header.png new file mode 100644 index 000000000..13696e9ce Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_nav_header.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_owncloud.png b/app/src/main/res/drawable-hdpi/ic_owncloud.png new file mode 100644 index 000000000..83587e2cc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_owncloud.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_arrow_drop_down_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_arrow_drop_down_white_24dp.png new file mode 100644 index 000000000..4046a7490 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_arrow_drop_down_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_clear_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_clear_black_24dp.png new file mode 100644 index 000000000..40a1a84e3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_clear_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_dashboard_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_dashboard_black_24dp.png new file mode 100644 index 000000000..c0cb8620d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_dashboard_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_nav_header.png b/app/src/main/res/drawable-mdpi/ic_nav_header.png new file mode 100644 index 000000000..13696e9ce Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_nav_header.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_owncloud.png b/app/src/main/res/drawable-mdpi/ic_owncloud.png new file mode 100644 index 000000000..53b18c85a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_owncloud.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_arrow_drop_down_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_arrow_drop_down_white_24dp.png new file mode 100644 index 000000000..da239e4f4 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_arrow_drop_down_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_clear_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_clear_black_24dp.png new file mode 100644 index 000000000..6bc437298 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_clear_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_dashboard_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_dashboard_black_24dp.png new file mode 100644 index 000000000..ba2911e39 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_dashboard_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_nav_header.png b/app/src/main/res/drawable-xhdpi/ic_nav_header.png new file mode 100644 index 000000000..052f51dac Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_nav_header.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_owncloud.png b/app/src/main/res/drawable-xhdpi/ic_owncloud.png new file mode 100644 index 000000000..3aaf9454f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_owncloud.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_arrow_drop_down_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_arrow_drop_down_white_24dp.png new file mode 100644 index 000000000..c19c19d2b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_arrow_drop_down_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_clear_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_clear_black_24dp.png new file mode 100644 index 000000000..51b4401ca Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_clear_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_dashboard_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_dashboard_black_24dp.png new file mode 100644 index 000000000..ad14dfeb9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_dashboard_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_nav_header.png b/app/src/main/res/drawable-xxhdpi/ic_nav_header.png new file mode 100644 index 000000000..a970ef753 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_nav_header.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_owncloud.png b/app/src/main/res/drawable-xxhdpi/ic_owncloud.png new file mode 100644 index 000000000..633ac813d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_owncloud.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_arrow_drop_down_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_arrow_drop_down_white_24dp.png new file mode 100644 index 000000000..452e50216 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_arrow_drop_down_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_clear_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_clear_black_24dp.png new file mode 100644 index 000000000..df42feecb Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_clear_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_dashboard_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_dashboard_black_24dp.png new file mode 100644 index 000000000..8fad114fe Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_dashboard_black_24dp.png differ diff --git a/app/src/main/res/drawable/budget_progress_indicator.xml b/app/src/main/res/drawable/budget_progress_indicator.xml new file mode 100644 index 000000000..fb2bae40c --- /dev/null +++ b/app/src/main/res/drawable/budget_progress_indicator.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/numeric_button.xml b/app/src/main/res/drawable/numeric_button.xml index 696f061cb..4f7baa871 100644 --- a/app/src/main/res/drawable/numeric_button.xml +++ b/app/src/main/res/drawable/numeric_button.xml @@ -15,38 +15,25 @@ See the License for the specific language governing permissions and limitations under the License. --> - - - - - - - - - - - - - + + + + + + - - - - - + + + + + + - + \ No newline at end of file diff --git a/app/src/main/res/layout-sw600dp/activity_settings.xml b/app/src/main/res/layout-sw600dp/activity_settings.xml new file mode 100644 index 000000000..cb7e5e707 --- /dev/null +++ b/app/src/main/res/layout-sw600dp/activity_settings.xml @@ -0,0 +1,37 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_spinner_dropdown_item.xml b/app/src/main/res/layout/account_spinner_dropdown_item.xml new file mode 100644 index 000000000..f27051994 --- /dev/null +++ b/app/src/main/res/layout/account_spinner_dropdown_item.xml @@ -0,0 +1,24 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_spinner_item.xml b/app/src/main/res/layout/account_spinner_item.xml new file mode 100644 index 000000000..a0119acc3 --- /dev/null +++ b/app/src/main/res/layout/account_spinner_item.xml @@ -0,0 +1,25 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/toolbar_transactions_activity.xml b/app/src/main/res/layout/actionbar_progress_indicator.xml similarity index 52% rename from app/src/main/res/layout/toolbar_transactions_activity.xml rename to app/src/main/res/layout/actionbar_progress_indicator.xml index 2f6d7745e..6761a74b1 100644 --- a/app/src/main/res/layout/toolbar_transactions_activity.xml +++ b/app/src/main/res/layout/actionbar_progress_indicator.xml @@ -14,20 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. --> - + - - \ No newline at end of file + android:layout_height="@dimen/toolbar_progress_height" + android:indeterminate="true" + android:visibility="gone" + app:elevation="@dimen/elevation_default" + tools:showIn="@layout/toolbar_with_spinner" /> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_accounts.xml b/app/src/main/res/layout/activity_accounts.xml index 838e12f95..20ea2521b 100644 --- a/app/src/main/res/layout/activity_accounts.xml +++ b/app/src/main/res/layout/activity_accounts.xml @@ -1,6 +1,6 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_reports.xml b/app/src/main/res/layout/activity_reports.xml index 602d4d153..628c334fc 100644 --- a/app/src/main/res/layout/activity_reports.xml +++ b/app/src/main/res/layout/activity_reports.xml @@ -24,65 +24,69 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - + + android:layout_height="wrap_content" + android:gravity="center_vertical"> - - + - - - - + + + - + + + + + + - + - - + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_transaction_detail.xml b/app/src/main/res/layout/activity_transaction_detail.xml index f04f239a8..b8db15440 100644 --- a/app/src/main/res/layout/activity_transaction_detail.xml +++ b/app/src/main/res/layout/activity_transaction_detail.xml @@ -22,16 +22,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_transactions.xml b/app/src/main/res/layout/activity_transactions.xml index 4893f3a84..9127d4814 100644 --- a/app/src/main/res/layout/activity_transactions.xml +++ b/app/src/main/res/layout/activity_transactions.xml @@ -33,7 +33,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - + + android:layout_height="wrap_content"> + + \ No newline at end of file diff --git a/app/src/main/res/layout/cardview_book.xml b/app/src/main/res/layout/cardview_book.xml new file mode 100644 index 000000000..be292e8e1 --- /dev/null +++ b/app/src/main/res/layout/cardview_book.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/cardview_budget.xml b/app/src/main/res/layout/cardview_budget.xml new file mode 100644 index 000000000..689d1da9c --- /dev/null +++ b/app/src/main/res/layout/cardview_budget.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/cardview_budget_amount.xml b/app/src/main/res/layout/cardview_budget_amount.xml new file mode 100644 index 000000000..c920774bd --- /dev/null +++ b/app/src/main/res/layout/cardview_budget_amount.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/cardview_compact_transaction.xml b/app/src/main/res/layout/cardview_compact_transaction.xml new file mode 100644 index 000000000..c009d744e --- /dev/null +++ b/app/src/main/res/layout/cardview_compact_transaction.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/cardview_transaction.xml b/app/src/main/res/layout/cardview_transaction.xml index c2bfabd48..f5e31101f 100644 --- a/app/src/main/res/layout/cardview_transaction.xml +++ b/app/src/main/res/layout/cardview_transaction.xml @@ -26,7 +26,7 @@ android:layout_marginLeft="5dp" android:layout_marginRight="5dp" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="wrap_content"> + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_account_form.xml b/app/src/main/res/layout/fragment_account_form.xml index 44d4f6fcb..62230c94e 100644 --- a/app/src/main/res/layout/fragment_account_form.xml +++ b/app/src/main/res/layout/fragment_account_form.xml @@ -1,6 +1,6 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_budget_amount_editor.xml b/app/src/main/res/layout/fragment_budget_amount_editor.xml new file mode 100644 index 000000000..e5a556a59 --- /dev/null +++ b/app/src/main/res/layout/fragment_budget_amount_editor.xml @@ -0,0 +1,42 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_budget_detail.xml b/app/src/main/res/layout/fragment_budget_detail.xml new file mode 100644 index 000000000..8351a0b6f --- /dev/null +++ b/app/src/main/res/layout/fragment_budget_detail.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_budget_form.xml b/app/src/main/res/layout/fragment_budget_form.xml new file mode 100644 index 000000000..db920d07f --- /dev/null +++ b/app/src/main/res/layout/fragment_budget_form.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +