diff --git a/build.gradle b/build.gradle index 3df2d26b2..4cfcc4791 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,8 @@ subprojects { constraintlayoutVersion = '1.1.3' legacySupportV4Version = '1.0.0' recycleViewVersion = '1.0.0' + lifecycleExtensionsVersion = '2.0.0' + pagingRuntimeVersion = '2.1.0' //Testing extJunitVerson = '1.1.1' testRunnerVersion = '1.2.0' diff --git a/common/src/main/java/com/hyperwallet/android/common/util/DateUtils.java b/common/src/main/java/com/hyperwallet/android/common/util/DateUtils.java new file mode 100644 index 000000000..7fd65e5aa --- /dev/null +++ b/common/src/main/java/com/hyperwallet/android/common/util/DateUtils.java @@ -0,0 +1,123 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.common.util; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Common HW-SDK UI Date Utility class, that will assist on safe presentation of date whatever the mobile device setting + * is set Locale, Timezone and etc... that dictates how that dates are being presented + * + * Moreover all date string to {@link Date} object conversion is automatically converted from + * GMT date string from API to locale Date set by the phone + */ +public final class DateUtils { + + private static final String DATE_FORMAT = "yyyy-MM-dd"; + private static final String DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; + private static final String DATE_TIME_FORMAT_MILLISECONDS = "yyyy-MM-dd'T'HH:mm:ss.SSS"; + private static final TimeZone API_TIMEZONE = TimeZone.getTimeZone("GMT"); + + private DateUtils() { + } + + /** + * Creates a string date format: yyyy-MM-dd + * + * @param date Date object + * @return string date in yyyy-MM-dd format + */ + public static String toDateFormat(@NonNull final Date date) { + SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT, Locale.getDefault()); + dateFormat.setTimeZone(TimeZone.getDefault()); + return dateFormat.format(date); + } + + /** + * Creates a string date in specified format + * + * @param date Date object + * @param format specify desired format of date + * @return formatted date string based on format specified + */ + public static String toDateFormat(@NonNull final Date date, @NonNull final String format) { + SimpleDateFormat dateFormat = new SimpleDateFormat(format, Locale.getDefault()); + dateFormat.setTimeZone(TimeZone.getDefault()); + return dateFormat.format(date); + } + + /** + * Creates a string date format + * + * @param date Date object + * @return formatted string in yyyy-MM-dd'T'HH:mm:ss format + */ + public static String toDateTimeFormat(@NonNull final Date date) { + SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_TIME_FORMAT, Locale.getDefault()); + dateFormat.setTimeZone(TimeZone.getDefault()); + return dateFormat.format(date); + } + + /** + * Creates a string date format + * + * @param date Date object + * @return formatted string in yyyy-MM-dd'T'HH:mm:ss.SSS format + */ + public static String toDateTimeMillisFormat(@NonNull final Date date) { + SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_TIME_FORMAT_MILLISECONDS, Locale.getDefault()); + dateFormat.setTimeZone(TimeZone.getDefault()); + return dateFormat.format(date); + } + + /** + * Creates a Date object from string date using API Timezone + * + * @param dateString String date from API with GMT timezone + * @return date Date object converted to local timezone + * @throws IllegalArgumentException when string is un-parsable + */ + public static Date fromDateTimeString(@NonNull final String dateString) { + try { + SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_TIME_FORMAT, Locale.getDefault()); + dateFormat.setTimeZone(API_TIMEZONE); + return dateFormat.parse(dateString); + } catch (ParseException e) { + throw new IllegalArgumentException("An exception occurred when attempting to parse " + + "the date " + dateString, e); + } + } + + @VisibleForTesting + static Date fromDateTimeString(@NonNull final String dateString, @NonNull final String format) { + try { + SimpleDateFormat dateFormat = new SimpleDateFormat(format, Locale.getDefault()); + dateFormat.setTimeZone(API_TIMEZONE); + return dateFormat.parse(dateString); + } catch (ParseException e) { + throw new IllegalArgumentException("An exception occurred when attempting to parse " + + "the date " + dateString, e); + } + } +} diff --git a/ui/src/main/java/com/hyperwallet/android/ui/view/HorizontalDividerItemDecorator.java b/common/src/main/java/com/hyperwallet/android/common/view/HorizontalDividerItemDecorator.java similarity index 96% rename from ui/src/main/java/com/hyperwallet/android/ui/view/HorizontalDividerItemDecorator.java rename to common/src/main/java/com/hyperwallet/android/common/view/HorizontalDividerItemDecorator.java index 22b3b432a..3e9d3777f 100644 --- a/ui/src/main/java/com/hyperwallet/android/ui/view/HorizontalDividerItemDecorator.java +++ b/common/src/main/java/com/hyperwallet/android/common/view/HorizontalDividerItemDecorator.java @@ -14,7 +14,7 @@ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.hyperwallet.android.ui.view; +package com.hyperwallet.android.common.view; import android.content.Context; import android.graphics.Canvas; @@ -26,12 +26,12 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; -import com.hyperwallet.android.hyperwallet_ui.R; +import com.hyperwallet.android.common.R; public class HorizontalDividerItemDecorator extends RecyclerView.ItemDecoration { - private final Drawable mHorizontalItemDivider; - private final int mDefaultPadding; + protected final Drawable mHorizontalItemDivider; + protected final int mDefaultPadding; public HorizontalDividerItemDecorator(@NonNull final Context context, final boolean withHeaderDivider) { mHorizontalItemDivider = context.getResources().getDrawable(R.drawable.horizontal_divider, null); diff --git a/common/src/main/java/com/hyperwallet/android/common/viewmodel/Event.java b/common/src/main/java/com/hyperwallet/android/common/viewmodel/Event.java new file mode 100644 index 000000000..a07aab0e1 --- /dev/null +++ b/common/src/main/java/com/hyperwallet/android/common/viewmodel/Event.java @@ -0,0 +1,65 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.common.viewmodel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Class that represents {@link androidx.lifecycle.LiveData} event with content + */ +public class Event { + + private final T content; + private boolean mIsContentConsumed; + + public Event(@NonNull final T t) { + content = t; + } + + /** + * @return content of this event, will also mark {@link Event#mIsContentConsumed} to true + * that will also mean that {@link Event#getContentIfNotConsumed()} will also return true + */ + @NonNull + public T getContent() { + mIsContentConsumed = true; + return content; + } + + /** + * @return true if content assigned to event is already referenced + * from {@link Event#getContent()}; false otherwise. + */ + public boolean isContentConsumed() { + return mIsContentConsumed; + } + + /** + * Retrieve assigned content based on if and only if content has not been referenced from {@link Event#getContent()} + * + * @return content if content is not yet consumed; otherwise null + */ + @Nullable + public T getContentIfNotConsumed() { + if (!mIsContentConsumed) { + mIsContentConsumed = true; + return content; + } + return null; + } +} diff --git a/common/src/main/res/drawable/circle.xml b/common/src/main/res/drawable/circle.xml index b6e02a0bc..4a83f9077 100644 --- a/common/src/main/res/drawable/circle.xml +++ b/common/src/main/res/drawable/circle.xml @@ -3,7 +3,7 @@ - + diff --git a/common/src/main/res/drawable/circle_white.xml b/common/src/main/res/drawable/circle_white.xml index 237aac74a..def791761 100644 --- a/common/src/main/res/drawable/circle_white.xml +++ b/common/src/main/res/drawable/circle_white.xml @@ -4,11 +4,9 @@ - - - - - + + diff --git a/common/src/main/res/font/icomoon.ttf b/common/src/main/res/font/icomoon.ttf index f216ee945..384737f83 100644 Binary files a/common/src/main/res/font/icomoon.ttf and b/common/src/main/res/font/icomoon.ttf differ diff --git a/common/src/main/res/values/colors.xml b/common/src/main/res/values/colors.xml index 2d8989b26..39fdb8129 100644 --- a/common/src/main/res/values/colors.xml +++ b/common/src/main/res/values/colors.xml @@ -23,5 +23,6 @@ #E5F7FA + #737373 diff --git a/common/src/main/res/values/dimens.xml b/common/src/main/res/values/dimens.xml index 1a3f3fb32..db66a3e95 100644 --- a/common/src/main/res/values/dimens.xml +++ b/common/src/main/res/values/dimens.xml @@ -73,4 +73,6 @@ 4dp 6dp 32dp + 48dp + 3dp diff --git a/common/src/test/java/com/hyperwallet/android/common/util/DateUtilsTest.java b/common/src/test/java/com/hyperwallet/android/common/util/DateUtilsTest.java new file mode 100644 index 000000000..27102325b --- /dev/null +++ b/common/src/test/java/com/hyperwallet/android/common/util/DateUtilsTest.java @@ -0,0 +1,63 @@ +package com.hyperwallet.android.common.util; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Date; +import java.util.TimeZone; + +public class DateUtilsTest { + + @Before + public void setTestLocalTimezone() { + TimeZone.setDefault(TimeZone.getTimeZone("PST")); + } + + @Test + public void testToDateFormat_returnsExpectedStringFormat() { + String dateString = "2019-05-27"; + Date dateTarget = DateUtils.fromDateTimeString("2019-05-27T15:57:49"); + + // test + String targetDate = DateUtils.toDateFormat(dateTarget); + assertThat(targetDate, is(notNullValue())); + assertThat(targetDate, is(dateString)); + } + + @Test + public void testToDateFormat_returnsExpectedStringFormatFromParameter() { + String dateString = "November 2019"; + Date dateTarget = DateUtils.fromDateTimeString("2019-11-27T15:57:49"); + + // test + String targetDate = DateUtils.toDateFormat(dateTarget, "MMMM yyyy"); + assertThat(targetDate, is(notNullValue())); + assertThat(targetDate, is(dateString)); + } + + @Test + public void testToDateTimeFormat_returnsExpectedStringFormat() { + String localTime = "2019-11-27T07:57:00"; + Date dateTarget = DateUtils.fromDateTimeString("2019-11-27T15:57:00"); + + // test + String targetDate = DateUtils.toDateTimeFormat(dateTarget); + assertThat(targetDate, is(notNullValue())); + assertThat(targetDate, is(localTime)); + } + + @Test + public void testToDateTimeMillisFormat_returnsExpectedStringFormat() { + String localTime = "2019-11-27T07:57:00.000"; + Date dateTarget = DateUtils.fromDateTimeString("2019-11-27T15:57:00.000", "yyyy-MM-dd'T'HH:mm:ss.SSS"); + + // test + String targetDate = DateUtils.toDateTimeMillisFormat(dateTarget); + assertThat(targetDate, is(notNullValue())); + assertThat(targetDate, is(localTime)); + } +} diff --git a/receipt/build.gradle b/receipt/build.gradle index 39c963b04..6aa8d9d42 100644 --- a/receipt/build.gradle +++ b/receipt/build.gradle @@ -2,6 +2,22 @@ apply from: "$rootProject.projectDir/android-library.gradle" dependencies { api project(":common") + + implementation "com.google.android.material:material:$androidMaterialVersion" + implementation "androidx.constraintlayout:constraintlayout:$constraintlayoutVersion" + implementation "androidx.legacy:legacy-support-v4:$legacySupportV4Version" + implementation "androidx.recyclerview:recyclerview:$recycleViewVersion" + implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleExtensionsVersion" + implementation "androidx.paging:paging-runtime:$pagingRuntimeVersion" + + testImplementation "org.robolectric:robolectric:$robolectricVersion" + + androidTestImplementation "androidx.test:rules:$testRulesVersion" + androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion" + androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion" + androidTestImplementation "com.squareup.okhttp3:mockwebserver:$mockServerVersion" + androidTestImplementation "com.squareup.leakcanary:leakcanary-android-instrumentation:$leakcanaryVersion" + androidTestImplementation "com.squareup.leakcanary:leakcanary-support-fragment:$leakcanaryVersion" } def aarFile = file("$buildDir/outputs/aar/receipt-$version" + ".aar") diff --git a/receipt/src/androidTest/AndroidManifest.xml b/receipt/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..8495adb45 --- /dev/null +++ b/receipt/src/androidTest/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/receipt/src/androidTest/java/com/hyperwallet/android/HyperwalletInstrumentedTestApplication.java b/receipt/src/androidTest/java/com/hyperwallet/android/HyperwalletInstrumentedTestApplication.java new file mode 100644 index 000000000..dfc559d2e --- /dev/null +++ b/receipt/src/androidTest/java/com/hyperwallet/android/HyperwalletInstrumentedTestApplication.java @@ -0,0 +1,31 @@ +package com.hyperwallet.android; + + +import android.app.Application; + +import com.squareup.leakcanary.InstrumentationLeakDetector; +import com.squareup.leakcanary.LeakCanary; + +public class HyperwalletInstrumentedTestApplication extends Application { + + @Override + public void onCreate() { + + super.onCreate(); + if (LeakCanary.isInAnalyzerProcess(this)) { + // This process is dedicated to LeakCanary for heap analysis. + // You should not init your app in this process. + return; + } + installLeakCanary(); + } + + + protected void installLeakCanary() { + + InstrumentationLeakDetector.instrumentationRefWatcher(this) + .buildAndInstall(); + + } + +} \ No newline at end of file diff --git a/receipt/src/androidTest/java/com/hyperwallet/android/receipt/ListReceiptsTest.java b/receipt/src/androidTest/java/com/hyperwallet/android/receipt/ListReceiptsTest.java new file mode 100644 index 000000000..5eb430354 --- /dev/null +++ b/receipt/src/androidTest/java/com/hyperwallet/android/receipt/ListReceiptsTest.java @@ -0,0 +1,265 @@ +package com.hyperwallet.android.receipt; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withParent; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.instanceOf; + +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static java.net.HttpURLConnection.HTTP_OK; + +import static com.hyperwallet.android.util.EspressoUtils.atPosition; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.widget.TextView; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.rule.ActivityTestRule; + +import com.hyperwallet.android.Hyperwallet; +import com.hyperwallet.android.receipt.repository.ReceiptRepositoryFactory; +import com.hyperwallet.android.receipt.view.ListReceiptActivity; +import com.hyperwallet.android.rule.HyperwalletExternalResourceManager; +import com.hyperwallet.android.rule.HyperwalletMockWebServer; +import com.hyperwallet.android.util.RecyclerViewCountAssertion; +import com.hyperwallet.android.util.TestAuthenticationProvider; + +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Locale; + +@RunWith(AndroidJUnit4.class) +public class ListReceiptsTest { + + @ClassRule + public static HyperwalletExternalResourceManager sResourceManager = new HyperwalletExternalResourceManager(); + @Rule + public HyperwalletMockWebServer mMockWebServer = new HyperwalletMockWebServer(8080); + @Rule + public ActivityTestRule mActivityTestRule = + new ActivityTestRule<>(ListReceiptActivity.class, true, false); + + @Before + public void setup() { + Hyperwallet.getInstance(new TestAuthenticationProvider()); + + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_OK).withBody(sResourceManager + .getResourceContent("authentication_token_response.json")).mock(); + + setLocale(Locale.US); + } + + @After + public void cleanup() { + ReceiptRepositoryFactory.clearInstance(); + } + + @Test + public void testListReceipts_userHasMultipleTransactions() { + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_OK).withBody(sResourceManager + .getResourceContent("receipt_list_response.json")).mock(); + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_NO_CONTENT).withBody("").mock(); + + // run test + mActivityTestRule.launchActivity(null); + + // assert + onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.toolbar)))) + .check(matches(withText(R.string.title_activity_receipt_list))); + onView(withId(R.id.list_receipts)).check(matches(isDisplayed())); + + onView(withId(R.id.list_receipts)) + .check(matches(atPosition(0, hasDescendant(withText("June 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(0, + hasDescendant(withText(com.hyperwallet.android.receipt.R.string.credit))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText("Payment"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText("+ 20.00"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText("June 07, 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(0, hasDescendant(withText("USD"))))); + + onView(withId(R.id.list_receipts)).check(matches(atPosition(1, + hasDescendant(withText(com.hyperwallet.android.receipt.R.string.credit))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(1, hasDescendant(withText("Payment"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(1, hasDescendant(withText("+ 25.00"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(1, hasDescendant(withText("June 02, 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(1, hasDescendant(withText("CAD"))))); + + onView(withId(R.id.list_receipts)).check(matches(atPosition(2, + hasDescendant(withText(com.hyperwallet.android.receipt.R.string.debit))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(2, hasDescendant(withText("Card Activation Fee"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(2, hasDescendant(withText("- 1.95"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(2, hasDescendant(withText("June 01, 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(2, hasDescendant(withText("USD"))))); + + onView(withId(R.id.list_receipts)) + .check(matches(atPosition(3, hasDescendant(withText("December 2018"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(3, + hasDescendant(withText(com.hyperwallet.android.receipt.R.string.debit))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(3, hasDescendant(withText("Card Load"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(3, hasDescendant(withText("- 18.05"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(3, hasDescendant(withText("December 01, 2018"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(3, hasDescendant(withText("USD"))))); + + onView(withId(R.id.list_receipts)).check(new RecyclerViewCountAssertion(4)); + } + + @Test + public void testListReceipts_displayCreditTransaction() { + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_OK).withBody(sResourceManager + .getResourceContent("receipt_credit_response.json")).mock(); + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_NO_CONTENT).withBody("").mock(); + + // run test + mActivityTestRule.launchActivity(null); + + // assert + onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.toolbar)))) + .check(matches(withText(R.string.title_activity_receipt_list))); + onView(withId(R.id.list_receipts)).check(matches(isDisplayed())); + + onView(withId(R.id.list_receipts)) + .check(matches(atPosition(0, hasDescendant(withText("June 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(0, + hasDescendant(withText(com.hyperwallet.android.receipt.R.string.credit))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText(R.string.payment))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText("+ 25.00"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText("June 02, 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(0, hasDescendant(withText("CAD"))))); + + onView(withId(R.id.list_receipts)).check(new RecyclerViewCountAssertion(1)); + } + + @Test + public void testListReceipts_displayDebitTransaction() { + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_OK).withBody(sResourceManager + .getResourceContent("receipt_debit_response.json")).mock(); + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_NO_CONTENT).withBody("").mock(); + + // run test + mActivityTestRule.launchActivity(null); + + // assert + onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.toolbar)))) + .check(matches(withText(R.string.title_activity_receipt_list))); + onView(withId(R.id.list_receipts)).check(matches(isDisplayed())); + + onView(withId(R.id.list_receipts)) + .check(matches(atPosition(0, hasDescendant(withText("May 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(0, + hasDescendant(withText(com.hyperwallet.android.receipt.R.string.debit))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText(R.string.transfer_to_prepaid_card))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText("- 18.05"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText("May 02, 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(0, hasDescendant(withText("USD"))))); + + onView(withId(R.id.list_receipts)).check(new RecyclerViewCountAssertion(1)); + } + + @Test + public void testListReceipts_displayUnknownTransactionType() { + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_OK).withBody(sResourceManager + .getResourceContent("receipt_unknown_type_response.json")).mock(); + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_NO_CONTENT).withBody("").mock(); + + // run test + mActivityTestRule.launchActivity(null); + + // assert + onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.toolbar)))) + .check(matches(withText(R.string.title_activity_receipt_list))); + onView(withId(R.id.list_receipts)).check(matches(isDisplayed())); + + onView(withId(R.id.list_receipts)) + .check(matches(atPosition(0, hasDescendant(withText("June 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(0, + hasDescendant(withText(com.hyperwallet.android.receipt.R.string.credit))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText(R.string.unknown_type))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText("+ 25.00"))))); + onView(withId(R.id.list_receipts)).check( + matches(atPosition(0, hasDescendant(withText("June 02, 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(0, hasDescendant(withText("CAD"))))); + + onView(withId(R.id.list_receipts)).check(new RecyclerViewCountAssertion(1)); + } + + @Test + public void testListReceipt_userHasNoTransactions() { + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_NO_CONTENT).withBody("").mock(); + + // run test + mActivityTestRule.launchActivity(null); + + onView(withId(R.id.toolbar)).check(matches(isDisplayed())); + onView(allOf(instanceOf(TextView.class), withParent(withId(R.id.toolbar)))) + .check(matches(withText(R.string.title_activity_receipt_list))); + //todo: check empty view when it will be ready + } + + @Test + public void testListReceipts_checkDateTextOnLocaleChange() { + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_OK).withBody(sResourceManager + .getResourceContent("receipt_debit_response.json")).mock(); + mMockWebServer.mockResponse().withHttpResponseCode(HTTP_NO_CONTENT).withBody("").mock(); + + setLocale(Locale.ITALY); + // run test + mActivityTestRule.launchActivity(null); + // assert + onView(withId(R.id.list_receipts)) + .check(matches(atPosition(0, hasDescendant(withText("maggio 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(0, hasDescendant(withText("maggio 02, 2019"))))); + mActivityTestRule.finishActivity(); + setLocale(Locale.US); + mActivityTestRule.launchActivity(null); + onView(withId(R.id.list_receipts)) + .check(matches(atPosition(0, hasDescendant(withText("May 2019"))))); + onView(withId(R.id.list_receipts)).check(matches(atPosition(0, hasDescendant(withText("May 02, 2019"))))); + } + + private void setLocale(Locale locale) { + Context context = ApplicationProvider.getApplicationContext(); + Locale.setDefault(locale); + Resources resources = context.getResources(); + Configuration configuration = resources.getConfiguration(); + if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.JELLY_BEAN) { + configuration.setLocale(locale); + } else { + configuration.locale = locale; + } + resources.updateConfiguration(configuration, resources.getDisplayMetrics()); + } +} diff --git a/receipt/src/androidTest/java/com/hyperwallet/android/rule/HyperwalletExternalResourceManager.java b/receipt/src/androidTest/java/com/hyperwallet/android/rule/HyperwalletExternalResourceManager.java new file mode 100644 index 000000000..e7e546063 --- /dev/null +++ b/receipt/src/androidTest/java/com/hyperwallet/android/rule/HyperwalletExternalResourceManager.java @@ -0,0 +1,73 @@ +package com.hyperwallet.android.rule; + +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringWriter; +import java.io.Writer; +import java.net.URL; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class HyperwalletExternalResourceManager extends TestWatcher { + + private static final String EMPTY = ""; + private ClassLoader classLoader; + private Logger logger; + + @Override + protected void starting(Description description) { + super.starting(description); + classLoader = description.getTestClass().getClassLoader(); + logger = Logger.getLogger(description.getTestClass().getName()); + } + + public String getResourceContent(final String resourceName) { + if (resourceName == null) { + throw new IllegalArgumentException("Parameter resourceName cannot be null"); + } + + return getContent(resourceName); + } + + private String getContent(final String resourceName) { + + URL resource = classLoader.getResource(resourceName); + InputStream inputStream = null; + Writer writer = new StringWriter(); + String resourceContent = EMPTY; + if (resource != null) { + try { + inputStream = resource.openStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8")); + String line = reader.readLine(); + while (line != null) { + writer.write(line); + line = reader.readLine(); + } + resourceContent = writer.toString(); + + } catch (Exception e) { + logger.log(Level.WARNING, "There was an error loading an external resource", e); + } finally { + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (Exception e) { + logger.log(Level.SEVERE, "There was an error closing input stream", e); + } + try { + writer.close(); + } catch (IOException e) { + logger.log(Level.SEVERE, "There was an error closing writer", e); + } + } + } + return resourceContent; + } +} diff --git a/receipt/src/androidTest/java/com/hyperwallet/android/rule/HyperwalletMockWebServer.java b/receipt/src/androidTest/java/com/hyperwallet/android/rule/HyperwalletMockWebServer.java new file mode 100644 index 000000000..9f42f6128 --- /dev/null +++ b/receipt/src/androidTest/java/com/hyperwallet/android/rule/HyperwalletMockWebServer.java @@ -0,0 +1,115 @@ +package com.hyperwallet.android.rule; + +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +import java.io.IOException; +import java.net.HttpURLConnection; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +public final class HyperwalletMockWebServer extends TestWatcher { + + private MockWebServer mServer; + private int port; + + public HyperwalletMockWebServer(int port) { + this.port = port; + } + + @Override + protected void starting(Description description) { + super.starting(description); + mServer = new MockWebServer(); + try { + mServer.start(port); + } catch (IOException e) { + throw new IllegalStateException("Unable to start mock server", e); + } + } + + @Override + protected void finished(Description description) { + super.finished(description); + try { + mServer.shutdown(); + mServer.close(); + } catch (IOException e) { + throw new IllegalStateException("Un error occurred when shutting down mock server", e); + } + } + + public HyperwalletMockResponse mockResponse() { + return new Builder(mServer).build(); + } + + public MockWebServer getServer() { + return mServer; + } + + public static class HyperwalletMockResponse { + + private String path; + private String body; + private int httpResponseCode; + private Builder builder; + + HyperwalletMockResponse(Builder builder) { + this.path = builder.path; + this.httpResponseCode = builder.responseCode; + this.body = builder.body; + this.builder = builder; + } + + public HyperwalletMockResponse withHttpResponseCode(final int code) { + builder.responseCode(code); + return builder.build(); + } + + public HyperwalletMockResponse withBody(final String body) { + builder.body(body); + return builder.build(); + } + + public void mock() { + mockRequest(); + } + + private String mockRequest() { + builder.server.enqueue(new MockResponse().setResponseCode(httpResponseCode).setBody(body)); + return builder.server.url(path).toString(); + } + + } + + private static class Builder { + + private String path; + private String body; + private int responseCode; + private MockWebServer server; + + + Builder(final MockWebServer server) { + this.path = ""; + this.responseCode = HttpURLConnection.HTTP_OK; + this.body = ""; + this.server = server; + } + + Builder responseCode(final int code) { + this.responseCode = code; + return this; + } + + Builder body(final String body) { + this.body = body; + return this; + } + + HyperwalletMockResponse build() { + return new HyperwalletMockResponse(this); + } + } +} diff --git a/receipt/src/androidTest/java/com/hyperwallet/android/util/EspressoUtils.java b/receipt/src/androidTest/java/com/hyperwallet/android/util/EspressoUtils.java new file mode 100644 index 000000000..92a711f96 --- /dev/null +++ b/receipt/src/androidTest/java/com/hyperwallet/android/util/EspressoUtils.java @@ -0,0 +1,187 @@ +package com.hyperwallet.android.util; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.view.View; +import android.widget.EditText; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.action.ViewActions; +import androidx.test.espresso.matcher.BoundedMatcher; + +import com.google.android.material.textfield.TextInputLayout; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +import java.util.Objects; + +public class EspressoUtils { + + public static Matcher withHint(final String expectedHint) { + return new TypeSafeMatcher() { + + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof TextInputLayout)) { + return false; + } + + String hint = Objects.toString(((TextInputLayout) view).getHint()); + return expectedHint.equals(hint); + } + + @Override + public void describeTo(Description description) { + description.appendText(expectedHint); + } + }; + } + + public static Matcher hasErrorText(final String expectedErrorMessage) { + return new TypeSafeMatcher() { + + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof TextInputLayout)) { + return false; + } + + String errorMessage = Objects.toString(((TextInputLayout) view).getError()); + return expectedErrorMessage.equals(errorMessage); + } + + @Override + public void describeTo(Description description) { + description.appendText(expectedErrorMessage); + } + }; + } + + public static Matcher hasErrorText(final int resourceId) { + return new TypeSafeMatcher() { + + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof TextInputLayout)) { + return false; + } + String expectedErrorMessage = view.getResources().getString(resourceId); + String errorMessage = Objects.toString(((TextInputLayout) view).getError()); + + return expectedErrorMessage.equals(errorMessage); + } + + @Override + public void describeTo(Description description) { + } + }; + } + + public static Matcher withDrawable(final int resourceId) { + return new TypeSafeMatcher() { + + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof ImageView)) { + return false; + } + + Drawable drawable = ((ImageView) view).getDrawable(); + if (drawable == null) { + return false; + } + Drawable expectedDrawable = view.getContext().getResources().getDrawable(resourceId); + + Bitmap bitmap = getBitmap(drawable); + Bitmap expectedBitmap = getBitmap(expectedDrawable); + + return bitmap.sameAs(expectedBitmap); + } + + @Override + public void describeTo(Description description) { + } + }; + } + + public static Matcher atPosition(final int position, @NonNull final Matcher matcher) { + return new BoundedMatcher(RecyclerView.class) { + + @Override + protected boolean matchesSafely(final RecyclerView view) { + RecyclerView.ViewHolder viewHolder = view.findViewHolderForAdapterPosition(position); + + if (viewHolder == null) { + return false; + } + + return matcher.matches(viewHolder.itemView); + } + + @Override + public void describeTo(Description description) { + description.appendText("has item at position " + position + ": "); + matcher.describeTo(description); + } + }; + } + + public static ViewAction nestedScrollTo() { + return ViewActions.actionWithAssertions(new NestedScrollToAction()); + } + + private static Bitmap getBitmap(Drawable drawable) { + Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), + drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + return bitmap; + } + + public static Matcher hasNoErrorText() { + return new TypeSafeMatcher() { + + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof TextInputLayout)) { + return false; + } + return ((TextInputLayout) view).getError() == null; + } + + @Override + public void describeTo(Description description) { + description.appendText("has no error text: "); + } + }; + } + + public static Matcher hasEmptyText() { + return new TypeSafeMatcher() { + + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof EditText)) { + return false; + } + String text = ((EditText) view).getText().toString(); + + return text.isEmpty(); + } + + @Override + public void describeTo(Description description) { + } + }; + } +} + diff --git a/receipt/src/androidTest/java/com/hyperwallet/android/util/NestedScrollToAction.java b/receipt/src/androidTest/java/com/hyperwallet/android/util/NestedScrollToAction.java new file mode 100644 index 000000000..392867387 --- /dev/null +++ b/receipt/src/androidTest/java/com/hyperwallet/android/util/NestedScrollToAction.java @@ -0,0 +1,41 @@ +package com.hyperwallet.android.util; + +import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; + +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; + +import android.view.View; + +import androidx.core.widget.NestedScrollView; +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.action.ScrollToAction; +import androidx.test.espresso.matcher.ViewMatchers; + +import org.hamcrest.Matcher; + +public class NestedScrollToAction implements ViewAction { + private static final String TAG = ScrollToAction.class.getSimpleName(); + + @SuppressWarnings("unchecked") + @Override + public Matcher getConstraints() { + return allOf( + withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), + isDescendantOfA( + anyOf(isAssignableFrom(NestedScrollView.class)))); + } + + @Override + public void perform(UiController uiController, View view) { + new ScrollToAction().perform(uiController, view); + } + + @Override + public String getDescription() { + return "scroll to"; + } +} diff --git a/receipt/src/androidTest/java/com/hyperwallet/android/util/RecyclerViewCountAssertion.java b/receipt/src/androidTest/java/com/hyperwallet/android/util/RecyclerViewCountAssertion.java new file mode 100644 index 000000000..bb3aecaff --- /dev/null +++ b/receipt/src/androidTest/java/com/hyperwallet/android/util/RecyclerViewCountAssertion.java @@ -0,0 +1,30 @@ +package com.hyperwallet.android.util; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.espresso.ViewAssertion; + +public class RecyclerViewCountAssertion implements ViewAssertion { + private final int mCount; + + public RecyclerViewCountAssertion(int count) { + this.mCount = count; + } + + @Override + public void check(View view, NoMatchingViewException noViewFoundException) { + if (noViewFoundException != null) { + throw noViewFoundException; + } + + RecyclerView recyclerView = (RecyclerView) view; + RecyclerView.Adapter adapter = recyclerView.getAdapter(); + + assertThat(adapter.getItemCount(), is(mCount)); + } +} diff --git a/receipt/src/androidTest/java/com/hyperwallet/android/util/TestAuthenticationProvider.java b/receipt/src/androidTest/java/com/hyperwallet/android/util/TestAuthenticationProvider.java new file mode 100644 index 000000000..686ccbf30 --- /dev/null +++ b/receipt/src/androidTest/java/com/hyperwallet/android/util/TestAuthenticationProvider.java @@ -0,0 +1,51 @@ +package com.hyperwallet.android.util; + +import com.hyperwallet.android.HyperwalletAuthenticationTokenListener; +import com.hyperwallet.android.HyperwalletAuthenticationTokenProvider; + +import java.io.IOException; +import java.text.MessageFormat; +import java.util.UUID; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +public class TestAuthenticationProvider implements HyperwalletAuthenticationTokenProvider { + + public static final MediaType JSON + = MediaType.get("application/json; charset=utf-8"); + private static final String mBaseUrl = "http://localhost:8080/rest/v3/users/{0}/authentication-token"; + private static final String mUserToken = "user_token"; + + @Override + public void retrieveAuthenticationToken(final HyperwalletAuthenticationTokenListener authenticationTokenListener) { + + OkHttpClient client = new OkHttpClient(); + + String payload = "{}"; + String baseUrl = MessageFormat.format(mBaseUrl, mUserToken); + + RequestBody body = RequestBody.create(JSON, payload); + Request request = new Request.Builder() + .url(baseUrl) + .post(body) + .build(); + + client.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(Call call, IOException e) { + authenticationTokenListener.onFailure(UUID.randomUUID(), e.getMessage()); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + authenticationTokenListener.onSuccess(response.body().string()); + } + }); + } +} diff --git a/receipt/src/main/AndroidManifest.xml b/receipt/src/main/AndroidManifest.xml index bf6f4ad0c..3039f6d18 100644 --- a/receipt/src/main/AndroidManifest.xml +++ b/receipt/src/main/AndroidManifest.xml @@ -1,2 +1,13 @@ - + + + + + + + + + + \ No newline at end of file diff --git a/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptDataSource.java b/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptDataSource.java new file mode 100644 index 000000000..5320214fb --- /dev/null +++ b/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptDataSource.java @@ -0,0 +1,189 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.receipt.repository; + +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.paging.PageKeyedDataSource; + +import com.hyperwallet.android.Hyperwallet; +import com.hyperwallet.android.common.viewmodel.Event; +import com.hyperwallet.android.exception.HyperwalletException; +import com.hyperwallet.android.listener.HyperwalletListener; +import com.hyperwallet.android.model.HyperwalletErrors; +import com.hyperwallet.android.model.paging.HyperwalletPageList; +import com.hyperwallet.android.model.receipt.Receipt; +import com.hyperwallet.android.model.receipt.ReceiptQueryParam; + +import java.util.Calendar; + +/** + * ReceiptDataSource mediates communication to HW API Platform particularly on + * Receipts V3 API + */ +public class ReceiptDataSource extends PageKeyedDataSource { + + private static final int YEAR_BEFORE_NOW = -1; + private final Calendar mCalendarYearBeforeNow; + private final MutableLiveData> mErrors = new MutableLiveData<>(); + private final MutableLiveData mIsFetchingData = new MutableLiveData<>(); + private LoadInitialCallback mLoadInitialCallback; + private LoadInitialParams mLoadInitialParams; + private LoadCallback mLoadAfterCallback; + private LoadParams mLoadAfterParams; + + ReceiptDataSource() { + super(); + mCalendarYearBeforeNow = Calendar.getInstance(); + mCalendarYearBeforeNow.add(Calendar.YEAR, YEAR_BEFORE_NOW); + } + + /** + * @see {@link PageKeyedDataSource#loadInitial(LoadInitialParams, LoadInitialCallback)} + */ + @Override + public void loadInitial(@NonNull final LoadInitialParams params, + @NonNull final LoadInitialCallback callback) { + mLoadInitialCallback = callback; + mLoadInitialParams = params; + mIsFetchingData.postValue(Boolean.TRUE); + + ReceiptQueryParam queryParam = new ReceiptQueryParam.Builder() + .createdAfter(mCalendarYearBeforeNow.getTime()) + .limit(params.requestedLoadSize) + .sortByCreatedOnDesc().build(); + + getHyperwallet().listReceipts(queryParam, + new HyperwalletListener>() { + @Override + public void onSuccess(@Nullable HyperwalletPageList result) { + mIsFetchingData.postValue(Boolean.FALSE); + mErrors.postValue(null); + + if (result != null) { + int next = result.getLimit() + result.getOffset(); + int previous = 0; + callback.onResult(result.getDataList(), previous, next); + } + // reset + mLoadInitialCallback = null; + mLoadInitialParams = null; + } + + @Override + public void onFailure(HyperwalletException exception) { + mIsFetchingData.postValue(Boolean.FALSE); + mErrors.postValue(new Event<>(exception.getHyperwalletErrors())); + } + + @Override + public Handler getHandler() { + return null; + } + }); + } + + /** + * Unused in this case + * + * @see {@link PageKeyedDataSource#loadBefore(LoadParams, LoadCallback)} + */ + @Override + public void loadBefore(@NonNull LoadParams params, + @NonNull LoadCallback callback) { + } + + /** + * @see {@link PageKeyedDataSource#loadAfter(LoadParams, LoadCallback)} + * */ + @Override + public void loadAfter(@NonNull LoadParams params, + final @NonNull LoadCallback callback) { + mLoadInitialCallback = null; + mLoadInitialParams = null; + mLoadAfterCallback = callback; + mLoadAfterParams = params; + mIsFetchingData.postValue(Boolean.TRUE); + + ReceiptQueryParam queryParam = new ReceiptQueryParam.Builder() + .createdAfter(mCalendarYearBeforeNow.getTime()) + .limit(params.requestedLoadSize) + .offset(params.key) + .sortByCreatedOnDesc().build(); + + getHyperwallet().listReceipts(queryParam, + new HyperwalletListener>() { + @Override + public void onSuccess(@Nullable HyperwalletPageList result) { + mIsFetchingData.postValue(Boolean.FALSE); + mErrors.postValue(null); + + if (result != null) { + int next = result.getLimit() + result.getOffset(); + callback.onResult(result.getDataList(), next); + } + + // reset + mLoadAfterCallback = null; + mLoadAfterParams = null; + } + + @Override + public void onFailure(HyperwalletException exception) { + mIsFetchingData.postValue(Boolean.FALSE); + mErrors.postValue(new Event<>(exception.getHyperwalletErrors())); + } + + @Override + public Handler getHandler() { + return null; + } + }); + } + + /** + * Facilitates retry when network is down; any error that we can have a retry operation + * */ + void retry() { + if (mLoadInitialCallback != null) { + loadInitial(mLoadInitialParams, mLoadInitialCallback); + } else if (mLoadAfterCallback != null) { + loadAfter(mLoadAfterParams, mLoadAfterCallback); + } + } + + /** + * Retrieve reference of Hyperwallet errors inorder for consumers to observe on data changes + * + * @return Live event data of {@link HyperwalletErrors} + * */ + public LiveData> getErrors() { + return mErrors; + } + + LiveData isFetchingData() { + return mIsFetchingData; + } + + Hyperwallet getHyperwallet() { + return Hyperwallet.getDefault(); + } +} diff --git a/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptDataSourceFactory.java b/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptDataSourceFactory.java new file mode 100644 index 000000000..a2b81216b --- /dev/null +++ b/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptDataSourceFactory.java @@ -0,0 +1,54 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.receipt.repository; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.paging.DataSource; + +/** + * Data source factory that uses {@link DataSource.Factory} facility + */ +public class ReceiptDataSourceFactory extends DataSource.Factory { + + private final MutableLiveData mDataSourceMutableLiveData; + private final ReceiptDataSource mReceiptDataSource; + + ReceiptDataSourceFactory() { + super(); + mReceiptDataSource = new ReceiptDataSource(); + mDataSourceMutableLiveData = new MutableLiveData<>(); + mDataSourceMutableLiveData.setValue(mReceiptDataSource); + } + + /** + * Returns observable members of receipt data source + */ + LiveData getReceiptDataSource() { + return mDataSourceMutableLiveData; + } + + /** + * @see {@link DataSource.Factory#create()} + */ + @NonNull + @Override + public DataSource create() { + return mReceiptDataSource; + } +} diff --git a/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptRepository.java b/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptRepository.java new file mode 100644 index 000000000..542d03c89 --- /dev/null +++ b/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptRepository.java @@ -0,0 +1,58 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.receipt.repository; + +import androidx.lifecycle.LiveData; +import androidx.paging.PagedList; + +import com.hyperwallet.android.common.viewmodel.Event; +import com.hyperwallet.android.model.HyperwalletErrors; +import com.hyperwallet.android.model.receipt.Receipt; + +/** + * Receipt Repository Contract + */ +public interface ReceiptRepository { + + /** + * Load receipts information, consumer can subscribe to receipts data changes events + * + * @return live data paged receipts + */ + LiveData> loadReceipts(); + + /** + * Loading indicator consumer can subscribe to loading of data events + * + * @return live data true if load receipt is in loading state; false otherwise + */ + LiveData isLoading(); + + /** + * Error information, consumer can subscribe of errors occur during data retrieval + * + * @return live event data list of errors if there's an error + */ + LiveData> getErrors(); + + /** + * Reload receipt information, usually invoked when error is raised after the first load and consumer opts to retry + * the last operation + */ + void retryLoadReceipt(); + +} diff --git a/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptRepositoryFactory.java b/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptRepositoryFactory.java new file mode 100644 index 000000000..b096ab7a0 --- /dev/null +++ b/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptRepositoryFactory.java @@ -0,0 +1,56 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.receipt.repository; + +/** + * {@link ReceiptRepository} factory + */ +public class ReceiptRepositoryFactory { + + private static ReceiptRepositoryFactory sInstance; + private final ReceiptRepository mReceiptRepository; + + private ReceiptRepositoryFactory() { + mReceiptRepository = new ReceiptRepositoryImpl(); + } + + /** + * Creates context single instance of this Factory + * + * @return receipt repository factory instance + */ + public static synchronized ReceiptRepositoryFactory getInstance() { + if (sInstance == null) { + sInstance = new ReceiptRepositoryFactory(); + } + return sInstance; + } + + /** + * Clears instance of repository factory + */ + public static void clearInstance() { + sInstance = null; + } + + /** + * @return ReceiptRepository instance implementation + * */ + public ReceiptRepository getReceiptRepository() { + return mReceiptRepository; + } +} diff --git a/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptRepositoryImpl.java b/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptRepositoryImpl.java new file mode 100644 index 000000000..cde7edd94 --- /dev/null +++ b/receipt/src/main/java/com/hyperwallet/android/receipt/repository/ReceiptRepositoryImpl.java @@ -0,0 +1,93 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.receipt.repository; + +import androidx.lifecycle.LiveData; +import androidx.paging.LivePagedListBuilder; +import androidx.paging.PagedList; + +import com.hyperwallet.android.common.viewmodel.Event; +import com.hyperwallet.android.model.HyperwalletErrors; +import com.hyperwallet.android.model.receipt.Receipt; + +/** + * {@link ReceiptRepository} implementation + */ +public class ReceiptRepositoryImpl implements ReceiptRepository { + + private static final int PAGE_SIZE = 10; + private static final int INITIAL_LOAD_SIZE = 20; + + private final ReceiptDataSourceFactory mDataSourceFactory; + private final LiveData mReceiptDataSourceLiveData; + private LiveData> mErrorsLiveData; + private LiveData mIsFetchingData; + private LiveData> mReceiptsLiveData; + + ReceiptRepositoryImpl() { + mDataSourceFactory = new ReceiptDataSourceFactory(); + mReceiptDataSourceLiveData = mDataSourceFactory.getReceiptDataSource(); + } + + /** + * @see {@link ReceiptRepository#loadReceipts()} + */ + @Override + public LiveData> loadReceipts() { + if (mReceiptsLiveData == null) { + PagedList.Config config = new PagedList.Config.Builder() + .setPageSize(PAGE_SIZE) + .setEnablePlaceholders(true) + .setInitialLoadSizeHint(INITIAL_LOAD_SIZE) + .build(); + mReceiptsLiveData = new LivePagedListBuilder<>(mDataSourceFactory, config).build(); + } + return mReceiptsLiveData; + } + + /** + * @see {@link ReceiptRepository#isLoading()} + */ + @Override + public LiveData isLoading() { + if (mIsFetchingData == null) { + mIsFetchingData = mReceiptDataSourceLiveData.getValue().isFetchingData(); + } + return mIsFetchingData; + } + + /** + * @see {@link ReceiptRepository#getErrors()} + * */ + @Override + public LiveData> getErrors() { + if (mErrorsLiveData == null) { + mErrorsLiveData = mReceiptDataSourceLiveData.getValue().getErrors(); + } + return mErrorsLiveData; + } + + /** + * @see {@link ReceiptRepository#retryLoadReceipt()} + * */ + @Override + public void retryLoadReceipt() { + if (mReceiptDataSourceLiveData.getValue() != null) { + mReceiptDataSourceLiveData.getValue().retry(); + } + } +} diff --git a/receipt/src/main/java/com/hyperwallet/android/receipt/view/ListReceiptActivity.java b/receipt/src/main/java/com/hyperwallet/android/receipt/view/ListReceiptActivity.java new file mode 100644 index 000000000..e7f57c821 --- /dev/null +++ b/receipt/src/main/java/com/hyperwallet/android/receipt/view/ListReceiptActivity.java @@ -0,0 +1,133 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.receipt.view; + +import android.content.Intent; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; + +import com.hyperwallet.android.common.view.error.DefaultErrorDialogFragment; +import com.hyperwallet.android.common.view.error.OnNetworkErrorCallback; +import com.hyperwallet.android.common.viewmodel.Event; +import com.hyperwallet.android.model.HyperwalletError; +import com.hyperwallet.android.model.HyperwalletErrors; +import com.hyperwallet.android.receipt.R; +import com.hyperwallet.android.receipt.repository.ReceiptRepositoryFactory; +import com.hyperwallet.android.receipt.viewmodel.ListReceiptViewModel; + +import java.util.List; + +public class ListReceiptActivity extends AppCompatActivity implements OnNetworkErrorCallback { + + private ListReceiptViewModel mListReceiptViewModel; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_list_receipt); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowHomeEnabled(true); + getSupportActionBar().setTitle(R.string.title_activity_receipt_list); + toolbar.setNavigationOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + finish(); + } + }); + + ReceiptRepositoryFactory factory = ReceiptRepositoryFactory.getInstance(); + mListReceiptViewModel = ViewModelProviders.of(this, new ListReceiptViewModel + .ListReceiptViewModelFactory(factory.getReceiptRepository())) + .get(ListReceiptViewModel.class); + + mListReceiptViewModel.getReceiptErrors().observe(this, new Observer>() { + @Override + public void onChanged(Event event) { + if (event != null && !event.isContentConsumed()) { + showErrorOnLoadReceipt(event.getContent().getErrors()); + } + } + }); + + if (savedInstanceState == null) { + initFragment(ListReceiptFragment.newInstance()); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + ReceiptRepositoryFactory.clearInstance(); + } + + @Override + protected void onActivityResult(final int requestCode, final int resultCode, @Nullable final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } + + private void initFragment(@NonNull final Fragment fragment) { + FragmentManager fragmentManager = getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + fragmentTransaction.add(R.id.list_receipt_fragment, fragment); + fragmentTransaction.commit(); + } + + @Override + public void retry() { + FragmentManager fragmentManager = getSupportFragmentManager(); + ListReceiptFragment fragment = (ListReceiptFragment) + fragmentManager.findFragmentById(R.id.list_receipt_fragment); + + if (fragment == null) { + fragment = ListReceiptFragment.newInstance(); + } + fragment.retry(); + } + + private void showErrorOnLoadReceipt(@NonNull final List errors) { + FragmentManager fragmentManager = getSupportFragmentManager(); + DefaultErrorDialogFragment fragment = (DefaultErrorDialogFragment) + fragmentManager.findFragmentByTag(DefaultErrorDialogFragment.TAG); + + if (fragment == null) { + fragment = DefaultErrorDialogFragment.newInstance(errors); + } + + if (!fragment.isAdded()) { + fragment.show(fragmentManager); + } + } +} diff --git a/receipt/src/main/java/com/hyperwallet/android/receipt/view/ListReceiptFragment.java b/receipt/src/main/java/com/hyperwallet/android/receipt/view/ListReceiptFragment.java new file mode 100644 index 000000000..ce6f9fd4d --- /dev/null +++ b/receipt/src/main/java/com/hyperwallet/android/receipt/view/ListReceiptFragment.java @@ -0,0 +1,264 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.receipt.view; + +import static com.hyperwallet.android.model.receipt.Receipt.Entries.CREDIT; +import static com.hyperwallet.android.model.receipt.Receipt.Entries.DEBIT; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProviders; +import androidx.paging.PagedList; +import androidx.paging.PagedListAdapter; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.hyperwallet.android.common.util.DateUtils; +import com.hyperwallet.android.model.receipt.Receipt; +import com.hyperwallet.android.receipt.R; +import com.hyperwallet.android.receipt.viewmodel.ListReceiptViewModel; + +import java.util.Calendar; +import java.util.Locale; +import java.util.Objects; + +public class ListReceiptFragment extends Fragment { + + private ListReceiptAdapter mListReceiptAdapter; + private RecyclerView mListReceiptsView; + private ListReceiptViewModel mListReceiptViewModel; + private View mProgressBar; + + /** + * Please don't use this constructor this is reserved for Android Core Framework + * + * @see {@link ListReceiptFragment#newInstance()} instead. + */ + public ListReceiptFragment() { + setRetainInstance(true); + } + + static ListReceiptFragment newInstance() { + ListReceiptFragment fragment = new ListReceiptFragment(); + return fragment; + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mListReceiptViewModel = ViewModelProviders.of(requireActivity()).get( + ListReceiptViewModel.class); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.list_receipt_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mProgressBar = view.findViewById(R.id.list_receipt_progress_bar); + mListReceiptAdapter = new ListReceiptAdapter(new ListReceiptItemDiffCallback()); + mListReceiptsView = view.findViewById(R.id.list_receipts); + mListReceiptsView.setHasFixedSize(true); + mListReceiptsView.setLayoutManager(new LinearLayoutManager(getActivity())); + mListReceiptsView.addItemDecoration(new ReceiptItemDividerDecorator(requireContext(), false)); + mListReceiptsView.setAdapter(mListReceiptAdapter); + registerObservers(); + } + + private void registerObservers() { + mListReceiptViewModel.getReceiptList().observe(getViewLifecycleOwner(), new Observer>() { + @Override + public void onChanged(PagedList transferMethods) { + mListReceiptAdapter.submitList(transferMethods); + } + }); + + mListReceiptViewModel.isLoadingData().observe(getViewLifecycleOwner(), new Observer() { + @Override + public void onChanged(Boolean loading) { + if (loading) { + mProgressBar.setVisibility(View.VISIBLE); + } else { + mProgressBar.setVisibility(View.GONE); + } + } + }); + } + + void retry() { + mListReceiptViewModel.retryLoadReceipts(); + } + + private static class ListReceiptItemDiffCallback extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull final Receipt oldItem, @NonNull final Receipt newItem) { + return oldItem.hashCode() == newItem.hashCode() + && Objects.equals(oldItem, newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull final Receipt oldItem, @NonNull final Receipt newItem) { + return oldItem.hashCode() == newItem.hashCode() + && Objects.equals(oldItem, newItem); + } + } + + private static class ListReceiptAdapter + extends PagedListAdapter { + + private static final String HEADER_DATE_FORMAT = "MMMM yyyy"; + private static final String CAPTION_DATE_FORMAT = "MMMM dd, yyyy"; + private static final int HEADER_VIEW_TYPE = 1; + private static final int DATA_VIEW_TYPE = 0; + + ListReceiptAdapter(@NonNull final DiffUtil.ItemCallback diffCallback) { + super(diffCallback); + } + + @Override + public int getItemViewType(final int position) { + if (position != 0) { + Receipt previous = getItem(position - 1); + Receipt current = getItem(position); + if (isDataViewType(previous, current)) { + return DATA_VIEW_TYPE; + } + } + return HEADER_VIEW_TYPE; + } + + boolean isDataViewType(@NonNull final Receipt previous, @NonNull final Receipt current) { + Calendar prev = Calendar.getInstance(); + prev.setTime(DateUtils.fromDateTimeString(previous.getCreatedOn())); + Calendar curr = Calendar.getInstance(); + curr.setTime(DateUtils.fromDateTimeString(current.getCreatedOn())); + + return prev.get(Calendar.MONTH) == curr.get(Calendar.MONTH) + && prev.get(Calendar.YEAR) == curr.get(Calendar.YEAR); + } + + @NonNull + @Override + public ReceiptViewHolder onCreateViewHolder(final @NonNull ViewGroup viewGroup, int viewType) { + LayoutInflater layout = LayoutInflater.from(viewGroup.getContext()); + + if (viewType == HEADER_VIEW_TYPE) { + View headerView = layout.inflate(R.layout.item_receipt_with_header, viewGroup, false); + return new ReceiptViewHolderWithHeader(headerView); + } + View dataView = layout.inflate(R.layout.item_receipt, viewGroup, false); + return new ReceiptViewHolder(dataView); + } + + @Override + public void onBindViewHolder(@NonNull final ReceiptViewHolder holder, final int position) { + final Receipt receipt = getItem(position); + if (receipt != null) { + holder.bind(receipt); + } + } + + class ReceiptViewHolder extends RecyclerView.ViewHolder { + private final TextView mTransactionAmount; + private final TextView mTransactionCurrency; + private final TextView mTransactionDate; + private final TextView mTransactionTitle; + private final TextView mTransactionTypeIcon; + + ReceiptViewHolder(@NonNull final View item) { + super(item); + mTransactionAmount = item.findViewById(R.id.transaction_amount); + mTransactionCurrency = item.findViewById(R.id.transaction_currency); + mTransactionDate = item.findViewById(R.id.transaction_date); + mTransactionTitle = item.findViewById(R.id.transaction_title); + mTransactionTypeIcon = item.findViewById(R.id.transaction_type_icon); + } + + void bind(@NonNull final Receipt receipt) { + if (CREDIT.equals(receipt.getEntry())) { + mTransactionAmount.setTextColor(mTransactionAmount.getContext() + .getResources().getColor(R.color.positiveColor)); + mTransactionAmount.setText(mTransactionAmount.getContext() + .getString(R.string.credit_sign, receipt.getAmount())); + mTransactionTypeIcon.setTextColor(mTransactionTypeIcon.getContext() + .getResources().getColor(R.color.positiveColor)); + mTransactionTypeIcon.setBackground(mTransactionTypeIcon.getContext() + .getDrawable(R.drawable.circle_positive)); + mTransactionTypeIcon.setText(mTransactionTypeIcon.getContext().getText(R.string.credit)); + } else if (DEBIT.equals(receipt.getEntry())) { + mTransactionAmount.setTextColor(mTransactionAmount.getContext() + .getResources().getColor(R.color.colorAccent)); + mTransactionAmount.setText(mTransactionAmount.getContext() + .getString(R.string.debit_sign, receipt.getAmount())); + mTransactionTypeIcon.setTextColor(mTransactionTypeIcon.getContext() + .getResources().getColor(R.color.colorAccent)); + mTransactionTypeIcon.setBackground(mTransactionTypeIcon.getContext() + .getDrawable(R.drawable.circle_negative)); + mTransactionTypeIcon.setText(mTransactionTypeIcon.getContext().getText(R.string.debit)); + } + + mTransactionCurrency.setText(receipt.getCurrency()); + mTransactionTitle.setText(getTransactionTitle(receipt.getType(), mTransactionTitle.getContext())); + mTransactionDate.setText(DateUtils.toDateFormat(DateUtils. + fromDateTimeString(receipt.getCreatedOn()), CAPTION_DATE_FORMAT)); + } + + String getTransactionTitle(@NonNull final String receiptType, @NonNull final Context context) { + String showTitle = context.getResources().getString(R.string.unknown_type); + int resourceId = context.getResources().getIdentifier(receiptType.toLowerCase(Locale.ROOT), "string", + context.getPackageName()); + if (resourceId != 0) { + showTitle = context.getResources().getString(resourceId); + } + + return showTitle; + } + } + + class ReceiptViewHolderWithHeader extends ReceiptViewHolder { + + private final TextView mTransactionHeaderText; + + ReceiptViewHolderWithHeader(@NonNull final View item) { + super(item); + mTransactionHeaderText = item.findViewById(R.id.item_date_header_title); + } + + @Override + void bind(@NonNull final Receipt receipt) { + super.bind(receipt); + mTransactionHeaderText.setText(DateUtils.toDateFormat( + DateUtils.fromDateTimeString(receipt.getCreatedOn()), HEADER_DATE_FORMAT)); + } + } + } +} diff --git a/receipt/src/main/java/com/hyperwallet/android/receipt/view/ReceiptItemDividerDecorator.java b/receipt/src/main/java/com/hyperwallet/android/receipt/view/ReceiptItemDividerDecorator.java new file mode 100644 index 000000000..c61b22e5d --- /dev/null +++ b/receipt/src/main/java/com/hyperwallet/android/receipt/view/ReceiptItemDividerDecorator.java @@ -0,0 +1,102 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.receipt.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.hyperwallet.android.common.view.HorizontalDividerItemDecorator; + +public class ReceiptItemDividerDecorator extends HorizontalDividerItemDecorator { + + ReceiptItemDividerDecorator(@NonNull final Context context, final boolean withHeaderDivider) { + super(context, withHeaderDivider); + } + + @Override + public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { + int right = parent.getWidth() - parent.getPaddingRight(); + + int childCount = parent.getChildCount(); + for (int i = 0; i < childCount; i++) { + int left = 0; + int top; + int bottom; + View child = parent.getChildAt(i); + RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams(); + + if (i == 0) { // first + // draw top + top = child.getTop() + params.topMargin; + bottom = top + mHorizontalItemDivider.getIntrinsicHeight(); + mHorizontalItemDivider.setBounds(left, top, right, bottom); + mHorizontalItemDivider.draw(c); + + if (childCount > 1) { // draw bottom + if (child instanceof LinearLayout) { // receipt header + // peek if next is a header then draw line from beginning + if (parent.getChildAt(i + 1) != null + && parent.getChildAt(i + 1) instanceof LinearLayout) { + left = 0; + } else { + left = ((ViewGroup) ((ViewGroup) child).getChildAt(1)).getChildAt(1).getLeft(); + } + } else { // receipt item + // peek if next is a header then draw line from beginning + if (parent.getChildAt(i + 1) != null + && parent.getChildAt(i + 1) instanceof LinearLayout) { + left = 0; + } else { + left = ((ViewGroup) child).getChildAt(1).getLeft(); + } + } + } + top = child.getBottom() + params.bottomMargin; + } else if (i == parent.getChildCount() - 1) { // draw bottom + top = child.getBottom() + params.bottomMargin; + } else { //draw middle + if (child instanceof LinearLayout) { // header found + // peek if next is a header then draw line from beginning + if (parent.getChildAt(i + 1) != null + && parent.getChildAt(i + 1) instanceof LinearLayout) { + left = 0; + } else { + left = ((ViewGroup) ((ViewGroup) child).getChildAt(1)).getChildAt(1).getLeft(); + } + } else { // non header + // peek if next is a header then draw line from beginning + if (parent.getChildAt(i + 1) != null + && parent.getChildAt(i + 1) instanceof LinearLayout) { + left = 0; + } else { + left = ((ViewGroup) child).getChildAt(1).getLeft(); + } + } + top = child.getBottom() + params.bottomMargin; + } + bottom = top + mHorizontalItemDivider.getIntrinsicHeight(); + mHorizontalItemDivider.setBounds(left, top, right, bottom); + mHorizontalItemDivider.draw(c); + } + } +} diff --git a/receipt/src/main/java/com/hyperwallet/android/receipt/viewmodel/ListReceiptViewModel.java b/receipt/src/main/java/com/hyperwallet/android/receipt/viewmodel/ListReceiptViewModel.java new file mode 100644 index 000000000..57f41b5c1 --- /dev/null +++ b/receipt/src/main/java/com/hyperwallet/android/receipt/viewmodel/ListReceiptViewModel.java @@ -0,0 +1,93 @@ +/* + * The MIT License (MIT) + * Copyright (c) 2019 Hyperwallet Systems Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.hyperwallet.android.receipt.viewmodel; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; +import androidx.paging.PagedList; + +import com.hyperwallet.android.common.viewmodel.Event; +import com.hyperwallet.android.model.HyperwalletErrors; +import com.hyperwallet.android.model.receipt.Receipt; +import com.hyperwallet.android.receipt.repository.ReceiptRepository; + +public class ListReceiptViewModel extends ViewModel { + + private MutableLiveData> mErrorEvent = new MutableLiveData<>(); + private Observer> mErrorEventObserver; + private ReceiptRepository mReceiptRepository; + + private ListReceiptViewModel(@NonNull final ReceiptRepository receiptRepository) { + mReceiptRepository = receiptRepository; + // load initial receipts + mReceiptRepository.loadReceipts(); + + // register one time error event observer + mErrorEventObserver = new Observer>() { + @Override + public void onChanged(Event event) { + mErrorEvent.postValue(event); + } + }; + mReceiptRepository.getErrors().observeForever(mErrorEventObserver); + } + + public LiveData isLoadingData() { + return mReceiptRepository.isLoading(); + } + + public LiveData> getReceiptErrors() { + return mErrorEvent; + } + + public LiveData> getReceiptList() { + return mReceiptRepository.loadReceipts(); + } + + public void retryLoadReceipts() { + mReceiptRepository.retryLoadReceipt(); + } + + @Override + protected void onCleared() { + super.onCleared(); + mReceiptRepository.getErrors().removeObserver(mErrorEventObserver); + mReceiptRepository = null; + } + + public static class ListReceiptViewModelFactory implements ViewModelProvider.Factory { + + private final ReceiptRepository mReceiptRepository; + + public ListReceiptViewModelFactory(@NonNull final ReceiptRepository receiptRepository) { + mReceiptRepository = receiptRepository; + } + + @NonNull + @Override + public T create(@NonNull Class modelClass) { + if (modelClass.isAssignableFrom(ListReceiptViewModel.class)) { + return (T) new ListReceiptViewModel(mReceiptRepository); + } + throw new IllegalArgumentException("Expecting ViewModel class: " + ListReceiptViewModel.class.getName()); + } + } +} diff --git a/receipt/src/main/res/drawable/circle_negative.xml b/receipt/src/main/res/drawable/circle_negative.xml new file mode 100644 index 000000000..c7ae00b18 --- /dev/null +++ b/receipt/src/main/res/drawable/circle_negative.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/receipt/src/main/res/drawable/circle_positive.xml b/receipt/src/main/res/drawable/circle_positive.xml new file mode 100644 index 000000000..8b6506627 --- /dev/null +++ b/receipt/src/main/res/drawable/circle_positive.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/receipt/src/main/res/drawable/item_view_border.xml b/receipt/src/main/res/drawable/item_view_border.xml new file mode 100644 index 000000000..1bd67efd9 --- /dev/null +++ b/receipt/src/main/res/drawable/item_view_border.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/receipt/src/main/res/drawable/item_view_border_header.xml b/receipt/src/main/res/drawable/item_view_border_header.xml new file mode 100644 index 000000000..cceee0055 --- /dev/null +++ b/receipt/src/main/res/drawable/item_view_border_header.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/receipt/src/main/res/layout/activity_list_receipt.xml b/receipt/src/main/res/layout/activity_list_receipt.xml new file mode 100644 index 000000000..44beb1444 --- /dev/null +++ b/receipt/src/main/res/layout/activity_list_receipt.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/receipt/src/main/res/layout/item_receipt.xml b/receipt/src/main/res/layout/item_receipt.xml new file mode 100644 index 000000000..4388d4ab7 --- /dev/null +++ b/receipt/src/main/res/layout/item_receipt.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/receipt/src/main/res/layout/item_receipt_with_header.xml b/receipt/src/main/res/layout/item_receipt_with_header.xml new file mode 100644 index 000000000..29f002169 --- /dev/null +++ b/receipt/src/main/res/layout/item_receipt_with_header.xml @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/receipt/src/main/res/layout/list_receipt_fragment.xml b/receipt/src/main/res/layout/list_receipt_fragment.xml new file mode 100644 index 000000000..52e9725db --- /dev/null +++ b/receipt/src/main/res/layout/list_receipt_fragment.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/receipt/src/main/res/values/colors.xml b/receipt/src/main/res/values/colors.xml new file mode 100644 index 000000000..89155ac94 --- /dev/null +++ b/receipt/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #FEF7F7 + #5FBF00 + #F1FAE8 + diff --git a/receipt/src/main/res/values/dimens.xml b/receipt/src/main/res/values/dimens.xml new file mode 100644 index 000000000..999d990b2 --- /dev/null +++ b/receipt/src/main/res/values/dimens.xml @@ -0,0 +1,6 @@ + + 3dp + 10dp + 8dp + 70dp + diff --git a/receipt/src/main/res/values/strings.xml b/receipt/src/main/res/values/strings.xml index c3ca11b97..6c6730a9e 100644 --- a/receipt/src/main/res/values/strings.xml +++ b/receipt/src/main/res/values/strings.xml @@ -1,6 +1,21 @@ receipt + Transactions + Seems like, you don\'t have any Transactions, + yet. + + Transfer Funds + \uE900 + Placeholder + + + \uE900 + \uE902 + - %s + + %s + + Unknown Transaction Type Annual Fee Annual Fee Refund Customer Service Fee diff --git a/receipt/src/main/res/values/styles.xml b/receipt/src/main/res/values/styles.xml new file mode 100644 index 000000000..b27a3a524 --- /dev/null +++ b/receipt/src/main/res/values/styles.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/receipt/src/main/res/xml/network_security_config.xml b/receipt/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..5e4ba9c97 --- /dev/null +++ b/receipt/src/main/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + localhost + + \ No newline at end of file diff --git a/receipt/src/test/java/com/hyperwallet/android/receipt/repository/ReceiptDataSourceFactoryTest.java b/receipt/src/test/java/com/hyperwallet/android/receipt/repository/ReceiptDataSourceFactoryTest.java new file mode 100644 index 000000000..cf5c63114 --- /dev/null +++ b/receipt/src/test/java/com/hyperwallet/android/receipt/repository/ReceiptDataSourceFactoryTest.java @@ -0,0 +1,38 @@ +package com.hyperwallet.android.receipt.repository; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; + +import androidx.lifecycle.LiveData; +import androidx.paging.DataSource; + +import org.hamcrest.CoreMatchers; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class ReceiptDataSourceFactoryTest { + + @Test + public void testGetReceiptDataSource_returnsLiveDataReceiptSource() { + // initialize + ReceiptDataSourceFactory dataSourceFactory = new ReceiptDataSourceFactory(); + // test + LiveData liveData = dataSourceFactory.getReceiptDataSource(); + // assert + assertThat(liveData, is(notNullValue())); + } + + @Test + public void testCreate_returnsDataSource() { + // initialize + ReceiptDataSourceFactory dataSourceFactory = new ReceiptDataSourceFactory(); + // test + DataSource dataSource = dataSourceFactory.create(); + // assert + assertThat(dataSource, is(notNullValue())); + assertThat(dataSource, CoreMatchers.instanceOf(ReceiptDataSource.class)); + } +} diff --git a/receipt/src/test/java/com/hyperwallet/android/receipt/repository/ReceiptDataSourceTest.java b/receipt/src/test/java/com/hyperwallet/android/receipt/repository/ReceiptDataSourceTest.java new file mode 100644 index 000000000..d09854710 --- /dev/null +++ b/receipt/src/test/java/com/hyperwallet/android/receipt/repository/ReceiptDataSourceTest.java @@ -0,0 +1,389 @@ +package com.hyperwallet.android.receipt.repository; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import static com.hyperwallet.android.model.receipt.Receipt.Entries.CREDIT; +import static com.hyperwallet.android.model.receipt.Receipt.Entries.DEBIT; +import static com.hyperwallet.android.model.receipt.Receipt.ReceiptTypes.PAYMENT; +import static com.hyperwallet.android.model.receipt.Receipt.ReceiptTypes.TRANSFER_TO_BANK_ACCOUNT; + +import androidx.paging.PageKeyedDataSource; + +import com.hyperwallet.android.Hyperwallet; +import com.hyperwallet.android.exception.HyperwalletException; +import com.hyperwallet.android.listener.HyperwalletListener; +import com.hyperwallet.android.model.HyperwalletError; +import com.hyperwallet.android.model.HyperwalletErrors; +import com.hyperwallet.android.model.paging.HyperwalletPageList; +import com.hyperwallet.android.model.receipt.Receipt; +import com.hyperwallet.android.model.receipt.ReceiptQueryParam; +import com.hyperwallet.android.rule.HyperwalletExternalResourceManager; + +import org.hamcrest.Matchers; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class ReceiptDataSourceTest { + + @Rule + public MockitoRule mMockito = MockitoJUnit.rule(); + @Rule + public HyperwalletExternalResourceManager mExternalResourceManager = new HyperwalletExternalResourceManager(); + + @Mock + private Hyperwallet mHyperwallet; + @Mock + private PageKeyedDataSource.LoadInitialParams mInitialParams; + @Mock + private PageKeyedDataSource.LoadInitialCallback mInitialCallback; + // can't be mocked due to params.key is of type Integer and autoboxing will not work with null to 0 + private final PageKeyedDataSource.LoadParams mLoadAfterParams = + new PageKeyedDataSource.LoadParams<>(10, 10); + @Mock + private PageKeyedDataSource.LoadCallback mLoadAfterCallback; + + @Captor + private ArgumentCaptor> mListArgumentCaptor; + @Captor + private ArgumentCaptor mPreviousCaptor; + @Captor + private ArgumentCaptor mNextCaptor; + + @Spy + private ReceiptDataSource mReceiptDataSource; + + @Before + public void setUp() { + doReturn(mHyperwallet).when(mReceiptDataSource).getHyperwallet(); + } + + @Test + public void testLoadInitial_returnsReceipts() throws Exception { + String json = mExternalResourceManager.getResourceContent("receipt_list_date_grouping_response.json"); + JSONObject jsonObject = new JSONObject(json); + final HyperwalletPageList response = new HyperwalletPageList<>(jsonObject, Receipt.class); + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + HyperwalletListener listener = (HyperwalletListener) invocation.getArguments()[1]; + listener.onSuccess(response); + return listener; + } + }).when(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + + // test + mReceiptDataSource.loadInitial(mInitialParams, mInitialCallback); + + verify(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + verify(mInitialCallback).onResult(mListArgumentCaptor.capture(), mPreviousCaptor.capture(), + mNextCaptor.capture()); + + assertThat(mPreviousCaptor.getValue(), is(0)); + assertThat(mNextCaptor.getValue(), is(10)); + + // assert receipts information + List receipts = mListArgumentCaptor.getValue(); + assertThat(receipts, Matchers.hasSize(5)); + assertThat(receipts.get(0).getJournalId(), is("51660665")); + assertThat(receipts.get(0).getType(), is(PAYMENT)); + assertThat(receipts.get(0).getEntry(), is(CREDIT)); + assertThat(receipts.get(0).getSourceToken(), is("act-b1f6dc28-e534-45f4-a661-3523f051f77a")); + assertThat(receipts.get(0).getDestinationToken(), is("usr-b4e8ec34-52d8-4a81-9566-bdde1bd745b6")); + assertThat(receipts.get(0).getAmount(), is("5000.00")); + assertThat(receipts.get(0).getFee(), is("0.00")); + assertThat(receipts.get(0).getCurrency(), is("USD")); + assertThat(receipts.get(0).getDetails(), is(notNullValue())); + assertThat(receipts.get(0).getDetails().getPayeeName(), is("Kevin Puckett")); + assertThat(receipts.get(0).getDetails().getClientPaymentId(), is("trans-0001")); + assertThat(receipts.get(1).getJournalId(), is("51660666")); + assertThat(receipts.get(1).getType(), is(TRANSFER_TO_BANK_ACCOUNT)); + assertThat(receipts.get(1).getEntry(), is(DEBIT)); + assertThat(receipts.get(1).getSourceToken(), is("usr-b4e8ec34-52d8-4a81-9566-bdde1bd745b6")); + assertThat(receipts.get(1).getDestinationToken(), is("trm-0a2ac589-2cae-4ed3-9b0b-658246a34687")); + assertThat(receipts.get(1).getAmount(), is("10.25")); + assertThat(receipts.get(1).getFee(), is("0.25")); + assertThat(receipts.get(1).getCurrency(), is("USD")); + assertThat(receipts.get(1).getDetails(), is(notNullValue())); + assertThat(receipts.get(1).getDetails().getPayeeName(), is("Kevin Puckett")); + assertThat(receipts.get(1).getDetails().getBankAccountId(), is("patzachery.mcclary@example.com")); + + assertThat(mReceiptDataSource.getErrors().getValue(), is(nullValue())); + assertThat(mReceiptDataSource.isFetchingData().getValue(), is(false)); + } + + @Test + public void testLoadInitial_returnNoReceipt() { + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + HyperwalletListener listener = (HyperwalletListener) invocation.getArguments()[1]; + listener.onSuccess(null); + return listener; + } + }).when(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + + // test + mReceiptDataSource.loadInitial(mInitialParams, mInitialCallback); + + verify(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + verify(mInitialCallback, never()).onResult(ArgumentMatchers.anyList(), anyInt(), anyInt()); + + assertThat(mReceiptDataSource.getErrors().getValue(), is(nullValue())); + assertThat(mReceiptDataSource.isFetchingData().getValue(), is(false)); + } + + @Test + public void testLoadInitial_withError() { + final HyperwalletError error = new HyperwalletError("test message", "TEST_CODE"); + List errorList = new ArrayList<>(); + errorList.add(error); + final HyperwalletErrors errors = new HyperwalletErrors(errorList); + + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + HyperwalletListener listener = (HyperwalletListener) invocation.getArguments()[1]; + listener.onFailure(new HyperwalletException(errors)); + return listener; + } + }).when(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + + // test + mReceiptDataSource.loadInitial(mInitialParams, mInitialCallback); + + verify(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + verify(mInitialCallback, never()).onResult(ArgumentMatchers.anyList(), anyInt(), anyInt()); + + assertThat(mReceiptDataSource.getErrors().getValue(), is(notNullValue())); + assertThat(mReceiptDataSource.getErrors().getValue().getContent().getErrors(), + Matchers.hasSize(1)); + assertThat(mReceiptDataSource.getErrors().getValue().getContent().getErrors().get(0).getCode(), + is("TEST_CODE")); + assertThat(mReceiptDataSource.getErrors().getValue().getContent().getErrors().get(0).getMessage(), + is("test message")); + assertThat(mReceiptDataSource.isFetchingData().getValue(), is(false)); + } + + @Test + public void testRetry_loadInitial() { + final HyperwalletError error = new HyperwalletError("test message", "TEST_CODE"); + List errorList = new ArrayList<>(); + errorList.add(error); + final HyperwalletErrors errors = new HyperwalletErrors(errorList); + + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + HyperwalletListener listener = (HyperwalletListener) invocation.getArguments()[1]; + listener.onFailure(new HyperwalletException(errors)); + return listener; + } + }).when(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + + // test + mReceiptDataSource.loadInitial(mInitialParams, mInitialCallback); + + verify(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + verify(mInitialCallback, never()).onResult(ArgumentMatchers.anyList(), anyInt(), anyInt()); + + // error occurred, this will save params and callback + assertThat(mReceiptDataSource.getErrors().getValue(), is(notNullValue())); + + // test retry, saved params and callback will be used and no null pointer exception is thrown + mReceiptDataSource.retry(); + + // verify calls + verify(mReceiptDataSource, times(2)).loadInitial( + ArgumentMatchers.>any(), + ArgumentMatchers.>any()); + verify(mReceiptDataSource, never()).loadAfter( + ArgumentMatchers.>any(), + ArgumentMatchers.>any()); + } + + @Test + public void testLoadAfter_returnsReceipts() throws Exception { + String json = mExternalResourceManager.getResourceContent("receipt_list_date_grouping_response.json"); + JSONObject jsonObject = new JSONObject(json); + final HyperwalletPageList response = new HyperwalletPageList<>(jsonObject, Receipt.class); + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + HyperwalletListener listener = (HyperwalletListener) invocation.getArguments()[1]; + listener.onSuccess(response); + return listener; + } + }).when(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + + // test + mReceiptDataSource.loadAfter(mLoadAfterParams, mLoadAfterCallback); + + verify(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + verify(mLoadAfterCallback).onResult(mListArgumentCaptor.capture(), mNextCaptor.capture()); + + assertThat(mNextCaptor.getValue(), is(10)); + + // assert receipts information + List receipts = mListArgumentCaptor.getValue(); + assertThat(receipts, Matchers.hasSize(5)); + assertThat(receipts.get(3).getJournalId(), is("51660675")); + assertThat(receipts.get(3).getType(), is(PAYMENT)); + assertThat(receipts.get(3).getEntry(), is(CREDIT)); + assertThat(receipts.get(3).getSourceToken(), is("act-b1f6dc28-e534-45f4-a661-3523f051f77a")); + assertThat(receipts.get(3).getDestinationToken(), is("usr-b4e8ec34-52d8-4a81-9566-bdde1bd745b6")); + assertThat(receipts.get(3).getAmount(), is("13.00")); + assertThat(receipts.get(3).getFee(), is("0.00")); + assertThat(receipts.get(3).getCurrency(), is("USD")); + assertThat(receipts.get(3).getDetails(), is(notNullValue())); + assertThat(receipts.get(3).getDetails().getPayeeName(), is("Kevin Puckett")); + assertThat(receipts.get(3).getDetails().getClientPaymentId(), is("CSietnRJQQ0bscYkOoPJxNiTDiVALhjQ")); + assertThat(receipts.get(4).getJournalId(), is("51660676")); + assertThat(receipts.get(4).getType(), is(PAYMENT)); + assertThat(receipts.get(4).getEntry(), is(CREDIT)); + assertThat(receipts.get(4).getSourceToken(), is("act-b1f6dc28-e534-45f4-a661-3523f051f77a")); + assertThat(receipts.get(4).getDestinationToken(), is("usr-b4e8ec34-52d8-4a81-9566-bdde1bd745b6")); + assertThat(receipts.get(4).getAmount(), is("14.00")); + assertThat(receipts.get(4).getFee(), is("0.00")); + assertThat(receipts.get(4).getCurrency(), is("USD")); + assertThat(receipts.get(4).getDetails(), is(notNullValue())); + assertThat(receipts.get(4).getDetails().getPayeeName(), is("Kevin Puckett")); + assertThat(receipts.get(4).getDetails().getClientPaymentId(), is("wUOdfLlJONacbdHlAHOAXQT7uwX7LTPy")); + + assertThat(mReceiptDataSource.getErrors().getValue(), is(nullValue())); + assertThat(mReceiptDataSource.isFetchingData().getValue(), is(false)); + } + + @Test + public void testLoadAfter_returnNoReceipt() { + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + HyperwalletListener listener = (HyperwalletListener) invocation.getArguments()[1]; + listener.onSuccess(null); + return listener; + } + }).when(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + + // test + mReceiptDataSource.loadAfter(mLoadAfterParams, mLoadAfterCallback); + + verify(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + verify(mLoadAfterCallback, never()).onResult(ArgumentMatchers.>any(), anyInt()); + + assertThat(mReceiptDataSource.getErrors().getValue(), is(nullValue())); + assertThat(mReceiptDataSource.isFetchingData().getValue(), is(false)); + } + + @Test + public void testLoadAfter_withError() { + final HyperwalletError error = new HyperwalletError("test message load after", "LOAD_AFTER_CODE"); + List errorList = new ArrayList<>(); + errorList.add(error); + final HyperwalletErrors errors = new HyperwalletErrors(errorList); + + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + HyperwalletListener listener = (HyperwalletListener) invocation.getArguments()[1]; + listener.onFailure(new HyperwalletException(errors)); + return listener; + } + }).when(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + + // test + mReceiptDataSource.loadAfter(mLoadAfterParams, mLoadAfterCallback); + + verify(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + verify(mLoadAfterCallback, never()).onResult(ArgumentMatchers.anyList(), anyInt()); + + // error occurred, this will save params and callback + assertThat(mReceiptDataSource.getErrors().getValue(), is(notNullValue())); + assertThat(mReceiptDataSource.getErrors().getValue().getContent().getErrors(), + Matchers.hasSize(1)); + assertThat(mReceiptDataSource.getErrors().getValue().getContent().getErrors().get(0).getCode(), + is("LOAD_AFTER_CODE")); + assertThat(mReceiptDataSource.getErrors().getValue().getContent().getErrors().get(0).getMessage(), + is("test message load after")); + assertThat(mReceiptDataSource.isFetchingData().getValue(), is(false)); + } + + @Test + public void testRetry_loadAfter() { + final HyperwalletError error = new HyperwalletError("test message", "TEST_CODE"); + List errorList = new ArrayList<>(); + errorList.add(error); + final HyperwalletErrors errors = new HyperwalletErrors(errorList); + + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) { + HyperwalletListener listener = (HyperwalletListener) invocation.getArguments()[1]; + listener.onFailure(new HyperwalletException(errors)); + return listener; + } + }).when(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + + // test + mReceiptDataSource.loadAfter(mLoadAfterParams, mLoadAfterCallback); + + verify(mHyperwallet).listReceipts(any(ReceiptQueryParam.class), + ArgumentMatchers.>>any()); + verify(mLoadAfterCallback, never()).onResult(ArgumentMatchers.anyList(), anyInt()); + + // error occurred, this will save params and callback + assertThat(mReceiptDataSource.getErrors().getValue(), is(notNullValue())); + + // test retry, saved params and callback will be used and no null pointer exception is thrown + mReceiptDataSource.retry(); + + // verify calls + verify(mReceiptDataSource, times(2)).loadAfter( + ArgumentMatchers.>any(), + ArgumentMatchers.>any()); + verify(mReceiptDataSource, never()).loadInitial( + ArgumentMatchers.>any(), + ArgumentMatchers.>any()); + } +} diff --git a/receipt/src/test/java/com/hyperwallet/android/receipt/repository/ReceiptRepositoryFactoryTest.java b/receipt/src/test/java/com/hyperwallet/android/receipt/repository/ReceiptRepositoryFactoryTest.java new file mode 100644 index 000000000..3326f2916 --- /dev/null +++ b/receipt/src/test/java/com/hyperwallet/android/receipt/repository/ReceiptRepositoryFactoryTest.java @@ -0,0 +1,39 @@ +package com.hyperwallet.android.receipt.repository; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class ReceiptRepositoryFactoryTest { + + @Test + public void testGetInstance_verifyRepositoriesInitialized() { + // test + ReceiptRepositoryFactory factory = ReceiptRepositoryFactory.getInstance(); + assertThat(factory, is(notNullValue())); + assertThat(factory.getReceiptRepository(), is(notNullValue())); + + ReceiptRepositoryFactory factory2 = ReceiptRepositoryFactory.getInstance(); + assertThat(factory, is(factory2)); + assertThat(factory.getReceiptRepository(), is(factory2.getReceiptRepository())); + } + + @Test + public void testClearInstance_verifyRepositoriesCleared() { + ReceiptRepositoryFactory factory = ReceiptRepositoryFactory.getInstance(); + assertThat(factory, is(notNullValue())); + assertThat(factory.getReceiptRepository(), is(notNullValue())); + + // test clear + ReceiptRepositoryFactory.clearInstance(); + ReceiptRepositoryFactory factory2 = ReceiptRepositoryFactory.getInstance(); + assertThat(factory, is(not(factory2))); + assertThat(factory.getReceiptRepository(), is(not(factory2.getReceiptRepository()))); + } +} diff --git a/receipt/src/test/java/com/hyperwallet/android/rule/HyperwalletExternalResourceManager.java b/receipt/src/test/java/com/hyperwallet/android/rule/HyperwalletExternalResourceManager.java new file mode 100644 index 000000000..9061af9b8 --- /dev/null +++ b/receipt/src/test/java/com/hyperwallet/android/rule/HyperwalletExternalResourceManager.java @@ -0,0 +1,72 @@ +package com.hyperwallet.android.rule; + +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringWriter; +import java.io.Writer; +import java.net.URL; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class HyperwalletExternalResourceManager extends TestWatcher { + + private static final String EMPTY = ""; + private ClassLoader classLoader; + private Logger logger; + + @Override + protected void starting(Description description) { + super.starting(description); + classLoader = description.getTestClass().getClassLoader(); + logger = Logger.getLogger(description.getTestClass().getName()); + } + + public String getResourceContent(final String resourceName) { + if (resourceName == null) { + throw new IllegalArgumentException("Parameter resourceName cannot be null"); + } + + return getContent(resourceName); + } + + private String getContent(final String resourceName) { + URL resource = classLoader.getResource(resourceName); + InputStream inputStream = null; + Writer writer = new StringWriter(); + String resourceContent = EMPTY; + if (resource != null) { + try { + inputStream = resource.openStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8")); + String line = reader.readLine(); + while (line != null) { + writer.write(line); + line = reader.readLine(); + } + resourceContent = writer.toString(); + + } catch (Exception e) { + logger.log(Level.WARNING, "There was an error loading an external resource", e); + } finally { + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (Exception e) { + logger.log(Level.SEVERE, "There was an error closing input stream", e); + } + try { + writer.close(); + } catch (IOException e) { + logger.log(Level.SEVERE, "There was an error closing writer", e); + } + } + } + return resourceContent; + } +} diff --git a/receipt/src/test/resources/authentication_token_response.json b/receipt/src/test/resources/authentication_token_response.json new file mode 100644 index 000000000..e8e41bf37 --- /dev/null +++ b/receipt/src/test/resources/authentication_token_response.json @@ -0,0 +1,3 @@ +{ + "value": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c3ItZjZlNmZjY2EtNTBmNy00ZWY1LWExYzUtNWZmMDJlMDU2MzgzIiwiaWF0IjoxNTQ5NTgwMzk0LCJleHAiOjI1NDk1ODA5OTQsImF1ZCI6InBndS03YTEyMzJlOC0xNDc5LTQzNzAtOWY1NC03ODc1ZjdiMTg2NmMiLCJpc3MiOiJwcmctY2NhODAyNWUtODVhMy0xMWU2LTg2MGEtNThhZDVlY2NlNjFkIiwicmVzdC11cmkiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVzdC92My8iLCJncmFwaHFsLXVyaSI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9ncmFwaHFsIn0.kILSynYHbepbl4sVqENnNog09iGByfTrckHhSCjVgnuRnuspI72cx3rt0SB2V_neHwzYkD_VfhNKk9gJDOwXeQ" +} \ No newline at end of file diff --git a/receipt/src/test/resources/receipt_credit_response.json b/receipt/src/test/resources/receipt_credit_response.json new file mode 100644 index 000000000..4351d17a2 --- /dev/null +++ b/receipt/src/test/resources/receipt_credit_response.json @@ -0,0 +1,30 @@ +{ + "count": 1, + "offset": 0, + "limit": 10, + "data": [ + { + "journalId": "3051581", + "type": "PAYMENT", + "createdOn": "2019-06-02T17:09:07", + "entry": "CREDIT", + "sourceToken": "act-12345", + "destinationToken": "usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d", + "amount": "25.00", + "fee": "0.00", + "currency": "CAD", + "details": { + "clientPaymentId": "ABC1234", + "payeeName": "A Person" + } + } + ], + "links": [ + { + "params": { + "rel": "self" + }, + "href": "https://api.sandbox.hyperwallet.com/rest/v3/users/usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d/receipts?offset=0&limit=10" + } + ] +} \ No newline at end of file diff --git a/receipt/src/test/resources/receipt_debit_response.json b/receipt/src/test/resources/receipt_debit_response.json new file mode 100644 index 000000000..8ed756455 --- /dev/null +++ b/receipt/src/test/resources/receipt_debit_response.json @@ -0,0 +1,25 @@ +{ + "count": 1, + "offset": 0, + "limit": 10, + "data": [ + { + "journalId": "3051590", + "type": "TRANSFER_TO_PREPAID_CARD", + "createdOn": "2019-05-02T17:12:18", + "entry": "DEBIT", + "sourceToken": "usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d", + "destinationToken": "trm-12345", + "amount": "18.05", + "currency": "USD" + } + ], + "links": [ + { + "params": { + "rel": "self" + }, + "href": "https://api.sandbox.hyperwallet.com/rest/v3/users/usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d/receipts?offset=0&limit=10" + } + ] +} \ No newline at end of file diff --git a/receipt/src/test/resources/receipt_list_date_grouping_response.json b/receipt/src/test/resources/receipt_list_date_grouping_response.json new file mode 100644 index 000000000..d4d552972 --- /dev/null +++ b/receipt/src/test/resources/receipt_list_date_grouping_response.json @@ -0,0 +1,90 @@ +{ + "count": 5, + "offset": 0, + "limit": 10, + "data": [ + { + "journalId": "51660665", + "type": "PAYMENT", + "createdOn": "2019-05-27T15:42:07", + "entry": "CREDIT", + "sourceToken": "act-b1f6dc28-e534-45f4-a661-3523f051f77a", + "destinationToken": "usr-b4e8ec34-52d8-4a81-9566-bdde1bd745b6", + "amount": "5000.00", + "fee": "0.00", + "currency": "USD", + "details": { + "clientPaymentId": "trans-0001", + "payeeName": "Kevin Puckett" + } + }, + { + "journalId": "51660666", + "type": "TRANSFER_TO_BANK_ACCOUNT", + "createdOn": "2019-05-27T15:57:49", + "entry": "DEBIT", + "sourceToken": "usr-b4e8ec34-52d8-4a81-9566-bdde1bd745b6", + "destinationToken": "trm-0a2ac589-2cae-4ed3-9b0b-658246a34687", + "amount": "10.25", + "fee": "0.25", + "currency": "USD", + "details": { + "payeeName": "Kevin Puckett", + "bankAccountId": "patzachery.mcclary@example.com" + } + }, + { + "journalId": "51660667", + "type": "PAYMENT", + "createdOn": "2019-05-27T16:01:10", + "entry": "CREDIT", + "sourceToken": "act-b1f6dc28-e534-45f4-a661-3523f051f77a", + "destinationToken": "usr-b4e8ec34-52d8-4a81-9566-bdde1bd745b6", + "amount": "11.00", + "fee": "0.00", + "currency": "USD", + "details": { + "clientPaymentId": "trans-02", + "payeeName": "Kevin Puckett" + } + }, + { + "journalId": "51660675", + "type": "PAYMENT", + "createdOn": "2019-06-04T10:35:23", + "entry": "CREDIT", + "sourceToken": "act-b1f6dc28-e534-45f4-a661-3523f051f77a", + "destinationToken": "usr-b4e8ec34-52d8-4a81-9566-bdde1bd745b6", + "amount": "13.00", + "fee": "0.00", + "currency": "USD", + "details": { + "clientPaymentId": "CSietnRJQQ0bscYkOoPJxNiTDiVALhjQ", + "payeeName": "Kevin Puckett" + } + }, + { + "journalId": "51660676", + "type": "PAYMENT", + "createdOn": "2019-06-04T11:16:21", + "entry": "CREDIT", + "sourceToken": "act-b1f6dc28-e534-45f4-a661-3523f051f77a", + "destinationToken": "usr-b4e8ec34-52d8-4a81-9566-bdde1bd745b6", + "amount": "14.00", + "fee": "0.00", + "currency": "USD", + "details": { + "clientPaymentId": "wUOdfLlJONacbdHlAHOAXQT7uwX7LTPy", + "payeeName": "Kevin Puckett" + } + } + ], + "links": [ + { + "params": { + "rel": "self" + }, + "href": "https://localhost:8181/rest/v3/users/usr-b4e8ec34-52d8-4a81-9566-bdde1bd745b6/receipts?offset=0&limit=10&createdAfter=2019-1-1" + } + ] +} \ No newline at end of file diff --git a/receipt/src/test/resources/receipt_list_response.json b/receipt/src/test/resources/receipt_list_response.json new file mode 100644 index 000000000..7e7cc58af --- /dev/null +++ b/receipt/src/test/resources/receipt_list_response.json @@ -0,0 +1,64 @@ +{ + "count": 4, + "offset": 0, + "limit": 10, + "data": [ + { + "journalId": "3051579", + "type": "PAYMENT", + "createdOn": "2019-06-07T17:08:58", + "entry": "CREDIT", + "sourceToken": "act-12345", + "destinationToken": "usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d", + "amount": "20.00", + "fee": "0.00", + "currency": "USD", + "details": { + "clientPaymentId": "8OxXefx5", + "payeeName": "A Person" + } + }, + { + "journalId": "3051581", + "type": "PAYMENT", + "createdOn": "2019-06-02T16:09:07", + "entry": "CREDIT", + "sourceToken": "act-12345", + "destinationToken": "usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d", + "amount": "25.00", + "fee": "0.00", + "currency": "CAD", + "details": { + "clientPaymentId": "Q3SVvpv0", + "payeeName": "A Person" + } + }, + { + "journalId": "3051582", + "type": "CARD_ACTIVATION_FEE", + "createdOn": "2019-06-01T11:09:16", + "entry": "DEBIT", + "sourceToken": "usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d", + "amount": "1.95", + "currency": "USD" + }, + { + "journalId": "3051590", + "type": "TRANSFER_TO_PREPAID_CARD", + "createdOn": "2018-12-01T17:12:18", + "entry": "DEBIT", + "sourceToken": "usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d", + "destinationToken": "trm-12345", + "amount": "18.05", + "currency": "USD" + } + ], + "links": [ + { + "params": { + "rel": "self" + }, + "href": "https://api.sandbox.hyperwallet.com/rest/v3/users/usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d/receipts?offset=0&limit=10" + } + ] +} \ No newline at end of file diff --git a/receipt/src/test/resources/receipt_unknown_type_response.json b/receipt/src/test/resources/receipt_unknown_type_response.json new file mode 100644 index 000000000..bf856ed63 --- /dev/null +++ b/receipt/src/test/resources/receipt_unknown_type_response.json @@ -0,0 +1,30 @@ +{ + "count": 1, + "offset": 0, + "limit": 10, + "data": [ + { + "journalId": "3051581", + "type": "ICK", + "createdOn": "2019-06-02T17:09:07", + "entry": "CREDIT", + "sourceToken": "act-12345", + "destinationToken": "usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d", + "amount": "25.00", + "fee": "0.00", + "currency": "CAD", + "details": { + "clientPaymentId": "ABC1234", + "payeeName": "A Person" + } + } + ], + "links": [ + { + "params": { + "rel": "self" + }, + "href": "https://api.sandbox.hyperwallet.com/rest/v3/users/usr-fa76a738-f43d-48b9-9a7a-7048d44a5d2d/receipts?offset=0&limit=10" + } + ] +} \ No newline at end of file diff --git a/ui/src/androidTest/AndroidManifest.xml b/ui/src/androidTest/AndroidManifest.xml index 9b2c52c35..0cd133def 100644 --- a/ui/src/androidTest/AndroidManifest.xml +++ b/ui/src/androidTest/AndroidManifest.xml @@ -1,6 +1,6 @@ + package="com.hyperwallet.android.hyperwallet_ui.test"> diff --git a/ui/src/main/java/com/hyperwallet/android/ui/HyperwalletUi.java b/ui/src/main/java/com/hyperwallet/android/ui/HyperwalletUi.java index d2f6b9123..aae63fa7e 100644 --- a/ui/src/main/java/com/hyperwallet/android/ui/HyperwalletUi.java +++ b/ui/src/main/java/com/hyperwallet/android/ui/HyperwalletUi.java @@ -31,6 +31,7 @@ import com.hyperwallet.android.Hyperwallet; import com.hyperwallet.android.HyperwalletAuthenticationTokenProvider; +import com.hyperwallet.android.receipt.view.ListReceiptActivity; import com.hyperwallet.android.ui.transfermethod.AddTransferMethodActivity; import com.hyperwallet.android.ui.transfermethod.ListTransferMethodActivity; import com.hyperwallet.android.ui.transfermethod.SelectTransferMethodActivity; @@ -78,6 +79,13 @@ public Intent getIntentSelectTransferMethodActivity(@NonNull final Context conte return new Intent(context, SelectTransferMethodActivity.class); } + /** + * @param context A Context of the application consuming this Intent. + * @return an Intent with the data necessary to launch the {@link ListReceiptActivity} + */ + public Intent getIntentListReceiptActivity(@NonNull final Context context) { + return new Intent(context, ListReceiptActivity.class); + } /** * @param context A Context of the application consuming this Intent. diff --git a/ui/src/main/java/com/hyperwallet/android/ui/repository/TransferMethodRepositoryImpl.java b/ui/src/main/java/com/hyperwallet/android/ui/repository/TransferMethodRepositoryImpl.java index 7d6b77063..902971023 100644 --- a/ui/src/main/java/com/hyperwallet/android/ui/repository/TransferMethodRepositoryImpl.java +++ b/ui/src/main/java/com/hyperwallet/android/ui/repository/TransferMethodRepositoryImpl.java @@ -16,6 +16,7 @@ */ package com.hyperwallet.android.ui.repository; +import static com.hyperwallet.android.model.HyperwalletStatusTransition.StatusDefinition.ACTIVATED; import static com.hyperwallet.android.model.transfermethod.HyperwalletTransferMethod.TransferMethodFields.TOKEN; import static com.hyperwallet.android.model.transfermethod.HyperwalletTransferMethod.TransferMethodFields.TYPE; import static com.hyperwallet.android.model.transfermethod.HyperwalletTransferMethod.TransferMethodTypes.BANK_ACCOUNT; @@ -36,6 +37,7 @@ import com.hyperwallet.android.model.transfermethod.HyperwalletBankAccount; import com.hyperwallet.android.model.transfermethod.HyperwalletBankCard; import com.hyperwallet.android.model.transfermethod.HyperwalletTransferMethod; +import com.hyperwallet.android.model.transfermethod.HyperwalletTransferMethodQueryParam; import com.hyperwallet.android.model.transfermethod.PayPalAccount; public class TransferMethodRepositoryImpl implements TransferMethodRepository { @@ -66,7 +68,11 @@ public void createTransferMethod(@NonNull final HyperwalletTransferMethod transf @Override public void loadTransferMethods(@NonNull final LoadTransferMethodListCallback callback) { - getHyperwallet().listTransferMethods(null, + + HyperwalletTransferMethodQueryParam queryParam = new HyperwalletTransferMethodQueryParam.Builder() + .status(ACTIVATED) + .build(); + getHyperwallet().listTransferMethods(queryParam, new HyperwalletListener>() { @Override public void onSuccess(@Nullable HyperwalletPageList result) { diff --git a/ui/src/main/java/com/hyperwallet/android/ui/transfermethod/ListTransferMethodFragment.java b/ui/src/main/java/com/hyperwallet/android/ui/transfermethod/ListTransferMethodFragment.java index 385d827a6..d09829047 100644 --- a/ui/src/main/java/com/hyperwallet/android/ui/transfermethod/ListTransferMethodFragment.java +++ b/ui/src/main/java/com/hyperwallet/android/ui/transfermethod/ListTransferMethodFragment.java @@ -48,13 +48,13 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import com.hyperwallet.android.common.view.HorizontalDividerItemDecorator; import com.hyperwallet.android.hyperwallet_ui.R; import com.hyperwallet.android.model.HyperwalletError; import com.hyperwallet.android.model.HyperwalletStatusTransition; import com.hyperwallet.android.model.transfermethod.HyperwalletTransferMethod; import com.hyperwallet.android.ui.HyperwalletLocalBroadcast; import com.hyperwallet.android.ui.repository.RepositoryFactory; -import com.hyperwallet.android.ui.view.HorizontalDividerItemDecorator; import com.hyperwallet.android.ui.view.widget.OneClickListener; import java.util.ArrayList; @@ -178,7 +178,7 @@ public void onOneClick(View v) { recyclerView = view.findViewById(R.id.list_transfer_method_item); recyclerView.setHasFixedSize(true); recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); - recyclerView.addItemDecoration(new HorizontalDividerItemDecorator(getContext(), false)); + recyclerView.addItemDecoration(new HorizontalDividerItemDecorator(requireContext(), false)); } @Override diff --git a/ui/src/main/java/com/hyperwallet/android/ui/transfermethod/SelectTransferMethodFragment.java b/ui/src/main/java/com/hyperwallet/android/ui/transfermethod/SelectTransferMethodFragment.java index aa4a9408f..154a2bff0 100644 --- a/ui/src/main/java/com/hyperwallet/android/ui/transfermethod/SelectTransferMethodFragment.java +++ b/ui/src/main/java/com/hyperwallet/android/ui/transfermethod/SelectTransferMethodFragment.java @@ -38,12 +38,12 @@ import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import com.hyperwallet.android.common.view.HorizontalDividerItemDecorator; import com.hyperwallet.android.hyperwallet_ui.R; import com.hyperwallet.android.model.HyperwalletError; import com.hyperwallet.android.ui.repository.RepositoryFactory; import com.hyperwallet.android.ui.view.CountrySelectionDialogFragment; import com.hyperwallet.android.ui.view.CurrencySelectionDialogFragment; -import com.hyperwallet.android.ui.view.HorizontalDividerItemDecorator; import com.hyperwallet.android.ui.view.widget.OneClickListener; import java.util.ArrayList; @@ -183,7 +183,7 @@ public void onTransferMethodSelected(TransferMethodSelectionItem transferMethodT mRecyclerView.setAdapter(mTransferMethodTypesAdapter); mRecyclerView.setHasFixedSize(true); mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - mRecyclerView.addItemDecoration(new HorizontalDividerItemDecorator(getContext(), true)); + mRecyclerView.addItemDecoration(new HorizontalDividerItemDecorator(requireContext(), true)); } @Override diff --git a/ui/src/test/java/com/hyperwallet/android/ui/repository/TransferMethodRepositoryImplTest.java b/ui/src/test/java/com/hyperwallet/android/ui/repository/TransferMethodRepositoryImplTest.java index 6135c42b2..334ad2044 100644 --- a/ui/src/test/java/com/hyperwallet/android/ui/repository/TransferMethodRepositoryImplTest.java +++ b/ui/src/test/java/com/hyperwallet/android/ui/repository/TransferMethodRepositoryImplTest.java @@ -33,7 +33,7 @@ import com.hyperwallet.android.model.transfermethod.HyperwalletBankAccount; import com.hyperwallet.android.model.transfermethod.HyperwalletBankCard; import com.hyperwallet.android.model.transfermethod.HyperwalletTransferMethod; -import com.hyperwallet.android.model.transfermethod.HyperwalletTransferMethodPagination; +import com.hyperwallet.android.model.transfermethod.HyperwalletTransferMethodQueryParam; import com.hyperwallet.android.model.transfermethod.PayPalAccount; import org.junit.Before; @@ -423,7 +423,7 @@ public Object answer(InvocationOnMock invocation) { listener.onSuccess(pageList); return listener; } - }).when(mHyperwallet).listTransferMethods((HyperwalletTransferMethodPagination) any(), + }).when(mHyperwallet).listTransferMethods((HyperwalletTransferMethodQueryParam) any(), ArgumentMatchers.>>any()); // test @@ -448,7 +448,7 @@ public Object answer(InvocationOnMock invocation) { listener.onSuccess(null); return listener; } - }).when(mHyperwallet).listTransferMethods((HyperwalletTransferMethodPagination) any(), + }).when(mHyperwallet).listTransferMethods((HyperwalletTransferMethodQueryParam) any(), ArgumentMatchers.>>any()); // test @@ -475,7 +475,7 @@ public Object answer(InvocationOnMock invocation) { listener.onFailure(new HyperwalletException(errors)); return listener; } - }).when(mHyperwallet).listTransferMethods((HyperwalletTransferMethodPagination) any(), + }).when(mHyperwallet).listTransferMethods((HyperwalletTransferMethodQueryParam) any(), ArgumentMatchers.>>any()); // test