├── .gitignore ├── .idea ├── caches │ └── build_file_checksums.ser ├── codeStyles │ └── Project.xml ├── gradle.xml ├── misc.xml ├── modules.xml ├── runConfigurations.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTestMock │ └── java │ │ └── com │ │ └── codemate │ │ └── blogreader │ │ ├── RecyclerViewMatcher.java │ │ ├── data │ │ └── cache │ │ │ ├── SharedPrefCommentCacheTest.java │ │ │ └── SharedPrefsPostCacheTest.java │ │ └── presentation │ │ ├── postdetails │ │ └── PostDetailsScreenTest.java │ │ └── posts │ │ └── PostsScreenTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── codemate │ │ │ └── blogreader │ │ │ ├── MVPApplication.java │ │ │ ├── data │ │ │ ├── cache │ │ │ │ ├── SharedPrefCache.java │ │ │ │ ├── SharedPrefCommentCache.java │ │ │ │ ├── SharedPrefPostCache.java │ │ │ │ └── converter │ │ │ │ │ ├── CommentJsonConverter.java │ │ │ │ │ └── PostJsonConverter.java │ │ │ └── network │ │ │ │ └── BlogService.java │ │ │ ├── domain │ │ │ ├── cache │ │ │ │ ├── CommentCache.java │ │ │ │ └── PostCache.java │ │ │ ├── interactors │ │ │ │ ├── BaseInteractor.java │ │ │ │ ├── GetPostCommentsInteractor.java │ │ │ │ └── GetPostsInteractor.java │ │ │ └── model │ │ │ │ ├── BlogPost.java │ │ │ │ └── Comment.java │ │ │ ├── injection │ │ │ └── AppComponent.java │ │ │ ├── presentation │ │ │ ├── anim │ │ │ │ └── ListItemAnimator.java │ │ │ ├── postdetails │ │ │ │ ├── CommentsAdapter.java │ │ │ │ ├── PostDetailActivity.java │ │ │ │ ├── PostDetailPresenter.java │ │ │ │ └── PostDetailView.java │ │ │ ├── posts │ │ │ │ ├── PostsActivity.java │ │ │ │ ├── PostsAdapter.java │ │ │ │ ├── PostsPresenter.java │ │ │ │ └── PostsView.java │ │ │ └── view │ │ │ │ └── ErrorViewLayout.java │ │ │ └── util │ │ │ ├── IntentFactory.java │ │ │ └── ScreenUtils.java │ └── res │ │ ├── drawable-hdpi │ │ └── ic_alert_error.png │ │ ├── drawable-mdpi │ │ └── ic_alert_error.png │ │ ├── drawable-xhdpi │ │ └── ic_alert_error.png │ │ ├── drawable-xxhdpi │ │ └── ic_alert_error.png │ │ ├── drawable-xxxhdpi │ │ └── ic_alert_error.png │ │ ├── layout │ │ ├── activity_post_details.xml │ │ ├── activity_posts.xml │ │ ├── item_comment.xml │ │ ├── item_post.xml │ │ └── view_error.xml │ │ ├── mipmap-hdpi │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ └── ic_launcher.png │ │ ├── values-w820dp │ │ └── dimens.xml │ │ └── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ ├── mock │ ├── java │ │ └── com │ │ │ └── codemate │ │ │ └── blogreader │ │ │ ├── FakeBlogApi.java │ │ │ └── injection │ │ │ └── AppModule.java │ └── res │ │ └── values │ │ └── strings.xml │ ├── prod │ └── java │ │ └── com │ │ └── codemate │ │ └── blogreader │ │ └── injection │ │ └── AppModule.java │ └── testMock │ └── java │ └── com │ └── codemate │ └── blogreader │ ├── data │ └── cache │ │ └── converter │ │ ├── CommentJsonConverterTest.java │ │ └── PostJsonConverterTest.java │ ├── presentation │ ├── postdetails │ │ ├── PostDetailActivityTest.java │ │ └── PostDetailPresenterTest.java │ └── posts │ │ └── PostsPresenterTest.java │ └── util │ └── MockIntentFactory.java ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/workspace.xml 5 | /.idea/libraries 6 | .DS_Store 7 | /build 8 | /captures 9 | -------------------------------------------------------------------------------- /.idea/caches/build_file_checksums.ser: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodemateLtd/android-architecture-and-testing-sample/a796120e38aa8a09f931165c37c0465a75cf1cdc/.idea/caches/build_file_checksums.ser -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 31 | 32 | 33 | 34 | 35 | 36 | 38 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android MVP & Clean Architecture sample with Unit and Instrumentation tests 2 | 3 | ## BlogReader App 4 | 5 | This sample app loads and displays blog posts in a list. By clicking a blog post, you are taken to a detail UI, which shows the entire blog post body and comments. 6 | 7 | The app uses a [fake blogpost API](https://jsonplaceholder.typicode.com) to load the posts and comments. All data is cached locally, so no unneeded network requests are being made until the cache becomes invalid. 8 | 9 | ### Maintainability 10 | 11 | The application has **almost 90% test coverage**, so adding / changing features is not scary at all. You can always run the tests after making changes, and if they pass, you can be pretty sure you didn't break anything vital. 12 | 13 | ![Ain't nobody got time for tests](https://i.imgflip.com/18ng7d.jpg) 14 | 15 | Actually, there's **only about 10 test functions**, and the longest one only has **8 lines of code**. That's not so much, is it? 16 | 17 | ## MVP? Clean? SOLID? 18 | 19 | By following [Model-View-Presenter](https://en.wikipedia.org/wiki/Model–view–presenter), [Clean Architecture](https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html) and [SOLID Principles](https://scotch.io/bar-talk/s-o-l-i-d-the-first-five-principles-of-object-oriented-design), this app becomes: 20 | * Easy to test. Business rules can be easily tested without Android emulators / devices in milliseconds. And the UI tests are't that horrible to write either. 21 | * Easy to maintain. Adding / modifying functionality is easy, and doesn't cause a regression bug hell as everything is separated and tested. 22 | * Independent of external third-party libraries. *Cache* can easily be replaced with Realm, without modifying the main application logic. **The application simply doesn't care about what frameworks we're using.** 23 | * Independent of UI. The UI can easily be changed without changing the existing business logic. This could be easily converted to a desktop or even a command line app. 24 | 25 | Everything is nicely decoupled. The application isn't dependent on concrete implementation details. They are more like plugins, *and they actually depend on the application; not the other way around*. 26 | 27 | ## How to run 28 | 29 | The project has two build variants: 30 | 31 |
32 |
mock
33 |
Instead of loading real data, populates the blog list and comments from a static FakeBlogApi to make testing easy. Also contains all the tests.
34 | 35 |
prod
36 |
The "real" app that actually loads real blog and comment data from a real API. Does not contain tests, they are in the mock variant.
37 |
38 | 39 | ## Package structure 40 | 41 | ### com.codemate.blogreader.data 42 | 43 | Blog API and Cache implementations. The Blog API is defined in a simple interface that has ```getPosts()``` and ```getComments(int postId)``` methods. The implementation is then automatically generated by the [Retrofit library](http://square.github.io/retrofit/). 44 | 45 | ### com.codemate.blogreader.domain 46 | 47 | Contains *the business rules* (usecases) that are our applications main functionality. No Android or external dependencies here, just plain Java. 48 | 49 | Also contains the *BlogPost* and *Comment* models and the *PostCache* and *CommentCache* interfaces that the **data layer implements** for *SharedPrefCache*. 50 | 51 | ### com.codemate.blogreader.presentation 52 | 53 | All the Android-related UI stuff, such as `Activities` and `RecyclerAdapters`. Also just plain Java `Presenter` objects and `View` interfaces implemented by the `Activities`. 54 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'jacoco' 3 | 4 | android { 5 | compileSdkVersion rootProject.ext.compileSdkVersion 6 | buildToolsVersion '28.0.3' 7 | 8 | defaultConfig { 9 | applicationId "com.codemate.blogreader" 10 | minSdkVersion rootProject.ext.minSdkVersion 11 | targetSdkVersion rootProject.ext.targetSdkVersion 12 | versionCode 1 13 | versionName "1.0" 14 | 15 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 16 | } 17 | buildTypes { 18 | debug { 19 | testCoverageEnabled = true 20 | } 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | 27 | flavorDimensions "version" 28 | 29 | productFlavors{ 30 | mock { 31 | applicationIdSuffix = ".mock" 32 | } 33 | prod { 34 | 35 | } 36 | } 37 | 38 | android.variantFilter { variant -> 39 | if(variant.buildType.name.equals('release') 40 | && variant.getFlavors().get(0).name.equals('mock')) { 41 | variant.setIgnore(true); 42 | } 43 | } 44 | 45 | testOptions { 46 | unitTests.returnDefaultValues = true 47 | } 48 | testOptions.unitTests.all { 49 | testLogging { 50 | events 'passed', 'skipped', 'failed', 'standardOut', 'standardError' 51 | } 52 | } 53 | } 54 | 55 | configurations.all { 56 | resolutionStrategy { 57 | force 'com.android.support:support-annotations:23.1.1' 58 | } 59 | } 60 | 61 | dependencies { 62 | implementation fileTree(dir: 'libs', include: ['*.jar']) 63 | 64 | // test dependencies 65 | testImplementation jUnit 66 | testImplementation mockito 67 | testImplementation hamcrest 68 | 69 | // Android Testing Support Library's runner and rules 70 | androidTestImplementation supportTest.runner 71 | androidTestImplementation supportTest.rules 72 | 73 | // Espresso UI Testing dependencies. 74 | androidTestImplementation (supportTest.espressoCore) { 75 | espressoExcludes.each { exclude module : "$it" } 76 | } 77 | 78 | androidTestImplementation (supportTest.espressoContrib) { 79 | espressoExcludes.each { exclude module : "$it" } 80 | } 81 | 82 | androidTestImplementation (supportTest.espressoIntents) { 83 | espressoExcludes.each { exclude module : "$it" } 84 | } 85 | 86 | // app dependencies 87 | implementation support.appcompat 88 | implementation support.design 89 | implementation support.recyclerview 90 | implementation support.cardview 91 | 92 | annotationProcessor daggerCompiler 93 | implementation dagger 94 | compileOnly jsr250 95 | 96 | implementation retrofit 97 | implementation rxJava 98 | implementation rxAndroid 99 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /Users/ironman/Library/Android/sdk/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | -------------------------------------------------------------------------------- /app/src/androidTestMock/java/com/codemate/blogreader/RecyclerViewMatcher.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader; 2 | 3 | import android.content.res.Resources; 4 | import android.support.v7.widget.RecyclerView; 5 | import android.view.View; 6 | 7 | import org.hamcrest.Description; 8 | import org.hamcrest.Matcher; 9 | import org.hamcrest.TypeSafeMatcher; 10 | 11 | /** 12 | * Created by ironman on 03/08/16. 13 | */ 14 | public class RecyclerViewMatcher { 15 | private final int recyclerViewId; 16 | 17 | public static RecyclerViewMatcher withRecyclerView(final int recyclerViewId) { 18 | return new RecyclerViewMatcher(recyclerViewId); 19 | } 20 | 21 | public RecyclerViewMatcher(int recyclerViewId) { 22 | this.recyclerViewId = recyclerViewId; 23 | } 24 | 25 | public Matcher atPosition(final int position) { 26 | return atPositionOnView(position, -1); 27 | } 28 | 29 | public Matcher atPositionOnView(final int position, final int targetViewId) { 30 | 31 | return new TypeSafeMatcher() { 32 | Resources resources = null; 33 | View childView; 34 | 35 | public void describeTo(Description description) { 36 | String idDescription = Integer.toString(recyclerViewId); 37 | if (this.resources != null) { 38 | try { 39 | idDescription = this.resources.getResourceName(recyclerViewId); 40 | } catch (Resources.NotFoundException var4) { 41 | idDescription = String.format("%s (resource name not found)", 42 | recyclerViewId); 43 | } 44 | } 45 | 46 | description.appendText("with id: " + idDescription); 47 | } 48 | 49 | public boolean matchesSafely(View view) { 50 | 51 | this.resources = view.getResources(); 52 | 53 | if (childView == null) { 54 | RecyclerView recyclerView = 55 | (RecyclerView) view.getRootView().findViewById(recyclerViewId); 56 | if (recyclerView != null && recyclerView.getId() == recyclerViewId) { 57 | childView = recyclerView.findViewHolderForAdapterPosition(position).itemView; 58 | } 59 | else { 60 | return false; 61 | } 62 | } 63 | 64 | if (targetViewId == -1) { 65 | return view == childView; 66 | } else { 67 | View targetView = childView.findViewById(targetViewId); 68 | return view == targetView; 69 | } 70 | 71 | } 72 | }; 73 | } 74 | } -------------------------------------------------------------------------------- /app/src/androidTestMock/java/com/codemate/blogreader/data/cache/SharedPrefCommentCacheTest.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.data.cache; 2 | 3 | import android.support.test.InstrumentationRegistry; 4 | import android.support.test.runner.AndroidJUnit4; 5 | import android.test.suitebuilder.annotation.LargeTest; 6 | 7 | import com.codemate.blogreader.FakeBlogApi; 8 | import com.codemate.blogreader.domain.model.Comment; 9 | import com.codemate.blogreader.domain.cache.CommentCache; 10 | 11 | import org.junit.Before; 12 | import org.junit.Test; 13 | import org.junit.runner.RunWith; 14 | 15 | import java.util.List; 16 | 17 | import static org.junit.Assert.*; 18 | 19 | /** 20 | * Created by ironman on 04/08/16. 21 | */ 22 | @RunWith(AndroidJUnit4.class) 23 | @LargeTest 24 | public class SharedPrefCommentCacheTest { 25 | private CommentCache commentCache; 26 | 27 | @Before 28 | public void setUp() { 29 | commentCache = new SharedPrefCommentCache(InstrumentationRegistry.getContext()); 30 | } 31 | 32 | @Test 33 | public void cacheWorks() { 34 | int postId = FakeBlogApi.COMMENTS.get(0).postId; 35 | 36 | commentCache.persistAll(postId, FakeBlogApi.COMMENTS); 37 | List comments = commentCache.getAll(postId); 38 | 39 | assertNotNull(comments); 40 | assertSame(3, comments.size()); 41 | 42 | ensureCommentSame(FakeBlogApi.COMMENTS.get(0), comments.get(0)); 43 | ensureCommentSame(FakeBlogApi.COMMENTS.get(1), comments.get(1)); 44 | ensureCommentSame(FakeBlogApi.COMMENTS.get(2), comments.get(2)); 45 | } 46 | 47 | private void ensureCommentSame(Comment actual, Comment cached) { 48 | assertEquals(actual.postId, cached.postId); 49 | assertEquals(actual.id, cached.id); 50 | assertEquals(actual.name, cached.name); 51 | assertEquals(actual.email, cached.email); 52 | assertEquals(actual.body, cached.body); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/androidTestMock/java/com/codemate/blogreader/data/cache/SharedPrefsPostCacheTest.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.data.cache; 2 | 3 | import android.support.test.InstrumentationRegistry; 4 | import android.support.test.runner.AndroidJUnit4; 5 | import android.test.suitebuilder.annotation.LargeTest; 6 | 7 | import com.codemate.blogreader.FakeBlogApi; 8 | import com.codemate.blogreader.domain.model.BlogPost; 9 | import com.codemate.blogreader.domain.cache.PostCache; 10 | 11 | import org.junit.Before; 12 | import org.junit.Test; 13 | import org.junit.runner.RunWith; 14 | 15 | import java.util.List; 16 | 17 | import static junit.framework.Assert.assertEquals; 18 | import static junit.framework.Assert.assertNotNull; 19 | import static junit.framework.Assert.assertSame; 20 | 21 | /** 22 | * Created by ironman on 04/08/16. 23 | */ 24 | @RunWith(AndroidJUnit4.class) 25 | @LargeTest 26 | public class SharedPrefsPostCacheTest { 27 | private PostCache postsCache; 28 | 29 | @Before 30 | public void setUp() { 31 | postsCache = new SharedPrefPostCache(InstrumentationRegistry.getContext()); 32 | } 33 | 34 | @Test 35 | public void cacheWorks() { 36 | postsCache.persistAll(FakeBlogApi.POSTS); 37 | 38 | List posts = postsCache.getAll(); 39 | 40 | assertNotNull(posts); 41 | assertSame(3, posts.size()); 42 | 43 | ensurePostSame(FakeBlogApi.POSTS.get(0), posts.get(0)); 44 | ensurePostSame(FakeBlogApi.POSTS.get(1), posts.get(1)); 45 | ensurePostSame(FakeBlogApi.POSTS.get(2), posts.get(2)); 46 | } 47 | 48 | private void ensurePostSame(BlogPost actual, BlogPost cached) { 49 | assertEquals(actual.userId, cached.userId); 50 | assertEquals(actual.id, cached.id); 51 | assertEquals(actual.title, cached.title); 52 | assertEquals(actual.body, cached.body); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/androidTestMock/java/com/codemate/blogreader/presentation/postdetails/PostDetailsScreenTest.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.postdetails; 2 | 3 | import android.content.Intent; 4 | import android.support.test.rule.ActivityTestRule; 5 | import android.support.test.runner.AndroidJUnit4; 6 | 7 | import com.codemate.blogreader.FakeBlogApi; 8 | import com.codemate.blogreader.domain.model.BlogPost; 9 | import com.codemate.blogreader.domain.model.Comment; 10 | 11 | import org.junit.Before; 12 | import org.junit.Rule; 13 | import org.junit.Test; 14 | import org.junit.runner.RunWith; 15 | 16 | import static android.support.test.espresso.Espresso.onView; 17 | import static android.support.test.espresso.assertion.ViewAssertions.matches; 18 | import static android.support.test.espresso.matcher.ViewMatchers.hasDescendant; 19 | import static android.support.test.espresso.matcher.ViewMatchers.withId; 20 | import static android.support.test.espresso.matcher.ViewMatchers.withText; 21 | import static com.codemate.blogreader.RecyclerViewMatcher.withRecyclerView; 22 | 23 | /** 24 | * Created by ironman on 03/08/16. 25 | */ 26 | @RunWith(AndroidJUnit4.class) 27 | public class PostDetailsScreenTest { 28 | private static final BlogPost POST = 29 | new BlogPost(1, 69, "Blog post title.", "This is a blog post content."); 30 | 31 | @Rule 32 | public ActivityTestRule postDetailTestRule = 33 | new ActivityTestRule<>(PostDetailActivity.class, true, false); 34 | 35 | @Before 36 | public void launchDetailActivityBeforeEachTest() { 37 | Intent intent = new Intent(); 38 | intent.putExtra(PostDetailActivity.EXTRA_POST_ID, POST.id); 39 | intent.putExtra(PostDetailActivity.EXTRA_POST_TITLE, POST.getFormattedTitle()); 40 | intent.putExtra(PostDetailActivity.EXTRA_POST_BODY, POST.body); 41 | 42 | postDetailTestRule.launchActivity(intent); 43 | } 44 | 45 | @Test 46 | public void postDetails_DisplayedInUi() { 47 | onView(withId(R.id.postTitle)).check(matches(withText(POST.title))); 48 | onView(withId(R.id.postBody)).check(matches(withText(POST.body))); 49 | } 50 | 51 | @Test 52 | public void comment_DisplayedInUi() { 53 | Comment fakeCommentOne = FakeBlogApi.COMMENTS.get(0); 54 | 55 | onView(withRecyclerView(R.id.commentList).atPosition(0)) 56 | .check(matches(hasDescendant(withText(fakeCommentOne.name)))) 57 | .check(matches(hasDescendant(withText(fakeCommentOne.email)))) 58 | .check(matches(hasDescendant(withText(fakeCommentOne.body)))); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/androidTestMock/java/com/codemate/blogreader/presentation/posts/PostsScreenTest.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.posts; 2 | 3 | import android.support.test.espresso.intent.Intents; 4 | import android.support.test.rule.ActivityTestRule; 5 | import android.support.test.runner.AndroidJUnit4; 6 | import android.test.suitebuilder.annotation.LargeTest; 7 | 8 | import com.codemate.blogreader.R; 9 | import com.codemate.blogreader.RecyclerViewMatcher; 10 | import com.codemate.blogreader.domain.model.BlogPost; 11 | import com.codemate.blogreader.FakeBlogApi; 12 | import com.codemate.blogreader.presentation.postdetails.PostDetailActivity; 13 | 14 | import org.junit.Rule; 15 | import org.junit.Test; 16 | import org.junit.runner.RunWith; 17 | 18 | import static android.support.test.espresso.Espresso.onView; 19 | import static android.support.test.espresso.action.ViewActions.click; 20 | import static android.support.test.espresso.assertion.ViewAssertions.matches; 21 | import static android.support.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition; 22 | import static android.support.test.espresso.intent.Intents.intended; 23 | import static android.support.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey; 24 | import static android.support.test.espresso.intent.matcher.IntentMatchers.toPackage; 25 | import static android.support.test.espresso.matcher.ViewMatchers.hasDescendant; 26 | import static android.support.test.espresso.matcher.ViewMatchers.withId; 27 | import static android.support.test.espresso.matcher.ViewMatchers.withText; 28 | 29 | /** 30 | * Created by ironman on 26/07/16. 31 | */ 32 | @RunWith(AndroidJUnit4.class) 33 | public class PostsScreenTest { 34 | @Rule 35 | public ActivityTestRule postsActivityActivityTestRule = 36 | new ActivityTestRule<>(PostsActivity.class); 37 | 38 | @Test 39 | public void testPosts_DisplayedInUi() { 40 | BlogPost fakePostOne = FakeBlogApi.POSTS.get(0); 41 | 42 | onView(RecyclerViewMatcher.withRecyclerView(R.id.postList).atPosition(0)) 43 | .check(matches(hasDescendant(withText(fakePostOne.getFormattedTitle())))) 44 | .check(matches(hasDescendant(withText(fakePostOne.getConcatenatedBody())))); 45 | } 46 | 47 | @Test 48 | public void testClickingPost_OpensDetails() { 49 | Intents.init(); 50 | 51 | onView(withId(R.id.postList)) 52 | .perform(actionOnItemAtPosition(0, click())); 53 | 54 | intended(toPackage("com.codemate.blogreader.mock")); 55 | intended(hasExtraWithKey(PostDetailActivity.EXTRA_POST_ID)); 56 | intended(hasExtraWithKey(PostDetailActivity.EXTRA_POST_TITLE)); 57 | intended(hasExtraWithKey(PostDetailActivity.EXTRA_POST_BODY)); 58 | 59 | Intents.release(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/MVPApplication.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader; 2 | 3 | import android.app.Application; 4 | 5 | import com.codemate.blogreader.injection.AppComponent; 6 | 7 | /** 8 | * Created by ironman on 04/08/16. 9 | */ 10 | public class MVPApplication extends Application { 11 | private static AppComponent appComponent; 12 | 13 | @Override 14 | public void onCreate() { 15 | super.onCreate(); 16 | 17 | appComponent = AppComponent.Initializer.init(this); 18 | } 19 | 20 | public static AppComponent component() { 21 | return appComponent; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/data/cache/SharedPrefCache.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.data.cache; 2 | 3 | import android.content.Context; 4 | import android.content.SharedPreferences; 5 | 6 | /** 7 | * Created by ironman on 04/08/16. 8 | */ 9 | public class SharedPrefCache { 10 | private static final String KEY_PERSIST_TIME = "persist_time"; 11 | private static final String KEY_DATA = "data"; 12 | 13 | private SharedPreferences prefs; 14 | private String cacheKeySuffix; 15 | 16 | public SharedPrefCache(Context context, String cacheName) { 17 | if (context == null) { 18 | return; 19 | } 20 | 21 | this.prefs = context.getSharedPreferences(cacheName, Context.MODE_PRIVATE); 22 | } 23 | 24 | protected void setCacheKeySuffix(String cacheKeySuffix) { 25 | this.cacheKeySuffix = cacheKeySuffix; 26 | } 27 | 28 | protected boolean isDataStillValid(long validityPeriod) { 29 | if (prefs == null) { 30 | return false; 31 | } 32 | 33 | long currentTime = System.currentTimeMillis(); 34 | long persistTime = prefs.getLong(getProperKey(KEY_PERSIST_TIME), 0); 35 | 36 | return currentTime - persistTime <= validityPeriod; 37 | } 38 | 39 | protected void persistData(String data) { 40 | if (prefs == null) { 41 | return; 42 | } 43 | 44 | prefs.edit() 45 | .putLong(getProperKey(KEY_PERSIST_TIME), System.currentTimeMillis()) 46 | .putString(getProperKey(KEY_DATA), data) 47 | .apply(); 48 | } 49 | 50 | protected String getData() { 51 | if (prefs == null) { 52 | return null; 53 | } 54 | 55 | return prefs.getString(getProperKey(KEY_DATA), null); 56 | } 57 | 58 | protected void clearCache() { 59 | if (prefs == null) { 60 | return; 61 | } 62 | 63 | prefs.edit().clear().apply(); 64 | } 65 | 66 | private String getProperKey(String input) { 67 | if (cacheKeySuffix != null) { 68 | return input + cacheKeySuffix; 69 | } 70 | 71 | return input; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/data/cache/SharedPrefCommentCache.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.data.cache; 2 | 3 | import android.content.Context; 4 | 5 | import com.codemate.blogreader.data.cache.converter.CommentJsonConverter; 6 | import com.codemate.blogreader.domain.cache.CommentCache; 7 | import com.codemate.blogreader.domain.model.Comment; 8 | 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | import java.util.concurrent.TimeUnit; 12 | 13 | /** 14 | * Created by ironman on 04/08/16. 15 | */ 16 | public class SharedPrefCommentCache extends SharedPrefCache implements CommentCache { 17 | private static final long VALIDITY_PERIOD = TimeUnit.MINUTES.toMillis(15); 18 | private final CommentJsonConverter converter; 19 | 20 | public SharedPrefCommentCache(Context context) { 21 | super(context, "comment_cache"); 22 | 23 | converter = new CommentJsonConverter(); 24 | } 25 | 26 | @Override 27 | public List getAll(int postId) { 28 | setCacheKeySuffix("_post_id_" + postId); 29 | 30 | if (isDataStillValid(VALIDITY_PERIOD)) { 31 | String data = getData(); 32 | 33 | if (data != null) { 34 | return converter.convertJsonToComments(getData()); 35 | } 36 | } 37 | 38 | return new ArrayList<>(0); 39 | } 40 | 41 | @Override 42 | public void persistAll(int postId, List comments) { 43 | String json = converter.convertCommentsToJson(comments); 44 | setCacheKeySuffix("_post_id_" + postId); 45 | persistData(json); 46 | } 47 | 48 | @Override 49 | public void clearAll() { 50 | clearCache(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/data/cache/SharedPrefPostCache.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.data.cache; 2 | 3 | import android.content.Context; 4 | 5 | import com.codemate.blogreader.data.cache.converter.PostJsonConverter; 6 | import com.codemate.blogreader.domain.cache.PostCache; 7 | import com.codemate.blogreader.domain.model.BlogPost; 8 | 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | import java.util.concurrent.TimeUnit; 12 | 13 | /** 14 | * Created by ironman on 04/08/16. 15 | */ 16 | public class SharedPrefPostCache extends SharedPrefCache implements PostCache { 17 | private static final long VALIDITY_PERIOD = TimeUnit.HOURS.toMillis(1); 18 | private final PostJsonConverter converter; 19 | 20 | public SharedPrefPostCache(Context context) { 21 | super(context, "post_cache"); 22 | 23 | converter = new PostJsonConverter(); 24 | } 25 | 26 | @Override 27 | public List getAll() { 28 | if (isDataStillValid(VALIDITY_PERIOD)) { 29 | String data = getData(); 30 | 31 | if (data != null) { 32 | return converter.convertJsonToPosts(data); 33 | } 34 | } 35 | 36 | return new ArrayList<>(0); 37 | } 38 | 39 | @Override 40 | public void persistAll(List items) { 41 | persistData(converter.convertPostsToJson(items)); 42 | } 43 | 44 | @Override 45 | public void clearAll() { 46 | clearCache(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/data/cache/converter/CommentJsonConverter.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.data.cache.converter; 2 | 3 | import com.codemate.blogreader.domain.model.Comment; 4 | import com.google.gson.Gson; 5 | import com.google.gson.reflect.TypeToken; 6 | 7 | import java.lang.reflect.Type; 8 | import java.util.List; 9 | 10 | public class CommentJsonConverter { 11 | private final Gson gson; 12 | 13 | public CommentJsonConverter() { 14 | gson = new Gson(); 15 | } 16 | 17 | public List convertJsonToComments(String data) { 18 | Type listType = new TypeToken>() {}.getType(); 19 | 20 | return gson.fromJson(data, listType); 21 | } 22 | 23 | public String convertCommentsToJson(List items) { 24 | return gson.toJson(items); 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/data/cache/converter/PostJsonConverter.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.data.cache.converter; 2 | 3 | import com.codemate.blogreader.domain.model.BlogPost; 4 | import com.google.gson.Gson; 5 | import com.google.gson.reflect.TypeToken; 6 | 7 | import java.lang.reflect.Type; 8 | import java.util.List; 9 | 10 | public class PostJsonConverter { 11 | private final Gson gson; 12 | 13 | public PostJsonConverter() { 14 | gson = new Gson(); 15 | } 16 | 17 | public List convertJsonToPosts(String data) { 18 | Type listType = new TypeToken>() {}.getType(); 19 | 20 | return gson.fromJson(data, listType); 21 | } 22 | 23 | public String convertPostsToJson(List items) { 24 | return gson.toJson(items); 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/data/network/BlogService.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.data.network; 2 | 3 | import com.codemate.blogreader.domain.model.BlogPost; 4 | import com.codemate.blogreader.domain.model.Comment; 5 | 6 | import java.util.List; 7 | 8 | import retrofit.RequestInterceptor; 9 | import retrofit.RestAdapter; 10 | import retrofit.http.GET; 11 | import retrofit.http.Query; 12 | import rx.Observable; 13 | 14 | /** 15 | * Created by ironman on 26/07/16. 16 | */ 17 | public class BlogService { 18 | private static final String FORUM_SERVER_URL = "http://jsonplaceholder.typicode.com"; 19 | private BlogApi blogApi; 20 | 21 | public BlogService() { 22 | RequestInterceptor requestInterceptor = new RequestInterceptor() { 23 | @Override 24 | public void intercept(RequestFacade request) { 25 | request.addHeader("Accept", "application/json"); 26 | } 27 | }; 28 | 29 | RestAdapter restAdapter = new RestAdapter.Builder() 30 | .setEndpoint(FORUM_SERVER_URL) 31 | .setRequestInterceptor(requestInterceptor) 32 | .build(); 33 | 34 | blogApi = restAdapter.create(BlogApi.class); 35 | } 36 | 37 | public BlogApi getApi() { 38 | return blogApi; 39 | } 40 | 41 | public interface BlogApi { 42 | @GET("/posts") 43 | Observable> getPosts(); 44 | 45 | @GET("/comments") 46 | Observable> getComments(@Query("postId") int postId); 47 | } 48 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/domain/cache/CommentCache.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.domain.cache; 2 | 3 | import com.codemate.blogreader.domain.model.Comment; 4 | 5 | import java.util.List; 6 | 7 | /** 8 | * Created by ironman on 04/08/16. 9 | */ 10 | public interface CommentCache { 11 | List getAll(int postId); 12 | void persistAll(int postId, List comments); 13 | void clearAll(); 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/domain/cache/PostCache.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.domain.cache; 2 | 3 | import com.codemate.blogreader.domain.model.BlogPost; 4 | 5 | import java.util.List; 6 | 7 | /** 8 | * Created by ironman on 04/08/16. 9 | */ 10 | public interface PostCache { 11 | List getAll(); 12 | void persistAll(List items); 13 | void clearAll(); 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/domain/interactors/BaseInteractor.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.domain.interactors; 2 | 3 | import rx.Observable; 4 | import rx.Scheduler; 5 | import rx.Subscriber; 6 | import rx.Subscription; 7 | import rx.subscriptions.Subscriptions; 8 | 9 | /** 10 | * Created by ironman on 26/07/16. 11 | */ 12 | public abstract class BaseInteractor { 13 | private final Scheduler worker; 14 | private final Scheduler observer; 15 | private Subscription subscription = Subscriptions.empty(); 16 | 17 | protected BaseInteractor(Scheduler worker, Scheduler observer) { 18 | this.worker = worker; 19 | this.observer = observer; 20 | } 21 | 22 | protected abstract Observable buildObservable(); 23 | 24 | public void execute(Subscriber subscriber) { 25 | this.subscription = buildObservable() 26 | .subscribeOn(worker) 27 | .observeOn(observer) 28 | .subscribe(subscriber); 29 | } 30 | 31 | public void unsubscribe() { 32 | if (!subscription.isUnsubscribed()) { 33 | subscription.unsubscribe(); 34 | } 35 | } 36 | 37 | public abstract void setForceRefresh(boolean forceRefresh); 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/domain/interactors/GetPostCommentsInteractor.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.domain.interactors; 2 | 3 | import android.util.Log; 4 | 5 | import com.codemate.blogreader.data.network.BlogService; 6 | import com.codemate.blogreader.domain.cache.CommentCache; 7 | import com.codemate.blogreader.domain.model.Comment; 8 | 9 | import java.util.List; 10 | 11 | import rx.Observable; 12 | import rx.Scheduler; 13 | import rx.functions.Func1; 14 | 15 | /** 16 | * Created by ironman on 28/07/16. 17 | */ 18 | public class GetPostCommentsInteractor extends BaseInteractor> { 19 | private final BlogService.BlogApi blogApi; 20 | private final CommentCache commentCache; 21 | 22 | private int postId; 23 | private boolean forceRefresh; 24 | 25 | public GetPostCommentsInteractor(BlogService.BlogApi blogApi, CommentCache commentCache, 26 | Scheduler worker, Scheduler observer) { 27 | super(worker, observer); 28 | this.blogApi = blogApi; 29 | this.commentCache = commentCache; 30 | } 31 | 32 | public void setPostId(int postId) { 33 | this.postId = postId; 34 | } 35 | 36 | @Override 37 | protected Observable> buildObservable() { 38 | if (forceRefresh) { 39 | commentCache.clearAll(); 40 | } 41 | 42 | List cachedComments = commentCache.getAll(postId); 43 | 44 | if (cachedComments.size() > 0) { 45 | Log.d("GetComments", "using cached comments"); 46 | return Observable.just(cachedComments); 47 | } 48 | 49 | return blogApi.getComments(postId) 50 | .filter(new Func1, Boolean>() { 51 | @Override 52 | public Boolean call(List comments) { 53 | commentCache.persistAll(postId, comments); 54 | return true; 55 | } 56 | }); 57 | } 58 | 59 | @Override 60 | public void setForceRefresh(boolean forceRefresh) { 61 | this.forceRefresh = forceRefresh; 62 | } 63 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/domain/interactors/GetPostsInteractor.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.domain.interactors; 2 | 3 | import android.util.Log; 4 | 5 | import com.codemate.blogreader.data.network.BlogService; 6 | import com.codemate.blogreader.domain.cache.PostCache; 7 | import com.codemate.blogreader.domain.model.BlogPost; 8 | 9 | import java.util.List; 10 | 11 | import javax.inject.Inject; 12 | 13 | import rx.Observable; 14 | import rx.Scheduler; 15 | import rx.functions.Func1; 16 | 17 | /** 18 | * Created by ironman on 26/07/16. 19 | */ 20 | public class GetPostsInteractor extends BaseInteractor> { 21 | private final BlogService.BlogApi blogApi; 22 | private final PostCache blogPostCache; 23 | private boolean forceRefresh; 24 | 25 | @Inject 26 | public GetPostsInteractor(BlogService.BlogApi blogApi, PostCache blogPostCache, 27 | Scheduler worker, Scheduler observer) { 28 | super(worker, observer); 29 | this.blogApi = blogApi; 30 | this.blogPostCache = blogPostCache; 31 | } 32 | 33 | @Override 34 | protected Observable> buildObservable() { 35 | if (forceRefresh) { 36 | blogPostCache.clearAll(); 37 | } 38 | 39 | final List cachedPosts = blogPostCache.getAll(); 40 | 41 | if (cachedPosts.size() > 0) { 42 | Log.d("GetPostsInteractor", "using cached posts"); 43 | return Observable.just(cachedPosts); 44 | } 45 | 46 | return blogApi.getPosts() 47 | .filter(new Func1, Boolean>() { 48 | @Override 49 | public Boolean call(List blogPosts) { 50 | blogPostCache.persistAll(blogPosts); 51 | return true; 52 | } 53 | }); 54 | } 55 | 56 | @Override 57 | public void setForceRefresh(boolean forceRefresh) { 58 | this.forceRefresh = forceRefresh; 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/domain/model/BlogPost.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.domain.model; 2 | 3 | /** 4 | * Created by ironman on 26/07/16. 5 | */ 6 | public class BlogPost { 7 | private static final int MAX_BODY_LENGTH = 100; 8 | 9 | public int userId; 10 | public int id; 11 | public String title; 12 | public String body; 13 | 14 | public BlogPost(int userId, int id, String title, String body) { 15 | this.userId = userId; 16 | this.id = id; 17 | this.title = title; 18 | this.body = body; 19 | } 20 | 21 | public String getFormattedTitle() { 22 | return title.substring(0, 1).toUpperCase() + title.substring(1, title.length()); 23 | } 24 | 25 | public String getConcatenatedBody() { 26 | boolean tooLong = body != null && body.length() > MAX_BODY_LENGTH; 27 | 28 | if (!tooLong) { 29 | return body; 30 | } 31 | 32 | return body.substring(0, MAX_BODY_LENGTH) + "..."; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/domain/model/Comment.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.domain.model; 2 | 3 | /** 4 | * Created by ironman on 26/07/16. 5 | */ 6 | public class Comment { 7 | public int postId; 8 | public int id; 9 | public String name; 10 | public String email; 11 | public String body; 12 | 13 | public Comment(int postId, int id, String name, String email, String body) { 14 | this.postId = postId; 15 | this.id = id; 16 | this.name = name; 17 | this.email = email; 18 | this.body = body; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/injection/AppComponent.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.injection; 2 | 3 | import com.codemate.blogreader.MVPApplication; 4 | import com.codemate.blogreader.presentation.postdetails.PostDetailActivity; 5 | import com.codemate.blogreader.presentation.posts.PostsActivity; 6 | 7 | import javax.inject.Singleton; 8 | 9 | import dagger.Component; 10 | 11 | /** 12 | * Created by ironman on 05/08/16. 13 | */ 14 | @Singleton 15 | @Component(modules = {AppModule.class}) 16 | public interface AppComponent { 17 | void inject(PostsActivity postsActivity); 18 | void inject(PostDetailActivity postDetailActivity); 19 | 20 | final class Initializer { 21 | private Initializer(){ 22 | } 23 | 24 | public static AppComponent init(MVPApplication application) { 25 | return DaggerAppComponent.builder() 26 | .appModule(new AppModule(application)) 27 | .build(); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/presentation/anim/ListItemAnimator.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.anim; 2 | 3 | import android.animation.Animator; 4 | import android.animation.AnimatorListenerAdapter; 5 | import android.support.v7.widget.DefaultItemAnimator; 6 | import android.support.v7.widget.RecyclerView; 7 | import android.view.animation.DecelerateInterpolator; 8 | 9 | import com.codemate.blogreader.util.ScreenUtils; 10 | 11 | /** 12 | * Created by ironman on 09/08/16. 13 | */ 14 | public class ListItemAnimator extends DefaultItemAnimator { 15 | private final int itemTypeToAnimate; 16 | 17 | public ListItemAnimator(int itemTypeToAnimate) { 18 | this.itemTypeToAnimate = itemTypeToAnimate; 19 | } 20 | 21 | @Override 22 | public boolean animateAdd(RecyclerView.ViewHolder viewHolder) { 23 | if (viewHolder.getItemViewType() == itemTypeToAnimate) { 24 | runEnterAnimation(viewHolder); 25 | return false; 26 | } 27 | 28 | super.dispatchAddFinished(viewHolder); 29 | return false; 30 | } 31 | 32 | private void runEnterAnimation(final RecyclerView.ViewHolder viewHolder) { 33 | int screenHeight = ScreenUtils.getScreenHeight(viewHolder.itemView.getContext()); 34 | 35 | viewHolder.itemView.setTranslationY(screenHeight); 36 | viewHolder.itemView.setScaleX(0.9f); 37 | viewHolder.itemView.setAlpha(0.5f); 38 | viewHolder.itemView.animate() 39 | .setInterpolator(new DecelerateInterpolator(3f)) 40 | .setDuration(1000) 41 | .setStartDelay(250 + (viewHolder.getLayoutPosition() * 75)) 42 | .setListener(new AnimatorListenerAdapter() { 43 | @Override 44 | public void onAnimationEnd(Animator animation) { 45 | ListItemAnimator.super.dispatchAddFinished(viewHolder); 46 | } 47 | }) 48 | .translationY(0) 49 | .scaleX(1) 50 | .alpha(1) 51 | .start(); 52 | } 53 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/presentation/postdetails/CommentsAdapter.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.postdetails; 2 | 3 | import android.content.Context; 4 | import android.support.v7.widget.RecyclerView; 5 | import android.view.LayoutInflater; 6 | import android.view.View; 7 | import android.view.ViewGroup; 8 | import android.widget.ImageView; 9 | import android.widget.TextView; 10 | 11 | import com.codemate.blogreader.R; 12 | import com.codemate.blogreader.domain.model.BlogPost; 13 | import com.codemate.blogreader.domain.model.Comment; 14 | 15 | import java.util.ArrayList; 16 | import java.util.List; 17 | 18 | /** 19 | * Created by ironman on 03/08/16. 20 | */ 21 | public class CommentsAdapter extends RecyclerView.Adapter { 22 | public static final int ITEM_TYPE_COMMENT = 2; 23 | 24 | private List comments; 25 | 26 | public CommentsAdapter(List comments) { 27 | this.comments = comments; 28 | } 29 | 30 | @Override 31 | public CommentViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 32 | Context context = parent.getContext(); 33 | LayoutInflater inflater = LayoutInflater.from(context); 34 | View commentView = inflater.inflate(R.layout.item_comment, parent, false); 35 | 36 | return new CommentViewHolder(commentView); 37 | } 38 | 39 | @Override 40 | public void onBindViewHolder(CommentViewHolder holder, int position) { 41 | Comment comment = comments.get(position); 42 | 43 | holder.name.setText(comment.name); 44 | holder.email.setText(comment.email); 45 | holder.body.setText(comment.body); 46 | } 47 | 48 | @Override 49 | public int getItemCount() { 50 | return comments.size(); 51 | } 52 | 53 | @Override 54 | public int getItemViewType(int position) { 55 | return ITEM_TYPE_COMMENT; 56 | } 57 | 58 | public void setComments(boolean animated, List comments) { 59 | this.comments = new ArrayList<>(comments); 60 | 61 | if (animated) { 62 | notifyItemRangeInserted(0, comments.size()); 63 | } else { 64 | notifyDataSetChanged(); 65 | } 66 | } 67 | 68 | public boolean isEmpty() { 69 | return getItemCount() == 0; 70 | } 71 | 72 | public void clear() { 73 | comments.clear(); 74 | notifyDataSetChanged(); 75 | } 76 | 77 | public class CommentViewHolder extends RecyclerView.ViewHolder { 78 | public ImageView profilePic; 79 | public TextView name; 80 | public TextView email; 81 | public TextView body; 82 | 83 | public CommentViewHolder(View itemView) { 84 | super(itemView); 85 | 86 | profilePic = (ImageView) itemView.findViewById(R.id.commentProfilePic); 87 | name = (TextView) itemView.findViewById(R.id.commentName); 88 | email = (TextView) itemView.findViewById(R.id.commentEmail); 89 | body = (TextView) itemView.findViewById(R.id.commentBody); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/presentation/postdetails/PostDetailActivity.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.postdetails; 2 | 3 | import android.content.Context; 4 | import android.content.Intent; 5 | import android.os.Bundle; 6 | import android.support.annotation.Nullable; 7 | import android.support.v4.content.ContextCompat; 8 | import android.support.v4.widget.SwipeRefreshLayout; 9 | import android.support.v7.app.ActionBar; 10 | import android.support.v7.app.AppCompatActivity; 11 | import android.support.v7.widget.LinearLayoutManager; 12 | import android.support.v7.widget.RecyclerView; 13 | import android.support.v7.widget.Toolbar; 14 | import android.view.MenuItem; 15 | import android.widget.TextView; 16 | 17 | import com.codemate.blogreader.MVPApplication; 18 | import com.codemate.blogreader.R; 19 | import com.codemate.blogreader.domain.interactors.GetPostCommentsInteractor; 20 | import com.codemate.blogreader.domain.model.BlogPost; 21 | import com.codemate.blogreader.domain.model.Comment; 22 | import com.codemate.blogreader.presentation.anim.ListItemAnimator; 23 | import com.codemate.blogreader.presentation.view.ErrorViewLayout; 24 | import com.codemate.blogreader.util.IntentFactory; 25 | 26 | import java.util.ArrayList; 27 | import java.util.List; 28 | 29 | import javax.inject.Inject; 30 | 31 | /** 32 | * Created by ironman on 26/07/16. 33 | */ 34 | public class PostDetailActivity extends AppCompatActivity implements PostDetailView { 35 | public static final String EXTRA_POST_USER_ID = "USER_ID"; 36 | public static final String EXTRA_POST_ID = "POST_ID"; 37 | public static final String EXTRA_POST_TITLE = "POST_TITLE"; 38 | public static final String EXTRA_POST_BODY = "POST_BODY"; 39 | 40 | @Inject 41 | GetPostCommentsInteractor getPostCommentsInteractor; 42 | 43 | private boolean shouldAnimate = true; 44 | private PostDetailPresenter presenter; 45 | private CommentsAdapter commentsAdapter; 46 | private int postId; 47 | 48 | private SwipeRefreshLayout refreshLayout; 49 | private ErrorViewLayout errorLayout; 50 | private RecyclerView commentList; 51 | 52 | public static Intent create(Context context, BlogPost blogPost) { 53 | Intent intent = IntentFactory.createIntent(context, PostDetailActivity.class); 54 | intent.putExtra(PostDetailActivity.EXTRA_POST_USER_ID, blogPost.userId); 55 | intent.putExtra(PostDetailActivity.EXTRA_POST_ID, blogPost.id); 56 | intent.putExtra(PostDetailActivity.EXTRA_POST_TITLE, blogPost.getFormattedTitle()); 57 | intent.putExtra(PostDetailActivity.EXTRA_POST_BODY, blogPost.body); 58 | 59 | return intent; 60 | } 61 | 62 | private BlogPost getPostFromIntent() { 63 | Intent intent = getIntent(); 64 | 65 | return new BlogPost( 66 | intent.getIntExtra(EXTRA_POST_USER_ID, 0), 67 | intent.getIntExtra(EXTRA_POST_ID, 0), 68 | intent.getStringExtra(EXTRA_POST_TITLE), 69 | intent.getStringExtra(EXTRA_POST_BODY) 70 | ); 71 | } 72 | 73 | @Override 74 | protected void onCreate(@Nullable Bundle savedInstanceState) { 75 | super.onCreate(savedInstanceState); 76 | setContentView(R.layout.activity_post_details); 77 | 78 | if (savedInstanceState != null) { 79 | shouldAnimate = false; 80 | } 81 | 82 | MVPApplication.component().inject(this); 83 | setUpActionBar(); 84 | 85 | postId = getIntent().getIntExtra(EXTRA_POST_ID, 0); 86 | presenter = new PostDetailPresenter(this, getPostCommentsInteractor); 87 | commentsAdapter = new CommentsAdapter(new ArrayList(0)); 88 | 89 | setUpViews(); 90 | } 91 | 92 | private void setUpActionBar() { 93 | setSupportActionBar((Toolbar) findViewById(R.id.toolbar)); 94 | ActionBar actionBar = getSupportActionBar(); 95 | assert actionBar != null; 96 | 97 | actionBar.setDisplayHomeAsUpEnabled(true); 98 | actionBar.setDisplayShowHomeEnabled(true); 99 | } 100 | 101 | private void setUpViews() { 102 | BlogPost post = getPostFromIntent(); 103 | ((TextView) findViewById(R.id.postTitle)).setText(post.getFormattedTitle()); 104 | ((TextView) findViewById(R.id.postBody)).setText(post.body); 105 | 106 | refreshLayout = (SwipeRefreshLayout) findViewById(R.id.refreshLayout); 107 | errorLayout = (ErrorViewLayout) findViewById(R.id.errorLayout); 108 | commentList = (RecyclerView) findViewById(R.id.commentList); 109 | 110 | refreshLayout.setColorSchemeColors( 111 | ContextCompat.getColor(this, R.color.colorPrimary), 112 | ContextCompat.getColor(this, R.color.colorAccent), 113 | ContextCompat.getColor(this, R.color.colorPrimaryDark) 114 | ); 115 | 116 | refreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() { 117 | @Override 118 | public void onRefresh() { 119 | shouldAnimate = true; 120 | 121 | commentsAdapter.clear(); 122 | presenter.loadComments(postId, true); 123 | } 124 | }); 125 | 126 | commentList.setAdapter(commentsAdapter); 127 | commentList.setLayoutManager(new LinearLayoutManager(this)); 128 | commentList.setItemAnimator(new ListItemAnimator(CommentsAdapter.ITEM_TYPE_COMMENT)); 129 | 130 | errorLayout.setOnActionPressedListener(new ErrorViewLayout.OnActionPressedListener() { 131 | @Override 132 | public void onActionPressed() { 133 | presenter.loadComments(postId, true); 134 | } 135 | }); 136 | } 137 | 138 | @Override 139 | public void onResume() { 140 | super.onResume(); 141 | 142 | if (commentsAdapter.isEmpty()) { 143 | presenter.loadComments(postId, false); 144 | } 145 | } 146 | 147 | @Override 148 | public void onDestroy() { 149 | super.onDestroy(); 150 | presenter.destroy(); 151 | } 152 | 153 | @Override 154 | public boolean onOptionsItemSelected(MenuItem item) { 155 | if (item.getItemId() == android.R.id.home) { 156 | onBackPressed(); 157 | return true; 158 | } 159 | 160 | return super.onOptionsItemSelected(item); 161 | } 162 | 163 | @Override 164 | public void setProgressIndicator(final boolean active) { 165 | if (refreshLayout == null) { 166 | return; 167 | } 168 | 169 | refreshLayout.post(new Runnable() { 170 | @Override 171 | public void run() { 172 | refreshLayout.setRefreshing(active); 173 | } 174 | }); 175 | } 176 | 177 | @Override 178 | public void showComments(List comments) { 179 | commentsAdapter.setComments(shouldAnimate, comments); 180 | } 181 | 182 | @Override 183 | public void onError(Throwable e) { 184 | refreshLayout.post(new Runnable() { 185 | @Override 186 | public void run() { 187 | refreshLayout.setRefreshing(false); 188 | } 189 | }); 190 | 191 | errorLayout.showError(); 192 | } 193 | 194 | @Override 195 | public void hideError() { 196 | errorLayout.hideError(); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/presentation/postdetails/PostDetailPresenter.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.postdetails; 2 | 3 | import com.codemate.blogreader.domain.interactors.GetPostCommentsInteractor; 4 | import com.codemate.blogreader.domain.model.Comment; 5 | 6 | import java.util.List; 7 | 8 | import rx.Subscriber; 9 | 10 | /** 11 | * Created by ironman on 28/07/16. 12 | */ 13 | public class PostDetailPresenter { 14 | private final PostDetailView postDetailView; 15 | private final GetPostCommentsInteractor getPostCommentsInteractor; 16 | 17 | public PostDetailPresenter(PostDetailView postDetailView, GetPostCommentsInteractor getPostCommentsInteractor) { 18 | this.postDetailView = postDetailView; 19 | this.getPostCommentsInteractor = getPostCommentsInteractor; 20 | } 21 | 22 | public void loadComments(int postId, boolean forceRefresh) { 23 | postDetailView.hideError(); 24 | postDetailView.setProgressIndicator(true); 25 | 26 | getPostCommentsInteractor.setPostId(postId); 27 | getPostCommentsInteractor.setForceRefresh(forceRefresh); 28 | getPostCommentsInteractor.execute(new Subscriber>() { 29 | @Override 30 | public void onCompleted() { 31 | postDetailView.setProgressIndicator(false); 32 | } 33 | 34 | @Override 35 | public void onError(Throwable e) { 36 | postDetailView.onError(e); 37 | } 38 | 39 | @Override 40 | public void onNext(List comments) { 41 | postDetailView.showComments(comments); 42 | } 43 | }); 44 | } 45 | 46 | public void destroy() { 47 | getPostCommentsInteractor.unsubscribe(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/presentation/postdetails/PostDetailView.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.postdetails; 2 | 3 | import com.codemate.blogreader.domain.model.Comment; 4 | 5 | import java.util.List; 6 | 7 | /** 8 | * Created by ironman on 05/08/16. 9 | */ 10 | 11 | public interface PostDetailView { 12 | void setProgressIndicator(boolean active); 13 | 14 | void showComments(List comments); 15 | 16 | void onError(Throwable e); 17 | 18 | void hideError(); 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/presentation/posts/PostsActivity.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.posts; 2 | 3 | import android.os.Bundle; 4 | import android.support.v4.content.ContextCompat; 5 | import android.support.v4.widget.SwipeRefreshLayout; 6 | import android.support.v7.app.AppCompatActivity; 7 | import android.support.v7.widget.LinearLayoutManager; 8 | import android.support.v7.widget.RecyclerView; 9 | import android.support.v7.widget.Toolbar; 10 | 11 | import com.codemate.blogreader.MVPApplication; 12 | import com.codemate.blogreader.R; 13 | import com.codemate.blogreader.domain.interactors.GetPostsInteractor; 14 | import com.codemate.blogreader.domain.model.BlogPost; 15 | import com.codemate.blogreader.presentation.anim.ListItemAnimator; 16 | import com.codemate.blogreader.presentation.postdetails.PostDetailActivity; 17 | import com.codemate.blogreader.presentation.view.ErrorViewLayout; 18 | 19 | import java.util.ArrayList; 20 | import java.util.List; 21 | 22 | import javax.inject.Inject; 23 | 24 | public class PostsActivity extends AppCompatActivity implements PostsView { 25 | @Inject 26 | GetPostsInteractor getPostsInteractor; 27 | 28 | private boolean shouldAnimate = true; 29 | private PostsPresenter presenter; 30 | private PostsAdapter postAdapter; 31 | 32 | private RecyclerView postList; 33 | private ErrorViewLayout errorLayout; 34 | private SwipeRefreshLayout refreshLayout; 35 | 36 | PostItemListener postItemListener = new PostItemListener() { 37 | @Override 38 | public void onPostClicked(BlogPost clickedBlogPost) { 39 | presenter.openPostDetails(clickedBlogPost); 40 | } 41 | }; 42 | 43 | @Override 44 | protected void onCreate(Bundle savedInstanceState) { 45 | super.onCreate(savedInstanceState); 46 | setContentView(R.layout.activity_posts); 47 | 48 | if (savedInstanceState != null) { 49 | shouldAnimate = false; 50 | } 51 | 52 | MVPApplication.component().inject(this); 53 | setUpActionBar(); 54 | 55 | presenter = new PostsPresenter(this, getPostsInteractor); 56 | postAdapter = new PostsAdapter(new ArrayList(0), postItemListener); 57 | 58 | setUpViews(); 59 | } 60 | 61 | private void setUpActionBar() { 62 | Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); 63 | setSupportActionBar(toolbar); 64 | } 65 | 66 | private void setUpViews() { 67 | refreshLayout = (SwipeRefreshLayout) findViewById(R.id.refreshLayout); 68 | errorLayout = (ErrorViewLayout) findViewById(R.id.errorLayout); 69 | postList = (RecyclerView) findViewById(R.id.postList); 70 | 71 | refreshLayout.setColorSchemeColors( 72 | ContextCompat.getColor(this, R.color.colorPrimary), 73 | ContextCompat.getColor(this, R.color.colorAccent), 74 | ContextCompat.getColor(this, R.color.colorPrimaryDark) 75 | ); 76 | 77 | refreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() { 78 | @Override 79 | public void onRefresh() { 80 | shouldAnimate = true; 81 | 82 | postAdapter.clear(); 83 | presenter.loadPosts(true); 84 | } 85 | }); 86 | 87 | postList.setAdapter(postAdapter); 88 | postList.setLayoutManager(new LinearLayoutManager(this)); 89 | postList.setItemAnimator(new ListItemAnimator(PostsAdapter.ITEM_TYPE_BLOG_POST)); 90 | 91 | errorLayout.setOnActionPressedListener(new ErrorViewLayout.OnActionPressedListener() { 92 | @Override 93 | public void onActionPressed() { 94 | presenter.loadPosts(true); 95 | } 96 | }); 97 | } 98 | 99 | @Override 100 | public void onResume() { 101 | super.onResume(); 102 | 103 | if (postAdapter.isEmpty()) { 104 | presenter.loadPosts(false); 105 | } 106 | } 107 | 108 | @Override 109 | public void onDestroy() { 110 | super.onDestroy(); 111 | presenter.destroy(); 112 | } 113 | 114 | @Override 115 | public void setProgressIndicator(final boolean active) { 116 | if (refreshLayout == null) { 117 | return; 118 | } 119 | 120 | refreshLayout.post(new Runnable() { 121 | @Override 122 | public void run() { 123 | refreshLayout.setRefreshing(active); 124 | } 125 | }); 126 | } 127 | 128 | @Override 129 | public void showPosts(List blogPosts) { 130 | postAdapter.setBlogPosts(shouldAnimate, blogPosts); 131 | } 132 | 133 | @Override 134 | public void showPostDetails(BlogPost post) { 135 | startActivity(PostDetailActivity.create(this, post)); 136 | } 137 | 138 | @Override 139 | public void showError(Throwable e) { 140 | refreshLayout.post(new Runnable() { 141 | @Override 142 | public void run() { 143 | refreshLayout.setRefreshing(false); 144 | } 145 | }); 146 | 147 | errorLayout.showError(); 148 | } 149 | 150 | @Override 151 | public void hideError() { 152 | errorLayout.hideError(); 153 | } 154 | 155 | public interface PostItemListener { 156 | void onPostClicked(BlogPost clickedBlogPost); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/presentation/posts/PostsAdapter.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.posts; 2 | 3 | import android.content.Context; 4 | import android.support.v7.widget.RecyclerView; 5 | import android.view.LayoutInflater; 6 | import android.view.View; 7 | import android.view.ViewGroup; 8 | import android.widget.TextView; 9 | 10 | import com.codemate.blogreader.R; 11 | import com.codemate.blogreader.domain.model.BlogPost; 12 | 13 | import java.util.ArrayList; 14 | import java.util.List; 15 | 16 | /** 17 | * Created by ironman on 26/07/16. 18 | */ 19 | public class PostsAdapter extends RecyclerView.Adapter { 20 | public static final int ITEM_TYPE_BLOG_POST = 1; 21 | 22 | private final PostsActivity.PostItemListener postItemListener; 23 | private List blogPosts; 24 | 25 | public PostsAdapter(List blogPosts, PostsActivity.PostItemListener postItemListener) { 26 | this.postItemListener = postItemListener; 27 | this.blogPosts = blogPosts; 28 | } 29 | 30 | @Override 31 | public PostViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 32 | Context context = parent.getContext(); 33 | LayoutInflater inflater = LayoutInflater.from(context); 34 | View postView = inflater.inflate(R.layout.item_post, parent, false); 35 | 36 | return new PostViewHolder(postView, postItemListener); 37 | } 38 | 39 | @Override 40 | public void onBindViewHolder(PostViewHolder holder, int position) { 41 | BlogPost blogPost = blogPosts.get(position); 42 | 43 | holder.title.setText(blogPost.getFormattedTitle()); 44 | holder.body.setText(blogPost.getConcatenatedBody()); 45 | } 46 | 47 | @Override 48 | public int getItemCount() { 49 | return blogPosts.size(); 50 | } 51 | 52 | @Override 53 | public int getItemViewType(int position) { 54 | return ITEM_TYPE_BLOG_POST; 55 | } 56 | 57 | public void setBlogPosts(boolean animated, List blogPosts) { 58 | this.blogPosts = new ArrayList<>(blogPosts); 59 | 60 | if (animated) { 61 | notifyItemRangeInserted(0, blogPosts.size()); 62 | } else { 63 | notifyDataSetChanged(); 64 | } 65 | } 66 | 67 | public boolean isEmpty() { 68 | return getItemCount() == 0; 69 | } 70 | 71 | public void clear() { 72 | blogPosts.clear(); 73 | notifyDataSetChanged(); 74 | } 75 | 76 | public class PostViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { 77 | public TextView title; 78 | public TextView body; 79 | 80 | private PostsActivity.PostItemListener postItemListener; 81 | 82 | public PostViewHolder(View itemView, PostsActivity.PostItemListener postItemListener) { 83 | super(itemView); 84 | 85 | title = (TextView) itemView.findViewById(R.id.postTitle); 86 | body = (TextView) itemView.findViewById(R.id.postBody); 87 | this.postItemListener = postItemListener; 88 | 89 | itemView.setOnClickListener(this); 90 | } 91 | 92 | @Override 93 | public void onClick(View v) { 94 | int position = getAdapterPosition(); 95 | BlogPost blogPost = blogPosts.get(position); 96 | 97 | postItemListener.onPostClicked(blogPost); 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/presentation/posts/PostsPresenter.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.posts; 2 | 3 | import com.codemate.blogreader.domain.interactors.GetPostsInteractor; 4 | import com.codemate.blogreader.domain.model.BlogPost; 5 | 6 | import java.util.List; 7 | 8 | import rx.Subscriber; 9 | 10 | /** 11 | * Created by ironman on 26/07/16. 12 | */ 13 | public class PostsPresenter { 14 | private final PostsView postsView; 15 | private final GetPostsInteractor getPostsInteractor; 16 | 17 | public PostsPresenter(PostsView postsView, GetPostsInteractor getPostsInteractor) { 18 | this.postsView = postsView; 19 | this.getPostsInteractor = getPostsInteractor; 20 | } 21 | 22 | public void loadPosts(boolean forceRefresh) { 23 | postsView.hideError(); 24 | postsView.setProgressIndicator(true); 25 | 26 | getPostsInteractor.setForceRefresh(forceRefresh); 27 | getPostsInteractor.execute(new Subscriber>() { 28 | @Override 29 | public void onCompleted() { 30 | postsView.setProgressIndicator(false); 31 | } 32 | 33 | @Override 34 | public void onError(Throwable e) { 35 | postsView.showError(e); 36 | } 37 | 38 | @Override 39 | public void onNext(List blogPosts) { 40 | postsView.showPosts(blogPosts); 41 | } 42 | }); 43 | } 44 | 45 | public void openPostDetails(BlogPost blogPost) { 46 | postsView.showPostDetails(blogPost); 47 | } 48 | 49 | public void destroy() { 50 | getPostsInteractor.unsubscribe(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/presentation/posts/PostsView.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.posts; 2 | 3 | import com.codemate.blogreader.domain.model.BlogPost; 4 | 5 | import java.util.List; 6 | 7 | /** 8 | * Created by ironman on 26/07/16. 9 | */ 10 | public interface PostsView { 11 | void setProgressIndicator(boolean active); 12 | 13 | void showPosts(List blogPosts); 14 | 15 | void showPostDetails(BlogPost post); 16 | 17 | void showError(Throwable throwable); 18 | 19 | void hideError(); 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/presentation/view/ErrorViewLayout.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.presentation.view; 2 | 3 | import android.content.Context; 4 | import android.util.AttributeSet; 5 | import android.view.View; 6 | import android.view.animation.BounceInterpolator; 7 | import android.widget.FrameLayout; 8 | 9 | import com.codemate.blogreader.R; 10 | 11 | /** 12 | * Created by ironman on 09/08/16. 13 | */ 14 | public class ErrorViewLayout extends FrameLayout implements View.OnClickListener { 15 | private View errorView; 16 | private View actualView; 17 | 18 | private OnActionPressedListener onActionPressedListener; 19 | private boolean errorVisible; 20 | 21 | public interface OnActionPressedListener { 22 | void onActionPressed(); 23 | } 24 | 25 | public ErrorViewLayout(Context context) { 26 | super(context); 27 | init(context); 28 | } 29 | 30 | public ErrorViewLayout(Context context, AttributeSet attrs) { 31 | super(context, attrs); 32 | init(context); 33 | } 34 | 35 | private void init(Context context) { 36 | errorView = View.inflate(context, R.layout.view_error, null); 37 | addView(errorView, 0); 38 | 39 | errorView.findViewById(R.id.errorActionButton).setOnClickListener(this); 40 | } 41 | 42 | @Override 43 | protected void onFinishInflate() { 44 | super.onFinishInflate(); 45 | 46 | actualView = getChildAt(1); 47 | } 48 | 49 | @Override 50 | public void onClick(View v) { 51 | if (onActionPressedListener != null) { 52 | onActionPressedListener.onActionPressed(); 53 | } 54 | } 55 | 56 | public void setOnActionPressedListener(OnActionPressedListener listener) { 57 | this.onActionPressedListener = listener; 58 | } 59 | 60 | public void showError() { 61 | errorView.setVisibility(VISIBLE); 62 | errorView.animate() 63 | .setInterpolator(new BounceInterpolator()) 64 | .setStartDelay(500) 65 | .setDuration(500) 66 | .scaleX(1) 67 | .scaleY(1) 68 | .alpha(1) 69 | .start(); 70 | 71 | actualView.setVisibility(GONE); 72 | } 73 | 74 | public void hideError() { 75 | errorView.setAlpha(0); 76 | errorView.setScaleX(0.5f); 77 | errorView.setScaleY(0.5f); 78 | errorView.setVisibility(GONE); 79 | actualView.setVisibility(VISIBLE); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/util/IntentFactory.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.util; 2 | 3 | import android.content.Context; 4 | import android.content.Intent; 5 | 6 | /** 7 | * A class for creating Intents, that can be intercepted during unit testing. 8 | * See the testMock/util/MockIntentFactory.java and testMock/PostDetailActivityTest.java under the mock Build Variant. 9 | */ 10 | public class IntentFactory { 11 | static IntentFactory instance = new IntentFactory(); 12 | 13 | IntentFactory() {} 14 | 15 | public static Intent createIntent(Context context, Class clazz) { 16 | return IntentFactory.instance.makeIntent(context, clazz); 17 | } 18 | 19 | Intent makeIntent(Context context, Class clazz) { 20 | return new Intent(context, clazz); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/codemate/blogreader/util/ScreenUtils.java: -------------------------------------------------------------------------------- 1 | package com.codemate.blogreader.util; 2 | 3 | import android.content.Context; 4 | import android.util.DisplayMetrics; 5 | import android.view.WindowManager; 6 | 7 | /** 8 | * Created by ironman on 09/08/16. 9 | */ 10 | public class ScreenUtils { 11 | public static int getScreenHeight(Context context) { 12 | DisplayMetrics displayMetrics = new DisplayMetrics(); 13 | WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); 14 | windowManager.getDefaultDisplay().getMetrics(displayMetrics); 15 | 16 | return displayMetrics.heightPixels; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_alert_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodemateLtd/android-architecture-and-testing-sample/a796120e38aa8a09f931165c37c0465a75cf1cdc/app/src/main/res/drawable-hdpi/ic_alert_error.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_alert_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodemateLtd/android-architecture-and-testing-sample/a796120e38aa8a09f931165c37c0465a75cf1cdc/app/src/main/res/drawable-mdpi/ic_alert_error.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_alert_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodemateLtd/android-architecture-and-testing-sample/a796120e38aa8a09f931165c37c0465a75cf1cdc/app/src/main/res/drawable-xhdpi/ic_alert_error.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_alert_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodemateLtd/android-architecture-and-testing-sample/a796120e38aa8a09f931165c37c0465a75cf1cdc/app/src/main/res/drawable-xxhdpi/ic_alert_error.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/ic_alert_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodemateLtd/android-architecture-and-testing-sample/a796120e38aa8a09f931165c37c0465a75cf1cdc/app/src/main/res/drawable-xxxhdpi/ic_alert_error.png -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_post_details.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 13 | 14 | 22 | 23 | 31 | 32 | 38 | 39 | 45 | 46 | 47 | 48 | 49 | 50 | 55 | 56 | 60 | 61 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_posts.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 18 | 19 | 24 | 25 | 29 | 30 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_comment.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 22 | 23 | 31 | 32 | 41 | 42 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_post.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 21 | 22 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_error.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 16 | 17 | 23 | 24 | 31 | 32 |