├── .gitignore
├── .idea
├── compiler.xml
├── copyright
│ └── profiles_settings.xml
├── encodings.xml
├── gradle.xml
├── markdown-navigator.xml
├── markdown-navigator
│ └── profiles_settings.xml
├── misc.xml
├── modules.xml
├── runConfigurations.xml
└── vcs.xml
├── DataFlow.png
├── README.md
├── build.gradle
├── core
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── karntrehan
│ │ └── posts
│ │ └── core
│ │ ├── application
│ │ ├── BaseActivity.kt
│ │ └── CoreApp.kt
│ │ ├── constants
│ │ └── Constants.kt
│ │ ├── di
│ │ ├── AppModule.kt
│ │ ├── CoreComponent.kt
│ │ ├── ImageModule.kt
│ │ ├── NetworkModule.kt
│ │ └── StorageModule.kt
│ │ ├── extensions
│ │ ├── NetworkingExtensions.kt
│ │ ├── OutcomePublishMapper.kt
│ │ └── ReactiveExtensions.kt
│ │ ├── networking
│ │ ├── AppScheduler.kt
│ │ ├── Outcome.kt
│ │ ├── Scheduler.kt
│ │ └── synk
│ │ │ ├── Synk.kt
│ │ │ └── SynkKeys.kt
│ │ └── testing
│ │ ├── DependencyProvider.kt
│ │ └── TestScheduler.kt
│ └── res
│ ├── values-v21
│ └── styles.xml
│ └── values
│ ├── colors.xml
│ ├── dimens.xml
│ └── styles.xml
├── gradle.properties
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── posts.gif
├── posts
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── karntrehan
│ │ └── posts
│ │ ├── details
│ │ └── model
│ │ │ └── DetailsLocalDataTest.kt
│ │ └── list
│ │ └── model
│ │ └── ListLocalDataTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── karntrehan
│ │ │ └── posts
│ │ │ ├── commons
│ │ │ ├── PostDH.kt
│ │ │ ├── data
│ │ │ │ ├── PostWithData.kt
│ │ │ │ ├── local
│ │ │ │ │ ├── Comment.kt
│ │ │ │ │ ├── CommentDao.kt
│ │ │ │ │ ├── Post.kt
│ │ │ │ │ ├── PostDao.kt
│ │ │ │ │ ├── PostDb.kt
│ │ │ │ │ ├── User.kt
│ │ │ │ │ └── UserDao.kt
│ │ │ │ └── remote
│ │ │ │ │ └── PostService.kt
│ │ │ └── testing
│ │ │ │ └── DummyData.kt
│ │ │ ├── details
│ │ │ ├── DetailsActivity.kt
│ │ │ ├── DetailsAdapter.kt
│ │ │ ├── di
│ │ │ │ ├── DetailsComponent.kt
│ │ │ │ └── DetailsScope.kt
│ │ │ ├── exceptions
│ │ │ │ └── DetailsExceptions.kt
│ │ │ ├── model
│ │ │ │ ├── DetailsDataContract.kt
│ │ │ │ ├── DetailsLocalData.kt
│ │ │ │ ├── DetailsRemoteData.kt
│ │ │ │ └── DetailsRepository.kt
│ │ │ └── viewmodel
│ │ │ │ ├── DetailsViewModel.kt
│ │ │ │ └── DetailsViewModelFactory.kt
│ │ │ └── list
│ │ │ ├── ListActivity.kt
│ │ │ ├── PostListAdapter.kt
│ │ │ ├── di
│ │ │ ├── ListComponent.kt
│ │ │ └── ListScope.kt
│ │ │ ├── model
│ │ │ ├── ListDataContract.kt
│ │ │ ├── ListLocalData.kt
│ │ │ ├── ListRemoteData.kt
│ │ │ └── ListRepository.kt
│ │ │ └── viewmodel
│ │ │ ├── ListViewModel.kt
│ │ │ └── ListViewModelFactory.kt
│ ├── res
│ │ ├── drawable-v24
│ │ │ └── ic_launcher_foreground.xml
│ │ ├── drawable
│ │ │ └── ic_launcher_background.xml
│ │ ├── layout
│ │ │ ├── activity_details.xml
│ │ │ ├── activity_list.xml
│ │ │ ├── comment_item.xml
│ │ │ └── post_item.xml
│ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ └── ic_launcher_round.png
│ │ └── values
│ │ │ ├── dimens.xml
│ │ │ └── strings.xml
│ └── resources
│ │ └── api-response
│ │ ├── comments.json
│ │ ├── posts.json
│ │ └── users.json
│ └── test
│ └── java
│ └── com
│ └── karntrehan
│ └── posts
│ ├── commons
│ └── data
│ │ └── remote
│ │ └── PostServiceTest.kt
│ ├── details
│ ├── model
│ │ ├── DetailsRemoteDataTest.kt
│ │ └── DetailsRepositoryTest.kt
│ └── viewmodel
│ │ └── DetailsViewModelTest.kt
│ └── list
│ ├── model
│ ├── ListRemoteDataTest.kt
│ └── ListRepositoryTest.kt
│ └── viewmodel
│ └── ListViewModelTest.kt
└── 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 | .externalNativeBuild
10 | .idea
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/copyright/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
20 |
21 |
--------------------------------------------------------------------------------
/.idea/markdown-navigator.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/.idea/markdown-navigator/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | 1.8
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/DataFlow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/DataFlow.png
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Posts
2 | A sample app to demonstrate the building of a good, modular and scalable Android app using Kotlin, Android Architecture Components (LiveData, ViewModel & Room), Dagger, RxJava and RxAndroid among others.
3 |
4 | # Features
5 | Some of the features of the app include
6 |
7 | - **Effective Networking** - Using a combination of Retrofit, Rx, Room and LiveData, we are able to handle networking in the most effective way.
8 |
9 | - **Modular** - The app is broken into modules of features and libraries which can be combined to build instant-apps, complete apps or lite version of apps.
10 |
11 | - **MVVM architecture** - Using the lifecycle aware viewmodels, the view observes changes in the model / repository.
12 |
13 | - **Kotlin** - This app is completely written in Kotlin.
14 |
15 | - **Android Architecture Components** - Lifecycle awareness has been achieved using a combination of LiveData, ViewModels and Room.
16 |
17 | - **Offline first architecture** - All the data is first tried to be loaded from the db and then updated from the server. This ensures that the app is usable even in an offline mode.
18 |
19 | - **Intelligent sync** -Intelligent hybrid syncing logic makes sure your Android app does not make repeated calls to the same back-end API for the same data in a particular time period.
20 |
21 | - **Dependency Injection** - Common elements like `context`, `networking` interface are injected using Dagger 2.
22 |
23 | - **Feature based packaging** - This screen-wise / feature-wise packaging makes code really easy to read and debug.
24 |
25 | # Working
26 | 
27 |
28 | # Networking
29 | 
30 |
31 | ### [Activity](posts/src/main/java/com/karntrehan/posts/list/ListActivity.kt)
32 | ```java
33 | viewModel.getPosts()
34 | ```
35 |
36 | ### [ViewModel](posts/src/main/java/com/karntrehan/posts/list/viewmodel/ListViewModel.kt)
37 | ```java
38 | fun getPosts() {
39 | if (postsOutcome.value == null)
40 | repo.fetchPosts()
41 | }
42 | ```
43 |
44 | ### [Repository](posts/src/main/java/com/karntrehan/posts/list/model/ListRepository.kt)
45 | ```java
46 | val postFetchOutcome: PublishSubject>> = PublishSubject.create>>()
47 |
48 | override fun fetchPosts() {
49 | postFetchOutcome.loading(true)
50 | //Observe changes to the db
51 | local.getPostsWithUsers()
52 | .performOnBackOutOnMain(scheduler)
53 | .doAfterNext {
54 | if (Synk.shouldSync(SynkKeys.POSTS_HOME, 2, TimeUnit.HOURS))
55 | refreshPosts()
56 | }
57 | .subscribe({ retailers ->
58 | postFetchOutcome.success(retailers)
59 | }, { error -> handleError(error) })
60 | .addTo(compositeDisposable)
61 | }
62 |
63 | override fun refreshPosts() {
64 | postFetchOutcome.loading(true)
65 | Flowable.zip(
66 | remote.getUsers(),
67 | remote.getPosts(),
68 | zipUsersAndPosts()
69 | )
70 | .performOnBackOutOnMain(scheduler)
71 | .updateSynkStatus(key = SynkKeys.POSTS_HOME)
72 | .subscribe({}, { error -> handleError(error) })
73 | .addTo(compositeDisposable)
74 | }
75 |
76 | private fun zipUsersAndPosts() =
77 | BiFunction, List, Unit> { users, posts ->
78 | saveUsersAndPosts(users, posts)
79 | }
80 |
81 | override fun saveUsersAndPosts(users: List, posts: List) {
82 | local.saveUsersAndPosts(users, posts)
83 | }
84 |
85 | override fun handleError(error: Throwable) {
86 | postFetchOutcome.failed(error)
87 | }
88 | ```
89 |
90 | ### [ViewModel](posts/src/main/java/com/karntrehan/posts/list/viewmodel/ListViewModel.kt) ###
91 | ```java
92 | val postsOutcome: LiveData>> by lazy {
93 | //Convert publish subject to livedata
94 | repo.postFetchOutcome.toLiveData(compositeDisposable)
95 | }
96 | ```
97 |
98 | ### [Activity](posts/src/main/java/com/karntrehan/posts/list/ListActivity.kt)
99 | ```java
100 | viewModel.postsOutcome.observe(this, Observer>> { outcome ->
101 | when (outcome) {
102 |
103 | is Outcome.Progress -> srlPosts.isRefreshing = outcome.loading
104 |
105 | is Outcome.Success -> {
106 | Log.d(TAG, "initiateDataListener: Successfully loaded data")
107 | adapter.setData(outcome.data)
108 | }
109 |
110 | is Outcome.Failure -> {
111 | if (outcome.e is IOException)
112 | Toast.makeText(context, R.string.need_internet_posts, Toast.LENGTH_LONG).show()
113 | else
114 | Toast.makeText(context, R.string.failed_post_try_again, Toast.LENGTH_LONG).show()
115 | }
116 |
117 | }
118 | })
119 | ```
120 |
121 |
122 | # Testing:
123 | To run all the unit tests, run `./gradlew test`. This would test the repositories and the viewmodels.
124 |
125 | To run all the instrumented tests, run `./gradlew connectedAndroidTest`. This would test the LocalDataSources (Room)
126 |
127 | # Build info:
128 | - Android Studio - 3.1 Canary 8
129 | - Compile SDK - 28
130 | - MinSDK - 16, Target - 28
131 |
132 | # Articles
133 | To read more about the architecture choices and the decisions behind this project, kindly refer to the following articles:
134 | * [Effective Networking On Android using Retrofit, Rx and Architecture Components](https://medium.com/mindorks/effective-networking-on-android-using-retrofit-rx-and-architecture-components-4554ca5b167d)
135 | * [Writing a modular project on Android](https://medium.com/mindorks/writing-a-modular-project-on-android-304f3b09cb37)
136 | * [To Synk or not to Synk](https://medium.com/mindorks/to-synk-or-not-to-synk-fcc6e4c56e14)
137 |
138 | **Talk to the developer about this project**: [@karntrehan](https://twitter.com/karntrehan)
139 |
140 | # Other samples
141 | Below are some of the other samples I have opensourced:
142 | * [Starwars](https://github.com/karntrehan/Starwars) : 2019 - A sample modular Android app written in Kotlin using Rx, Koin, Coroutines, Dagger 2 and Architecture components
143 | * [Agni](https://github.com/karntrehan/Agni) : 2019 - Android app template for modular apps with Dagger 2, Coroutines, LiveData, ViewModel and RxJava 2.
144 | * [Talko](https://github.com/karntrehan/Talko) : 2019 - A sample messaging UI app for Android writen in Kotlin with a working local persistence layer.
145 |
146 | # Libraries used
147 | * [Android Support Libraries](https://developer.android.com/topic/libraries/support-library/index.html)
148 | * [Dagger 2](https://google.github.io/dagger/)
149 | * [Retrofit](http://square.github.io/retrofit/)
150 | * [OkHttp](http://square.github.io/okhttp/)
151 | * [Picasso](http://square.github.io/picasso/)
152 | * [Stetho](http://facebook.github.io/stetho/)
153 | * [Room](https://developer.android.com/topic/libraries/architecture/room.html)
154 | * [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel.html)
155 | * [LiveData](https://developer.android.com/topic/libraries/architecture/livedata.html)
156 | * [RxJava](https://github.com/ReactiveX/RxJava)
157 | * [RxAndroid](https://github.com/ReactiveX/RxAndroid)
158 |
159 | # License
160 |
161 | Copyright 2018 Karan Trehan
162 |
163 | Licensed under the Apache License, Version 2.0 (the "License");
164 | you may not use this file except in compliance with the License.
165 | You may obtain a copy of the License at
166 |
167 | http://www.apache.org/licenses/LICENSE-2.0
168 |
169 | Unless required by applicable law or agreed to in writing, software
170 | distributed under the License is distributed on an "AS IS" BASIS,
171 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
172 | See the License for the specific language governing permissions and
173 | limitations under the License.
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 |
3 | buildscript {
4 | ext.versions = [
5 | 'minSdk' : 16,
6 | 'compileSdk' : 28,
7 |
8 | 'buildTools' : '28.0.3',
9 | 'androidPlugin' : '3.2.1',
10 | 'kotlin' : '1.3.11',
11 | 'gms' : '3.1.0',
12 | 'dagger' : '2.12',
13 | 'gson' : '2.8.0',
14 | 'retrofit' : '2.3.0',
15 | 'retrofitRxAdapter': '1.0.0',
16 | 'okhttp' : '3.9.0',
17 | 'okhttpDownloader' : '1.1.0',
18 | 'picasso' : '2.5.2',
19 | 'rxJava' : '2.1.7',
20 | 'rxAndroid' : '2.0.1',
21 | 'jodaTime' : "2.9.9",
22 | 'stetho' : '1.5.0',
23 | 'junit' : '4.12',
24 | 'atsl' : '1.0.1',
25 | 'mockitoKotlin' : '1.5.0',
26 | 'robolectric' : '4.3',
27 | 'sourceCompat' : JavaVersion.VERSION_1_8,
28 | 'targetCompat' : JavaVersion.VERSION_1_8
29 | ]
30 |
31 |
32 | ext.deps = [
33 | android : [
34 | 'gradlePlugin' : "com.android.tools.build:gradle:${versions.androidPlugin}",
35 | 'lifecycleExt' : 'androidx.lifecycle:lifecycle-extensions:2.0.0',
36 | 'lifecycleCommon': 'androidx.lifecycle:lifecycle-common-java8:2.0.0',
37 | 'roomRuntime' : 'androidx.room:room-runtime:2.0.0',
38 | 'roomCompiler' : 'androidx.room:room-compiler:2.0.0',
39 | 'roomRx' : 'androidx.room:room-rxjava2:2.0.0',
40 | ],
41 | support : [
42 | 'appCompat' : 'androidx.appcompat:appcompat:1.0.0',
43 | 'recyclerView' : 'androidx.recyclerview:recyclerview:1.0.0',
44 | 'cardView' : 'androidx.cardview:cardview:1.0.0',
45 | 'support' : 'androidx.legacy:legacy-support-v4:1.0.0',
46 | 'designSupport': 'com.google.android.material:material:1.0.0',
47 | ],
48 | kotlin : [
49 | 'gradlePlugin': "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}",
50 | 'stdlib7' : "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}",
51 | ],
52 | reactivex : [
53 | 'rxJava' : "io.reactivex.rxjava2:rxjava:${versions.rxJava}",
54 | 'rxAndroid': "io.reactivex.rxjava2:rxandroid:${versions.rxAndroid}"
55 | ],
56 | google : [
57 | 'gmsPlugin' : "com.google.gms:google-services:${versions.gms}",
58 | 'dagger' : "com.google.dagger:dagger:${versions.dagger}",
59 | 'daggerProcessor': "com.google.dagger:dagger-compiler:${versions.dagger}",
60 | 'gson' : "com.google.code.gson:gson:${versions.gson}",
61 | ],
62 | square : [
63 | 'retrofit' : "com.squareup.retrofit2:retrofit:${versions.retrofit}",
64 | 'gsonConverter' : "com.squareup.retrofit2:converter-gson:${versions.retrofit}",
65 | 'okhttp' : "com.squareup.okhttp3:okhttp:${versions.okhttp}",
66 | 'picasso' : "com.squareup.picasso:picasso:${versions.picasso}",
67 | 'okhttpDownloader' : "com.jakewharton.picasso:picasso2-okhttp3-downloader:${versions.okhttpDownloader}",
68 | 'retrofitRxAdapter': "com.jakewharton.retrofit:retrofit2-rxjava2-adapter:${versions.retrofitRxAdapter}",
69 | ],
70 | facebook : [
71 | 'stetho' : "com.facebook.stetho:stetho:${versions.stetho}",
72 | 'networkInterceptor': "com.facebook.stetho:stetho-okhttp3:${versions.stetho}"
73 | ],
74 | additional: [
75 | 'jodaTime': "joda-time:joda-time:${versions.jodaTime}"
76 | ],
77 | test : [
78 | 'junit' : "junit:junit:${versions.junit}",
79 | 'atslRunner' : 'androidx.test.ext:junit:1.1.0',
80 | 'atslRules' : 'androidx.test:rules:1.1.0',
81 | 'mockitoKotlin' : "com.nhaarman:mockito-kotlin:${versions.mockitoKotlin}",
82 | 'robolectric' : "org.robolectric:robolectric:${versions.robolectric}",
83 | 'mockWebServer' : "com.squareup.okhttp3:mockwebserver:${versions.okhttp}",
84 | 'roomTesting' : 'androidx.room:room-testing:2.0.0',
85 | 'livedataTesting': 'androidx.arch.core:core-testing:2.0.0',
86 | ]
87 | ]
88 | repositories {
89 | google()
90 | jcenter()
91 | }
92 | dependencies {
93 | classpath deps.android.gradlePlugin
94 | classpath deps.google.gmsPlugin
95 | classpath deps.kotlin.gradlePlugin
96 |
97 | // NOTE: Do not place your application dependencies here; they belong
98 | // in the individual module build.gradle files
99 | }
100 | }
101 |
102 | allprojects {
103 | repositories {
104 | google()
105 | jcenter()
106 | }
107 | }
108 |
109 | task clean(type: Delete) {
110 | delete rootProject.buildDir
111 | }
112 |
--------------------------------------------------------------------------------
/core/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/core/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.library'
2 | apply plugin: 'kotlin-android'
3 | apply plugin: 'kotlin-kapt'
4 | apply plugin: 'kotlin-android-extensions'
5 |
6 | android {
7 | compileSdkVersion versions.compileSdk
8 | buildToolsVersion versions.buildTools
9 |
10 | defaultConfig {
11 | minSdkVersion versions.minSdk
12 | targetSdkVersion versions.compileSdk
13 | versionCode 1
14 | versionName "1.0"
15 |
16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
17 |
18 | }
19 |
20 | buildTypes {
21 | release {
22 | postprocessing {
23 | removeUnusedCode false
24 | removeUnusedResources false
25 | obfuscate false
26 | optimizeCode false
27 | proguardFile 'proguard-rules.pro'
28 | }
29 | buildConfigField 'String', 'BASE_URL', '"http://jsonplaceholder.typicode.com/"'
30 | }
31 | debug {
32 | buildConfigField 'String', 'BASE_URL', '"http://jsonplaceholder.typicode.com/"'
33 | }
34 | }
35 |
36 | compileOptions {
37 | sourceCompatibility versions.sourceCompat
38 | targetCompatibility versions.targetCompat
39 | }
40 | }
41 |
42 | dependencies {
43 | api fileTree(include: ['*.jar'], dir: 'libs')
44 | api deps.support.appCompat
45 | api deps.support.recyclerView
46 | api deps.support.cardView
47 | api deps.support.support
48 | api deps.support.designSupport
49 |
50 | api deps.android.lifecycleExt
51 | api deps.android.lifecycleCommon
52 | api deps.android.roomRuntime
53 | api deps.android.roomRx
54 |
55 | api deps.kotlin.stdlib7
56 |
57 | api deps.reactivex.rxJava
58 | api deps.reactivex.rxAndroid
59 |
60 | api deps.google.dagger
61 | kapt deps.google.daggerProcessor
62 | api deps.google.gson
63 |
64 | api deps.square.picasso
65 | api deps.square.okhttpDownloader
66 |
67 | api deps.square.retrofit
68 | api deps.square.okhttp
69 | api deps.square.gsonConverter
70 | api deps.square.retrofitRxAdapter
71 |
72 | api deps.additional.jodaTime
73 |
74 | api deps.facebook.stetho
75 | api deps.facebook.networkInterceptor
76 | }
77 |
--------------------------------------------------------------------------------
/core/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/core/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/application/BaseActivity.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.application
2 |
3 | import android.os.Bundle
4 | import androidx.appcompat.app.AppCompatActivity
5 | import android.view.MenuItem
6 |
7 | abstract class BaseActivity:AppCompatActivity(){
8 | override fun onCreate(savedInstanceState: Bundle?) {
9 | super.onCreate(savedInstanceState)
10 |
11 | setUpToolbar()
12 | }
13 |
14 | private fun setUpToolbar() {
15 | supportActionBar?.setDisplayHomeAsUpEnabled(true)
16 | }
17 |
18 | override fun onOptionsItemSelected(item: MenuItem): Boolean {
19 | val id = item.itemId
20 | if (id == android.R.id.home) {
21 | finish()
22 | }
23 | return super.onOptionsItemSelected(item)
24 | }
25 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/application/CoreApp.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.application
2 |
3 | import android.app.Application
4 | import com.facebook.stetho.Stetho
5 | import com.karntrehan.posts.core.BuildConfig
6 | import com.karntrehan.posts.core.di.AppModule
7 | import com.karntrehan.posts.core.di.CoreComponent
8 | import com.karntrehan.posts.core.di.DaggerCoreComponent
9 | import com.karntrehan.posts.core.networking.synk.Synk
10 |
11 | open class CoreApp : Application() {
12 |
13 | companion object {
14 | lateinit var coreComponent: CoreComponent
15 | }
16 |
17 | override fun onCreate() {
18 | super.onCreate()
19 | initSynk()
20 | initDI()
21 | initStetho()
22 | }
23 |
24 | private fun initSynk() {
25 | Synk.init(this)
26 | }
27 |
28 | private fun initStetho() {
29 | if (BuildConfig.DEBUG)
30 | Stetho.initializeWithDefaults(this)
31 | }
32 |
33 | private fun initDI() {
34 | coreComponent = DaggerCoreComponent.builder().appModule(AppModule(this)).build()
35 | }
36 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/constants/Constants.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.constants
2 |
3 | import com.karntrehan.posts.core.BuildConfig
4 |
5 | object Constants {
6 | val API_URL = BuildConfig.BASE_URL
7 |
8 | object Posts {
9 | val DB_NAME = "posts_db"
10 | }
11 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/di/AppModule.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.di
2 |
3 | import android.content.Context
4 | import com.karntrehan.posts.core.networking.AppScheduler
5 | import com.karntrehan.posts.core.networking.Scheduler
6 | import dagger.Module
7 | import dagger.Provides
8 | import javax.inject.Singleton
9 |
10 | @Module
11 | class AppModule(val context: Context) {
12 | @Provides
13 | @Singleton
14 | fun providesContext(): Context {
15 | return context
16 | }
17 |
18 | @Provides
19 | @Singleton
20 | fun scheduler(): Scheduler {
21 | return AppScheduler()
22 | }
23 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/di/CoreComponent.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.di
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import com.karntrehan.posts.core.networking.Scheduler
6 | import com.squareup.picasso.Picasso
7 | import dagger.Component
8 | import retrofit2.Retrofit
9 | import javax.inject.Singleton
10 |
11 | @Singleton
12 | @Component(modules = [AppModule::class, NetworkModule::class, StorageModule::class, ImageModule::class])
13 | interface CoreComponent {
14 |
15 | fun context(): Context
16 |
17 | fun retrofit(): Retrofit
18 |
19 | fun picasso(): Picasso
20 |
21 | fun sharedPreferences(): SharedPreferences
22 |
23 | fun scheduler(): Scheduler
24 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/di/ImageModule.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.di
2 |
3 | import android.content.Context
4 | import com.jakewharton.picasso.OkHttp3Downloader
5 | import com.squareup.picasso.Picasso
6 | import dagger.Module
7 | import dagger.Provides
8 | import okhttp3.OkHttpClient
9 | import javax.inject.Singleton
10 |
11 | @Module
12 | class ImageModule {
13 | @Provides
14 | @Singleton
15 | fun providesOkhttp3Downloader(okHttpClient: OkHttpClient): OkHttp3Downloader {
16 | return OkHttp3Downloader(okHttpClient)
17 | }
18 |
19 | @Provides
20 | @Singleton
21 | fun providesPicasso(context: Context, okHttp3Downloader: OkHttp3Downloader): Picasso {
22 | return Picasso.Builder(context)
23 | .downloader(okHttp3Downloader)
24 | .build()
25 | }
26 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/di/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.di
2 |
3 | import android.content.Context
4 | import com.facebook.stetho.okhttp3.StethoInterceptor
5 | import com.google.gson.Gson
6 | import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
7 | import com.karntrehan.posts.core.BuildConfig
8 | import com.karntrehan.posts.core.constants.Constants
9 | import dagger.Module
10 | import dagger.Provides
11 | import okhttp3.Cache
12 | import okhttp3.OkHttpClient
13 | import retrofit2.Retrofit
14 | import retrofit2.converter.gson.GsonConverterFactory
15 | import java.util.concurrent.TimeUnit
16 | import javax.inject.Singleton
17 |
18 | @Module
19 | class NetworkModule {
20 |
21 | @Provides
22 | @Singleton
23 | fun providesRetrofit(
24 | gsonConverterFactory: GsonConverterFactory,
25 | rxJava2CallAdapterFactory: RxJava2CallAdapterFactory,
26 | okHttpClient: OkHttpClient
27 | ): Retrofit {
28 | return Retrofit.Builder().baseUrl(Constants.API_URL)
29 | .addConverterFactory(gsonConverterFactory)
30 | .addCallAdapterFactory(rxJava2CallAdapterFactory)
31 | .client(okHttpClient)
32 | .build()
33 | }
34 |
35 | @Provides
36 | @Singleton
37 | fun providesOkHttpClient(cache: Cache): OkHttpClient {
38 | val client = OkHttpClient.Builder()
39 | .cache(cache)
40 | .connectTimeout(10, TimeUnit.SECONDS)
41 | .writeTimeout(30, TimeUnit.SECONDS)
42 | .readTimeout(10, TimeUnit.SECONDS)
43 |
44 | if (BuildConfig.DEBUG)
45 | client.addNetworkInterceptor(StethoInterceptor())
46 |
47 | return client.build()
48 | }
49 |
50 | @Provides
51 | @Singleton
52 | fun providesOkhttpCache(context: Context): Cache {
53 | val cacheSize = 10 * 1024 * 1024 // 10 MB
54 | return Cache(context.cacheDir, cacheSize.toLong())
55 | }
56 |
57 | @Provides
58 | @Singleton
59 | fun providesGson(): Gson {
60 | return Gson()
61 | }
62 |
63 | @Provides
64 | @Singleton
65 | fun providesGsonConverterFactory(): GsonConverterFactory {
66 | return GsonConverterFactory.create()
67 | }
68 |
69 | @Provides
70 | @Singleton
71 | fun providesRxJavaCallAdapterFactory(): RxJava2CallAdapterFactory {
72 | return RxJava2CallAdapterFactory.create()
73 | }
74 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/di/StorageModule.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.di
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import android.preference.PreferenceManager
6 | import dagger.Module
7 | import dagger.Provides
8 | import javax.inject.Singleton
9 |
10 | @Module
11 | class StorageModule {
12 | @Provides
13 | @Singleton
14 | fun providesSharedPreferences(context: Context): SharedPreferences {
15 | return PreferenceManager.getDefaultSharedPreferences(context)
16 | }
17 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/extensions/NetworkingExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.extensions
2 |
3 | import com.karntrehan.posts.core.networking.synk.Synk
4 | import io.reactivex.Single
5 |
6 | /**
7 | * Extension function to update [Synk] about a remote call's status
8 | **/
9 | fun Single.updateSynkStatus(key: String): Single {
10 | return this.doOnSuccess { Synk.syncSuccess(key = key) }
11 | .doOnError { Synk.syncFailure(key = key) }
12 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/extensions/OutcomePublishMapper.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.extensions
2 |
3 | import androidx.lifecycle.LiveData
4 | import androidx.lifecycle.MutableLiveData
5 | import com.mpaani.core.networking.Outcome
6 | import io.reactivex.disposables.CompositeDisposable
7 | import io.reactivex.subjects.PublishSubject
8 |
9 | /**
10 | * Created by karn on 18/1/18.
11 | **/
12 |
13 | /**
14 | * Extension function to convert a Publish subject into a LiveData by subscribing to it.
15 | **/
16 | fun PublishSubject.toLiveData(compositeDisposable: CompositeDisposable): LiveData {
17 | val data = MutableLiveData()
18 | compositeDisposable.add(this.subscribe({ t: T -> data.value = t }))
19 | return data
20 | }
21 |
22 | /**
23 | * Extension function to push a failed event with an exception to the observing outcome
24 | * */
25 | fun PublishSubject>.failed(e: Throwable) {
26 | with(this){
27 | loading(false)
28 | onNext(Outcome.failure(e))
29 | }
30 | }
31 |
32 | /**
33 | * Extension function to push a success event with data to the observing outcome
34 | * */
35 | fun PublishSubject>.success(t: T) {
36 | with(this){
37 | loading(false)
38 | onNext(Outcome.success(t))
39 | }
40 | }
41 |
42 | /**
43 | * Extension function to push the loading status to the observing outcome
44 | * */
45 | fun PublishSubject>.loading(isLoading: Boolean) {
46 | this.onNext(Outcome.loading(isLoading))
47 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/extensions/ReactiveExtensions.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.extensions
2 |
3 | import com.karntrehan.posts.core.networking.Scheduler
4 | import io.reactivex.Completable
5 | import io.reactivex.Flowable
6 | import io.reactivex.Observable
7 | import io.reactivex.Single
8 | import io.reactivex.disposables.CompositeDisposable
9 | import io.reactivex.disposables.Disposable
10 |
11 | /**
12 | * Extension function to subscribe on the background thread and observe on the main thread for a [Completable]
13 | * */
14 | fun Completable.performOnBackOutOnMain(scheduler: Scheduler): Completable {
15 | return this.subscribeOn(scheduler.io())
16 | .observeOn(scheduler.mainThread())
17 | }
18 |
19 | /**
20 | * Extension function to subscribe on the background thread and observe on the main thread for a [Flowable]
21 | * */
22 | fun Flowable.performOnBackOutOnMain(scheduler: Scheduler): Flowable {
23 | return this.subscribeOn(scheduler.io())
24 | .observeOn(scheduler.mainThread())
25 | }
26 |
27 | /**
28 | * Extension function to subscribe on the background thread and observe on the main thread for a [Single]
29 | * */
30 | fun Single.performOnBackOutOnMain(scheduler: Scheduler): Single {
31 | return this.subscribeOn(scheduler.io())
32 | .observeOn(scheduler.mainThread())
33 | }
34 |
35 | /**
36 | * Extension function to subscribe on the background thread and observe on the main thread for a [Observable]
37 | * */
38 | fun Observable.performOnBackOutOnMain(scheduler: Scheduler): Observable {
39 | return this.subscribeOn(scheduler.io())
40 | .observeOn(scheduler.mainThread())
41 | }
42 |
43 | /**
44 | * Extension function to add a Disposable to a CompositeDisposable
45 | */
46 | fun Disposable.addTo(compositeDisposable: CompositeDisposable) {
47 | compositeDisposable.add(this)
48 | }
49 |
50 | /**
51 | * Extension function to subscribe on the background thread for a Flowable
52 | * */
53 | fun Flowable.performOnBack(scheduler: Scheduler): Flowable {
54 | return this.subscribeOn(scheduler.io())
55 | }
56 |
57 | /**
58 | * Extension function to subscribe on the background thread for a Completable
59 | * */
60 | fun Completable.performOnBack(scheduler: Scheduler): Completable {
61 | return this.subscribeOn(scheduler.io())
62 | }
63 |
64 | /**
65 | * Extension function to subscribe on the background thread for a Observable
66 | * */
67 | fun Observable.performOnBack(scheduler: Scheduler): Observable {
68 | return this.subscribeOn(scheduler.io())
69 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/networking/AppScheduler.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.networking
2 |
3 | import io.reactivex.android.schedulers.AndroidSchedulers
4 | import io.reactivex.schedulers.Schedulers
5 |
6 | /**
7 | * Implementation of [Scheduler] with actual threads.
8 | * */
9 | class AppScheduler : Scheduler {
10 |
11 | override fun mainThread(): io.reactivex.Scheduler {
12 | return AndroidSchedulers.mainThread()
13 | }
14 |
15 | override fun io(): io.reactivex.Scheduler {
16 | return Schedulers.io()
17 | }
18 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/networking/Outcome.kt:
--------------------------------------------------------------------------------
1 | package com.mpaani.core.networking
2 |
3 | /**
4 | * Created by karn on 18/1/18.
5 | */
6 | sealed class Outcome {
7 | data class Progress(var loading: Boolean) : Outcome()
8 | data class Success(var data: T) : Outcome()
9 | data class Failure(val e: Throwable) : Outcome()
10 |
11 | companion object {
12 | fun loading(isLoading: Boolean): Outcome = Progress(isLoading)
13 |
14 | fun success(data: T): Outcome = Success(data)
15 |
16 | fun failure(e: Throwable): Outcome = Failure(e)
17 | }
18 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/networking/Scheduler.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.networking
2 |
3 | import io.reactivex.Scheduler
4 |
5 | /**
6 | * Interface to mock different threads during testing.
7 | * */
8 | interface Scheduler {
9 | fun mainThread():Scheduler
10 | fun io():Scheduler
11 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/networking/synk/Synk.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.networking.synk
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import android.preference.PreferenceManager
6 | import org.joda.time.DateTime
7 | import org.joda.time.format.ISODateTimeFormat
8 | import java.util.concurrent.TimeUnit
9 |
10 | /**
11 | * A Singleton object to identify if a sync operation should run or not.
12 | * */
13 | object Synk {
14 |
15 | private var preferences: SharedPreferences? = null
16 | const val TAG = "Synk"
17 | private const val SYNK_IT = true
18 | private const val DONT_SYNK = false
19 |
20 | /**
21 | * Initialize the preference object to read and write sync operation date-times.
22 | * Preferably should be initialized in the application class.
23 | **/
24 | fun init(context: Context) {
25 | preferences = PreferenceManager.getDefaultSharedPreferences(context)
26 | }
27 |
28 | /**
29 | * Check if the sync operation should run or not.
30 | *
31 | * Note: Requires the [init] function to be called beforehand once per application run time.
32 | *
33 | * @param key key of the sync operation
34 | * @param window amount of the delay between successive syncs
35 | * @param unit [TimeUnit] of [window]
36 | * @return [Boolean] to be checked if the sync should run
37 | *
38 | * @throws IllegalStateException if [unit] is [TimeUnit.MILLISECONDS], [TimeUnit.NANOSECONDS], [TimeUnit.MICROSECONDS] or [TimeUnit.SECONDS]
39 | **/
40 | fun shouldSync(key: String, window: Int = 4, unit: TimeUnit = TimeUnit.HOURS): Boolean {
41 |
42 | performPrefsSanityCheck()
43 |
44 | if (unit == TimeUnit.MILLISECONDS
45 | || unit == TimeUnit.NANOSECONDS
46 | || unit == TimeUnit.MICROSECONDS
47 | || unit == TimeUnit.SECONDS
48 | )
49 | throw IllegalStateException("Illegal time window")
50 |
51 | val currentSavedValue = preferences?.getString(key, "")
52 |
53 | if (currentSavedValue.isNullOrEmpty()) //Operation has never run or Synk doesn't know about it
54 | return syncIt(key)
55 |
56 | val syncedTime = DateTime.parse(currentSavedValue)
57 | val syncBlock = when (unit) { //Identify the block window from last sync
58 | TimeUnit.MINUTES -> syncedTime.plusMinutes(window)
59 | TimeUnit.HOURS -> syncedTime.plusHours(window)
60 | TimeUnit.DAYS -> syncedTime.plusDays(window)
61 | else -> syncedTime
62 | }
63 |
64 | //Is the current time past the sync block window?
65 | return if (DateTime.now() >= syncBlock) syncIt(key) else DONT_SYNK
66 | }
67 |
68 | /**
69 | * Tell Synk that the sync operation was a success.
70 | *
71 | * This function saves the current time with the passed key into preferences.
72 | *
73 | * @param key String key of the sync operation
74 | **/
75 | fun syncSuccess(key: String) {
76 | performPrefsSanityCheck()
77 | saveSyncTime(key)
78 | }
79 |
80 | /**
81 | * Tell Synk that the sync operation was a failure.
82 | *
83 | * This function removes the passed key from preferences.
84 | *
85 | * @param key String key of the sync operation
86 | **/
87 | fun syncFailure(key: String) {
88 | performPrefsSanityCheck()
89 | preferences
90 | ?.edit()
91 | ?.remove(key)
92 | ?.apply()
93 | }
94 |
95 | /**
96 | * Checks if the preferences object has been initialized.
97 | *
98 | * @throws IllegalStateException if prefs not initialized
99 | **/
100 | private fun performPrefsSanityCheck() {
101 | if (preferences == null)
102 | throw IllegalStateException("Make sure to init Synk")
103 | }
104 |
105 | /**
106 | * Triggers a save into preferences with current time for mentioned key,
107 | * preventing multiple sync calls if first call is still under progress.
108 | *
109 | * @param key key of the sync operation
110 | **/
111 | private fun syncIt(key: String): Boolean {
112 | saveSyncTime(key)
113 | return SYNK_IT
114 | }
115 |
116 | /**
117 | * Saves the mentioned datetime into the preferences in ISODateTimeFormat string.
118 | *
119 | * @param key key of the sync operation
120 | * @param dateTime [DateTime] to be saved.
121 | * */
122 | private fun saveSyncTime(key: String, dateTime: DateTime = DateTime.now()) {
123 | preferences
124 | ?.edit()
125 | ?.putString(key, ISODateTimeFormat.dateTime().print(dateTime))
126 | ?.apply()
127 | }
128 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/networking/synk/SynkKeys.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.networking.synk
2 |
3 | object SynkKeys {
4 | const val POSTS_HOME = "sync_posts_home"
5 | const val POST_DETAILS = "sync_post_details"
6 |
7 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/testing/DependencyProvider.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.testing
2 |
3 | import android.annotation.SuppressLint
4 | import androidx.annotation.VisibleForTesting
5 | import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
6 | import okhttp3.HttpUrl
7 | import okhttp3.OkHttpClient
8 | import okio.Okio
9 | import retrofit2.Retrofit
10 | import retrofit2.converter.gson.GsonConverterFactory
11 | import java.nio.charset.StandardCharsets
12 | import java.util.concurrent.TimeUnit
13 |
14 | /**
15 | * Only for Testing
16 | * Shouldn't be used in actual production code
17 | */
18 | @VisibleForTesting(otherwise = VisibleForTesting.NONE)
19 | object DependencyProvider {
20 |
21 | /**
22 | * Returns a Retrofit instance for Testing
23 | */
24 | fun getRetrofit(baseUrl: HttpUrl): Retrofit {
25 | return Retrofit.Builder().baseUrl(baseUrl)
26 | .addConverterFactory(GsonConverterFactory.create())
27 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
28 | .client(OkHttpClient.Builder()
29 | .connectTimeout(10, TimeUnit.SECONDS)
30 | .writeTimeout(30, TimeUnit.SECONDS)
31 | .readTimeout(10, TimeUnit.SECONDS).build())
32 | .build()
33 | }
34 |
35 |
36 | /**
37 | *Helper to read a JSON file and return a JSON string
38 | *Note: JSON file should have the structure "module/resources/api-response/filename.json"
39 | * @param fileName: File's name
40 | * @return JSON String
41 | */
42 | @SuppressLint("NewApi")
43 | fun getResponseFromJson(fileName: String): String {
44 | val inputStream = javaClass.classLoader
45 | .getResourceAsStream("api-response/$fileName.json")
46 | val source = Okio.buffer(Okio.source(inputStream))
47 | return source.readString(StandardCharsets.UTF_8)
48 | }
49 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/karntrehan/posts/core/testing/TestScheduler.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.core.testing
2 |
3 | import androidx.annotation.VisibleForTesting
4 | import com.karntrehan.posts.core.networking.Scheduler
5 | import io.reactivex.schedulers.Schedulers
6 |
7 | @VisibleForTesting(otherwise = VisibleForTesting.NONE)
8 | class TestScheduler : Scheduler {
9 |
10 | override fun mainThread(): io.reactivex.Scheduler {
11 | return Schedulers.trampoline()
12 | }
13 |
14 | override fun io(): io.reactivex.Scheduler {
15 | return Schedulers.trampoline()
16 | }
17 | }
--------------------------------------------------------------------------------
/core/src/main/res/values-v21/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
--------------------------------------------------------------------------------
/core/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #009688
4 | #00796B
5 | #B2DFDB
6 | #FFC107
7 | #212121
8 | #757575
9 | #FFFFFF
10 | #BDBDBD
11 | #c0392b
12 |
13 |
--------------------------------------------------------------------------------
/core/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 5dp
4 | 4dp
5 | 4dp
6 | 24dp
7 |
--------------------------------------------------------------------------------
/core/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | #org.gradle.jvmargs=-Xmx1536m
13 |
14 | # When configured, Gradle will run in incubating parallel mode.
15 | # This option should only be used with decoupled projects. More details, visit
16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
17 | # org.gradle.parallel=true
18 | android.enableJetifier=true
19 | android.useAndroidX=true
20 | org.gradle.jvmargs=-Xmx1024m
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Wed Dec 19 18:38:04 IST 2018
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
10 | DEFAULT_JVM_OPTS=""
11 |
12 | APP_NAME="Gradle"
13 | APP_BASE_NAME=`basename "$0"`
14 |
15 | # Use the maximum available, or set MAX_FD != -1 to use that value.
16 | MAX_FD="maximum"
17 |
18 | warn ( ) {
19 | echo "$*"
20 | }
21 |
22 | die ( ) {
23 | echo
24 | echo "$*"
25 | echo
26 | exit 1
27 | }
28 |
29 | # OS specific support (must be 'true' or 'false').
30 | cygwin=false
31 | msys=false
32 | darwin=false
33 | case "`uname`" in
34 | CYGWIN* )
35 | cygwin=true
36 | ;;
37 | Darwin* )
38 | darwin=true
39 | ;;
40 | MINGW* )
41 | msys=true
42 | ;;
43 | esac
44 |
45 | # Attempt to set APP_HOME
46 | # Resolve links: $0 may be a link
47 | PRG="$0"
48 | # Need this for relative symlinks.
49 | while [ -h "$PRG" ] ; do
50 | ls=`ls -ld "$PRG"`
51 | link=`expr "$ls" : '.*-> \(.*\)$'`
52 | if expr "$link" : '/.*' > /dev/null; then
53 | PRG="$link"
54 | else
55 | PRG=`dirname "$PRG"`"/$link"
56 | fi
57 | done
58 | SAVED="`pwd`"
59 | cd "`dirname \"$PRG\"`/" >/dev/null
60 | APP_HOME="`pwd -P`"
61 | cd "$SAVED" >/dev/null
62 |
63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
64 |
65 | # Determine the Java command to use to start the JVM.
66 | if [ -n "$JAVA_HOME" ] ; then
67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
68 | # IBM's JDK on AIX uses strange locations for the executables
69 | JAVACMD="$JAVA_HOME/jre/sh/java"
70 | else
71 | JAVACMD="$JAVA_HOME/bin/java"
72 | fi
73 | if [ ! -x "$JAVACMD" ] ; then
74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
75 |
76 | Please set the JAVA_HOME variable in your environment to match the
77 | location of your Java installation."
78 | fi
79 | else
80 | JAVACMD="java"
81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
82 |
83 | Please set the JAVA_HOME variable in your environment to match the
84 | location of your Java installation."
85 | fi
86 |
87 | # Increase the maximum file descriptors if we can.
88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
89 | MAX_FD_LIMIT=`ulimit -H -n`
90 | if [ $? -eq 0 ] ; then
91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
92 | MAX_FD="$MAX_FD_LIMIT"
93 | fi
94 | ulimit -n $MAX_FD
95 | if [ $? -ne 0 ] ; then
96 | warn "Could not set maximum file descriptor limit: $MAX_FD"
97 | fi
98 | else
99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
100 | fi
101 | fi
102 |
103 | # For Darwin, add options to specify how the application appears in the dock
104 | if $darwin; then
105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
106 | fi
107 |
108 | # For Cygwin, switch paths to Windows format before running java
109 | if $cygwin ; then
110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
112 | JAVACMD=`cygpath --unix "$JAVACMD"`
113 |
114 | # We build the pattern for arguments to be converted via cygpath
115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
116 | SEP=""
117 | for dir in $ROOTDIRSRAW ; do
118 | ROOTDIRS="$ROOTDIRS$SEP$dir"
119 | SEP="|"
120 | done
121 | OURCYGPATTERN="(^($ROOTDIRS))"
122 | # Add a user-defined pattern to the cygpath arguments
123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
125 | fi
126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
127 | i=0
128 | for arg in "$@" ; do
129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
131 |
132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
134 | else
135 | eval `echo args$i`="\"$arg\""
136 | fi
137 | i=$((i+1))
138 | done
139 | case $i in
140 | (0) set -- ;;
141 | (1) set -- "$args0" ;;
142 | (2) set -- "$args0" "$args1" ;;
143 | (3) set -- "$args0" "$args1" "$args2" ;;
144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
150 | esac
151 | fi
152 |
153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
154 | function splitJvmOpts() {
155 | JVM_OPTS=("$@")
156 | }
157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
159 |
160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
161 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
12 | set DEFAULT_JVM_OPTS=
13 |
14 | set DIRNAME=%~dp0
15 | if "%DIRNAME%" == "" set DIRNAME=.
16 | set APP_BASE_NAME=%~n0
17 | set APP_HOME=%DIRNAME%
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windowz variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 | if "%@eval[2+2]" == "4" goto 4NT_args
53 |
54 | :win9xME_args
55 | @rem Slurp the command line arguments.
56 | set CMD_LINE_ARGS=
57 | set _SKIP=2
58 |
59 | :win9xME_args_slurp
60 | if "x%~1" == "x" goto execute
61 |
62 | set CMD_LINE_ARGS=%*
63 | goto execute
64 |
65 | :4NT_args
66 | @rem Get arguments from the 4NT Shell from JP Software
67 | set CMD_LINE_ARGS=%$
68 |
69 | :execute
70 | @rem Setup the command line
71 |
72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if "%ERRORLEVEL%"=="0" goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
85 | exit /b 1
86 |
87 | :mainEnd
88 | if "%OS%"=="Windows_NT" endlocal
89 |
90 | :omega
91 |
--------------------------------------------------------------------------------
/posts.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts.gif
--------------------------------------------------------------------------------
/posts/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/posts/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 | apply plugin: 'kotlin-android'
3 | apply plugin: 'kotlin-kapt'
4 | apply plugin: 'kotlin-android-extensions'
5 |
6 | android {
7 | compileSdkVersion versions.compileSdk
8 | buildToolsVersion versions.buildTools
9 |
10 | defaultConfig {
11 | applicationId "com.karntrehan.posts"
12 | minSdkVersion versions.minSdk
13 | targetSdkVersion versions.compileSdk
14 | versionCode 3
15 | versionName "1.2"
16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
17 | }
18 | buildTypes {
19 | release {
20 | postprocessing {
21 | removeUnusedCode false
22 | removeUnusedResources false
23 | obfuscate false
24 | optimizeCode false
25 | proguardFile 'proguard-rules.pro'
26 | }
27 | }
28 | }
29 | compileOptions {
30 | sourceCompatibility versions.sourceCompat
31 | targetCompatibility versions.targetCompat
32 | }
33 |
34 | //For testing
35 | testOptions {
36 | unitTests {
37 | includeAndroidResources = true
38 | }
39 | }
40 | }
41 |
42 | androidExtensions {
43 | experimental = true
44 | }
45 |
46 | dependencies {
47 | implementation project(':core')
48 | kapt deps.android.roomCompiler
49 | kapt deps.google.daggerProcessor
50 |
51 | //Local unit tests
52 | testImplementation deps.test.junit
53 | testImplementation deps.test.mockitoKotlin
54 | testImplementation deps.test.robolectric
55 | testImplementation deps.test.mockWebServer
56 | testImplementation deps.test.livedataTesting
57 | testImplementation deps.test.roomTesting
58 |
59 | androidTestImplementation deps.test.junit
60 | androidTestImplementation deps.test.mockitoKotlin
61 | androidTestImplementation deps.test.atslRunner
62 | androidTestImplementation deps.test.atslRules
63 | androidTestImplementation deps.test.roomTesting
64 | androidTestImplementation deps.test.livedataTesting
65 | }
66 |
--------------------------------------------------------------------------------
/posts/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/posts/src/androidTest/java/com/karntrehan/posts/details/model/DetailsLocalDataTest.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.model
2 |
3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
4 | import androidx.room.Room
5 | import androidx.test.platform.app.InstrumentationRegistry
6 | import androidx.test.ext.junit.runners.AndroidJUnit4
7 | import com.karntrehan.posts.commons.testing.DummyData
8 | import com.karntrehan.posts.core.testing.TestScheduler
9 | import com.karntrehan.posts.commons.data.local.PostDb
10 | import org.junit.Before
11 | import org.junit.Rule
12 | import org.junit.Test
13 | import org.junit.runner.RunWith
14 |
15 | /**
16 | * Tests for [DetailsLocalData]
17 | **/
18 | @RunWith(AndroidJUnit4::class)
19 | class DetailsLocalDataTest {
20 | private lateinit var postDb: PostDb
21 |
22 | private val detailsLocalData: DetailsLocalData by lazy {
23 | DetailsLocalData(
24 | postDb,
25 | TestScheduler()
26 | )
27 | }
28 |
29 | //Necessary for Room insertions to work
30 | @get:Rule
31 | val instantTaskExecutorRule = InstantTaskExecutorRule()
32 |
33 | private val postId = 1
34 | private val userId = 1
35 | private val dummyComments = listOf(DummyData.Comment(postId, 1), DummyData.Comment(postId, 2))
36 |
37 | @Before
38 | fun init() {
39 | postDb = Room
40 | .inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().context, PostDb::class.java)
41 | .allowMainThreadQueries()
42 | .build()
43 |
44 | /*Need to insert users and posts, in that order, before testing
45 | or else ForeignKey constraint fails on inserts*/
46 | val dummyUsers = listOf(DummyData.User(userId))
47 | val dummyPosts = listOf(DummyData.Post(userId, postId))
48 | postDb.userDao().upsertAll(dummyUsers)
49 | postDb.postDao().upsertAll(dummyPosts)
50 | }
51 |
52 | /**
53 | * Test that [DetailsLocalData.getCommentsForPost] returns correct values
54 | * */
55 | @Test
56 | fun testGetCommentsForPost() {
57 | postDb.commentDao().upsertAll(dummyComments)
58 | detailsLocalData.getCommentsForPost(postId).test().assertValue(dummyComments)
59 | }
60 |
61 | /**
62 | * Test that [DetailsLocalData.saveComments] actually inserts the comments into the database
63 | * */
64 | @Test
65 | fun testSaveComments() {
66 | detailsLocalData.saveComments(dummyComments)
67 | val comments = postDb.commentDao().getForPost(postId)
68 | comments.test().assertNoErrors().assertValue(dummyComments)
69 | }
70 |
71 | }
--------------------------------------------------------------------------------
/posts/src/androidTest/java/com/karntrehan/posts/list/model/ListLocalDataTest.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.model
2 |
3 | import androidx.room.Room
4 | import androidx.test.platform.app.InstrumentationRegistry
5 | import androidx.test.ext.junit.runners.AndroidJUnit4
6 | import com.karntrehan.posts.commons.testing.DummyData
7 | import com.karntrehan.posts.core.testing.TestScheduler
8 | import com.karntrehan.posts.commons.data.local.PostDb
9 | import org.junit.After
10 | import org.junit.Before
11 | import org.junit.runner.RunWith
12 | import org.junit.Rule
13 | import org.junit.Test
14 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
15 |
16 | /**
17 | *
18 | * Test for [ListLocalData]
19 | * Needs to be an instrumented test because Room needs to be tested on a physical device:
20 | * https://developer.android.com/training/data-storage/room/testing-db.html#android
21 | *
22 | * */
23 | @RunWith(AndroidJUnit4::class)
24 | class ListLocalDataTest {
25 |
26 | private lateinit var postDb: PostDb
27 |
28 | private val listLocalData: ListLocalData by lazy { ListLocalData(postDb, TestScheduler()) }
29 |
30 | //Necessary for Room insertions to work
31 | @get:Rule
32 | val instantTaskExecutorRule = InstantTaskExecutorRule()
33 |
34 | private val dummyUsers = listOf(DummyData.User(1), DummyData.User(2))
35 | private val dummyPosts = listOf(DummyData.Post(1, 1), DummyData.Post(2, 2))
36 |
37 | @Before
38 | fun init() {
39 | postDb = Room
40 | .inMemoryDatabaseBuilder(InstrumentationRegistry.getInstrumentation().context, PostDb::class.java)
41 | .allowMainThreadQueries()
42 | .build()
43 | }
44 |
45 | /**
46 | * Test that [ListLocalData.getPostsWithUsers] fetches the posts and users in the database
47 | * */
48 | @Test
49 | fun testGetPostsWithUsers() {
50 | val postsWithUser = listOf(DummyData.PostWithUser(1), DummyData.PostWithUser(2))
51 |
52 | postDb.userDao().upsertAll(dummyUsers)
53 | postDb.postDao().upsertAll(dummyPosts)
54 |
55 | listLocalData.getPostsWithUsers().test().assertValue(postsWithUser)
56 | }
57 |
58 |
59 | /**
60 | * Test that [ListLocalData.saveUsersAndPosts] saves the passed lists into the database
61 | * */
62 | @Test
63 | fun saveUsersAndPosts() {
64 |
65 | listLocalData.saveUsersAndPosts(dummyUsers, dummyPosts)
66 |
67 | val users = postDb.userDao().getAll()
68 | users.test().assertNoErrors().assertValue(dummyUsers)
69 |
70 | val posts = postDb.postDao().getAll()
71 | posts.test().assertNoErrors().assertValue(dummyPosts)
72 | }
73 |
74 | @After
75 | fun clean() {
76 | postDb.close()
77 | }
78 | }
--------------------------------------------------------------------------------
/posts/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
12 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/PostDH.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons
2 |
3 | import com.karntrehan.posts.core.application.CoreApp
4 | import com.karntrehan.posts.details.di.DaggerDetailsComponent
5 | import com.karntrehan.posts.details.di.DetailsComponent
6 | import com.karntrehan.posts.list.di.DaggerListComponent
7 | import com.karntrehan.posts.list.di.ListComponent
8 | import javax.inject.Singleton
9 |
10 | @Singleton
11 | object PostDH {
12 | private var listComponent: ListComponent? = null
13 | private var detailsComponent: DetailsComponent? = null
14 |
15 | fun listComponent(): ListComponent {
16 | if (listComponent == null)
17 | listComponent = DaggerListComponent.builder().coreComponent(CoreApp.coreComponent).build()
18 | return listComponent as ListComponent
19 | }
20 |
21 | fun destroyListComponent() {
22 | listComponent = null
23 | }
24 |
25 | fun detailsComponent(): DetailsComponent {
26 | if (detailsComponent == null)
27 | detailsComponent = DaggerDetailsComponent.builder().listComponent(listComponent()).build()
28 | return detailsComponent as DetailsComponent
29 | }
30 |
31 | fun destroyDetailsComponent() {
32 | detailsComponent = null
33 | }
34 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/data/PostWithData.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.data
2 |
3 | import android.annotation.SuppressLint
4 | import androidx.room.PrimaryKey
5 | import android.os.Parcelable
6 | import kotlinx.android.parcel.Parcelize
7 |
8 | @Parcelize
9 | @SuppressLint("ParcelCreator")
10 | data class PostWithUser(@PrimaryKey val postId: Int,
11 | val postTitle: String,
12 | val postBody: String,
13 | val userName: String) : Parcelable {
14 |
15 | fun getFormattedPostBody(): String {
16 | return if (postBody.length <= 70)
17 | postBody
18 | else
19 | postBody.substring(0, 67) + "..."
20 | }
21 |
22 | fun getAvatarPhoto() = "https://api.adorable.io/avatars/64/$userName.png"
23 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/data/local/Comment.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.data.local
2 |
3 | import androidx.room.Entity
4 | import androidx.room.ForeignKey
5 | import androidx.room.Index
6 | import androidx.room.PrimaryKey
7 | import com.google.gson.annotations.SerializedName
8 |
9 | @Entity(foreignKeys = [(ForeignKey(entity = Post::class, parentColumns = arrayOf("postId"),
10 | childColumns = arrayOf("postId"), onDelete = ForeignKey.CASCADE))],
11 | indices = [(Index("postId"))])
12 | data class Comment(@SerializedName("postId") val postId: Int,
13 | @SerializedName("id") @PrimaryKey val id: Int,
14 | @SerializedName("name") val name: String,
15 | @SerializedName("email") val email: String,
16 | @SerializedName("body") val body: String)
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/data/local/CommentDao.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.data.local
2 |
3 | import androidx.room.*
4 | import io.reactivex.Flowable
5 |
6 | @Dao
7 | interface CommentDao {
8 |
9 | @Query("SELECT * from comment where postId = :postId")
10 | fun getForPost(postId: Int): Flowable>
11 |
12 | @Insert(onConflict = OnConflictStrategy.REPLACE)
13 | fun upsertAll(comments: List)
14 |
15 | @Delete
16 | fun delete(comment: Comment)
17 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/data/local/Post.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.data.local
2 |
3 | import androidx.room.Entity
4 | import androidx.room.ForeignKey
5 | import androidx.room.Index
6 | import androidx.room.PrimaryKey
7 | import com.google.gson.annotations.SerializedName
8 |
9 | @Entity(foreignKeys = [(ForeignKey(entity = User::class, parentColumns = ["id"],
10 | childColumns = ["userId"], onDelete = ForeignKey.CASCADE))],
11 | indices = [(Index("userId"))])
12 | data class Post(@SerializedName("userId") val userId: Int,
13 | @SerializedName("id") @PrimaryKey val postId: Int,
14 | @SerializedName("title") val postTitle: String,
15 | @SerializedName("body") val postBody: String)
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/data/local/PostDao.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.data.local
2 |
3 | import androidx.room.*
4 | import com.karntrehan.posts.commons.data.PostWithUser
5 | import io.reactivex.Flowable
6 |
7 |
8 | @Dao
9 | interface PostDao {
10 | @Query("SELECT post.postId AS postId, post.postTitle AS postTitle ,post.postBody AS postBody, user.userName as userName FROM post, user WHERE post.userId= user.id")
11 | fun getAllPostsWithUser(): Flowable>
12 |
13 |
14 | @Insert(onConflict = OnConflictStrategy.REPLACE)
15 | fun upsertAll(posts: List)
16 |
17 | @Delete
18 | fun delete(post: Post)
19 |
20 | @Query("SELECT * FROM post")
21 | fun getAll(): Flowable>
22 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/data/local/PostDb.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.data.local
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 |
6 | @Database(entities = [Post::class, User::class, Comment::class], version = 1,exportSchema = false)
7 | abstract class PostDb : RoomDatabase() {
8 | abstract fun postDao(): PostDao
9 | abstract fun userDao(): UserDao
10 | abstract fun commentDao(): CommentDao
11 | }
12 |
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/data/local/User.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.data.local
2 |
3 | import androidx.room.Entity
4 | import androidx.room.Index
5 | import androidx.room.PrimaryKey
6 | import com.google.gson.annotations.SerializedName
7 |
8 | @Entity(indices = [(Index("id"))])
9 | data class User(@SerializedName("id") @PrimaryKey val id: Int,
10 | @SerializedName("name") val userName: String,
11 | @SerializedName("username") val userIdentity: String,
12 | @SerializedName("email") val userEmail: String)
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/data/local/UserDao.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.data.local
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy
6 | import androidx.room.Query
7 | import io.reactivex.Flowable
8 |
9 | @Dao
10 | interface UserDao {
11 | @Insert(onConflict = OnConflictStrategy.REPLACE)
12 | fun upsertAll(users: List)
13 |
14 | @Query("SELECT * FROM user")
15 | fun getAll(): Flowable>
16 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/data/remote/PostService.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.data.remote
2 |
3 | import com.karntrehan.posts.commons.data.local.Comment
4 | import com.karntrehan.posts.commons.data.local.Post
5 | import com.karntrehan.posts.commons.data.local.User
6 | import io.reactivex.Flowable
7 | import io.reactivex.Single
8 | import retrofit2.http.GET
9 | import retrofit2.http.Query
10 |
11 | interface PostService {
12 | @GET("/posts/")
13 | fun getPosts(): Single>
14 |
15 | @GET("/users/")
16 | fun getUsers(): Single>
17 |
18 | @GET("/comments/")
19 | fun getComments(@Query("postId") postId: Int): Single>
20 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/commons/testing/DummyData.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.testing
2 |
3 | import androidx.annotation.VisibleForTesting
4 | import com.karntrehan.posts.commons.data.PostWithUser
5 | import com.karntrehan.posts.commons.data.local.Comment
6 | import com.karntrehan.posts.commons.data.local.Post
7 | import com.karntrehan.posts.commons.data.local.User
8 |
9 | @VisibleForTesting(otherwise = VisibleForTesting.NONE)
10 | object DummyData {
11 | fun User(id: Int) = User(id, "username$id", "userIdentity$id", "email$id")
12 | fun Post(userId: Int, id: Int) = Post(userId, id, "title$id", "body$id")
13 | fun PostWithUser(id: Int) = PostWithUser(id, "title$id", "body$id", "username$id")
14 | fun Comment(postId: Int, id: Int) = Comment(postId,id,"name$id","email$id","body$id")
15 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/DetailsActivity.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details
2 |
3 | import android.app.Activity
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.os.Build
7 | import android.os.Bundle
8 | import android.util.Log
9 | import android.view.View
10 | import android.widget.ImageView
11 | import android.widget.TextView
12 | import android.widget.Toast
13 | import androidx.core.app.ActivityOptionsCompat
14 | import androidx.core.util.Pair
15 | import androidx.core.view.ViewCompat
16 | import androidx.lifecycle.Observer
17 | import androidx.lifecycle.ViewModelProviders
18 | import com.karntrehan.posts.R
19 | import com.karntrehan.posts.commons.PostDH
20 | import com.karntrehan.posts.commons.data.PostWithUser
21 | import com.karntrehan.posts.commons.data.local.Comment
22 | import com.karntrehan.posts.core.application.BaseActivity
23 | import com.karntrehan.posts.details.exceptions.DetailsExceptions
24 | import com.karntrehan.posts.details.viewmodel.DetailsViewModel
25 | import com.karntrehan.posts.details.viewmodel.DetailsViewModelFactory
26 | import com.mpaani.core.networking.Outcome
27 | import com.squareup.picasso.Picasso
28 | import kotlinx.android.synthetic.main.activity_details.*
29 | import java.io.IOException
30 | import javax.inject.Inject
31 |
32 |
33 | class DetailsActivity : BaseActivity(), DetailsAdapter.Interaction {
34 |
35 | companion object {
36 | private const val SELECTED_POST = "post"
37 | //Transitions
38 | private const val TITLE_TRANSITION_NAME = "title_transition"
39 | private const val BODY_TRANSITION_NAME = "body_transition"
40 | private const val AUTHOR_TRANSITION_NAME = "author_transition"
41 | private const val AVATAR_TRANSITION_NAME = "avatar_transition"
42 |
43 | fun start(
44 | context: Context,
45 | post: PostWithUser,
46 | tvTitle: TextView,
47 | tvBody: TextView,
48 | tvAuthorName: TextView,
49 | ivAvatar: ImageView
50 | ) {
51 | val intent = Intent(context, DetailsActivity::class.java)
52 | intent.putExtra(SELECTED_POST, post)
53 |
54 | //Transitions
55 | intent.putExtra(TITLE_TRANSITION_NAME, ViewCompat.getTransitionName(tvTitle))
56 | intent.putExtra(BODY_TRANSITION_NAME, ViewCompat.getTransitionName(tvBody))
57 | intent.putExtra(AUTHOR_TRANSITION_NAME, ViewCompat.getTransitionName(tvAuthorName))
58 | intent.putExtra(AVATAR_TRANSITION_NAME, ViewCompat.getTransitionName(ivAvatar))
59 |
60 | val p1 = Pair.create(tvTitle as View, ViewCompat.getTransitionName(tvTitle))
61 | val p2 = Pair.create(tvBody as View, ViewCompat.getTransitionName(tvBody))
62 | val p3 = Pair.create(tvAuthorName as View, ViewCompat.getTransitionName(tvAuthorName))
63 | val p4 = Pair.create(ivAvatar as View, ViewCompat.getTransitionName(ivAvatar))
64 | val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
65 | context as Activity,
66 | p1,
67 | p2,
68 | p3,
69 | p4
70 | )
71 |
72 | context.startActivity(intent, options.toBundle())
73 | }
74 |
75 | fun start(context: Context, postId: Int) {
76 | val intent = Intent(context, DetailsActivity::class.java)
77 | intent.putExtra(SELECTED_POST, postId)
78 | context.startActivity(intent)
79 | }
80 | }
81 |
82 | private val TAG = "DetailsActivity"
83 | private var selectedPost: PostWithUser? = null
84 | private val context: Context by lazy { this }
85 |
86 | private val component by lazy { PostDH.detailsComponent() }
87 |
88 | val adapter: DetailsAdapter by lazy { DetailsAdapter(this) }
89 |
90 | @Inject
91 | lateinit var viewModelFactory: DetailsViewModelFactory
92 |
93 | @Inject
94 | lateinit var picasso: Picasso
95 |
96 | private val viewModel: DetailsViewModel by lazy {
97 | ViewModelProviders.of(this, viewModelFactory).get(DetailsViewModel::class.java)
98 | }
99 |
100 | override fun onCreate(savedInstanceState: Bundle?) {
101 | super.onCreate(savedInstanceState)
102 | setContentView(R.layout.activity_details)
103 | component.inject(this)
104 | getIntentData()
105 |
106 | srlComments.setOnRefreshListener { viewModel.refreshCommentsFor(selectedPost?.postId) }
107 | }
108 |
109 | private fun getIntentData() {
110 | if (!intent.hasExtra(SELECTED_POST)) {
111 | Log.d(TAG, "getIntentData: could not find selected post")
112 | finish()
113 | return
114 | }
115 |
116 | selectedPost = intent.getParcelableExtra(SELECTED_POST)
117 | tvTitle.text = selectedPost?.postTitle
118 | tvBody.text = selectedPost?.postBody
119 | tvAuthorName.text = selectedPost?.userName
120 | picasso.load(selectedPost?.getAvatarPhoto()).into(ivAvatar)
121 |
122 | handleTransition(intent.extras)
123 |
124 | rvComments.adapter = adapter
125 |
126 | viewModel.loadCommentsFor(selectedPost?.postId)
127 | observeData()
128 | }
129 |
130 | private fun handleTransition(extras: Bundle?) {
131 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
132 | tvTitle.transitionName = extras?.getString(TITLE_TRANSITION_NAME)
133 | tvBody.transitionName = extras?.getString(BODY_TRANSITION_NAME)
134 | tvAuthorName.transitionName = extras?.getString(AUTHOR_TRANSITION_NAME)
135 | ivAvatar.transitionName = extras?.getString(AVATAR_TRANSITION_NAME)
136 | }
137 | }
138 |
139 | private fun observeData() {
140 | viewModel.commentsOutcome.observe(this, Observer>> { outcome ->
141 | Log.d(TAG, "initiateDataListener: " + outcome.toString())
142 | when (outcome) {
143 |
144 | is Outcome.Progress -> srlComments.isRefreshing = outcome.loading
145 |
146 | is Outcome.Success -> {
147 | Log.d(TAG, "observeData: Successfully loaded data")
148 | tvCommentError.visibility = View.GONE
149 | adapter.swapData(outcome.data)
150 | }
151 |
152 | is Outcome.Failure -> {
153 | when (outcome.e) {
154 | DetailsExceptions.NoComments() -> tvCommentError.visibility =
155 | View.VISIBLE
156 | IOException() -> Toast.makeText(
157 | context,
158 | R.string.need_internet_posts,
159 | Toast.LENGTH_LONG
160 | ).show()
161 | else -> Toast.makeText(
162 | context,
163 | R.string.failed_post_try_again,
164 | Toast.LENGTH_LONG
165 | ).show()
166 | }
167 | }
168 |
169 | }
170 | })
171 | }
172 |
173 | override fun commentClicked(model: Comment) {
174 | Log.d(TAG, "Comment clicked: $model")
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/DetailsAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details
2 |
3 | import android.view.LayoutInflater
4 | import android.view.View
5 | import android.view.View.OnClickListener
6 | import android.view.ViewGroup
7 | import androidx.recyclerview.widget.DiffUtil
8 | import androidx.recyclerview.widget.ListAdapter
9 | import androidx.recyclerview.widget.RecyclerView
10 | import com.karntrehan.posts.R
11 | import com.karntrehan.posts.commons.data.local.Comment
12 | import kotlinx.android.synthetic.main.comment_item.view.*
13 |
14 | class DetailsAdapter(private val interaction: Interaction? = null) :
15 | ListAdapter(CommentDC()) {
16 |
17 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = DetailsViewHolder(
18 | LayoutInflater.from(parent.context)
19 | .inflate(R.layout.comment_item, parent, false), interaction
20 | )
21 |
22 | override fun onBindViewHolder(holder: DetailsViewHolder, position: Int) = holder.bind(getItem(position))
23 |
24 | fun swapData(data: List) {
25 | submitList(data.toMutableList())
26 | }
27 |
28 | inner class DetailsViewHolder(itemView: View, private val interaction: Interaction?)
29 | : RecyclerView.ViewHolder(itemView), OnClickListener {
30 |
31 | init {
32 | itemView.setOnClickListener(this)
33 | }
34 |
35 | override fun onClick(v: View?) {
36 | val clicked = getItem(adapterPosition)
37 | interaction?.commentClicked(clicked)
38 | }
39 |
40 | fun bind(item: Comment) = with(itemView) {
41 | tvComment.text = item.body
42 | tvAuthor.text = "- ${item.name}"
43 | }
44 | }
45 |
46 | interface Interaction {
47 | fun commentClicked(model: Comment)
48 | }
49 |
50 | private class CommentDC : DiffUtil.ItemCallback() {
51 | override fun areItemsTheSame(
52 | oldItem: Comment,
53 | newItem: Comment
54 | ) = oldItem.id == newItem.id
55 |
56 | override fun areContentsTheSame(
57 | oldItem: Comment,
58 | newItem: Comment
59 | ) = oldItem == newItem
60 | }
61 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/di/DetailsComponent.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.di
2 |
3 | import com.karntrehan.posts.commons.data.local.PostDb
4 | import com.karntrehan.posts.commons.data.remote.PostService
5 | import com.karntrehan.posts.core.networking.Scheduler
6 | import com.karntrehan.posts.details.DetailsActivity
7 | import com.karntrehan.posts.details.model.DetailsDataContract
8 | import com.karntrehan.posts.details.model.DetailsLocalData
9 | import com.karntrehan.posts.details.model.DetailsRemoteData
10 | import com.karntrehan.posts.details.model.DetailsRepository
11 | import com.karntrehan.posts.details.viewmodel.DetailsViewModelFactory
12 | import com.karntrehan.posts.list.di.ListComponent
13 | import dagger.Component
14 | import dagger.Module
15 | import dagger.Provides
16 | import io.reactivex.disposables.CompositeDisposable
17 |
18 | @DetailsScope
19 | @Component(dependencies = [ListComponent::class], modules = [DetailsModule::class])
20 | interface DetailsComponent {
21 | fun inject(detailsActivity: DetailsActivity)
22 | }
23 |
24 | @Module
25 | class DetailsModule {
26 |
27 | /*ViewModel*/
28 | @Provides
29 | @DetailsScope
30 | fun detailsViewModelFactory(repo: DetailsDataContract.Repository, compositeDisposable: CompositeDisposable): DetailsViewModelFactory {
31 | return DetailsViewModelFactory(repo, compositeDisposable)
32 | }
33 |
34 | /*Repository*/
35 | @Provides
36 | @DetailsScope
37 | fun detailsRepo(local: DetailsDataContract.Local, remote: DetailsDataContract.Remote, scheduler: Scheduler, compositeDisposable: CompositeDisposable)
38 | : DetailsDataContract.Repository = DetailsRepository(local, remote, scheduler, compositeDisposable)
39 |
40 | @Provides
41 | @DetailsScope
42 | fun remoteData(postService: PostService): DetailsDataContract.Remote = DetailsRemoteData(postService)
43 |
44 | @Provides
45 | @DetailsScope
46 | fun localData(postDb: PostDb, scheduler: Scheduler): DetailsDataContract.Local = DetailsLocalData(postDb, scheduler)
47 |
48 | @Provides
49 | @DetailsScope
50 | fun compositeDisposable(): CompositeDisposable = CompositeDisposable()
51 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/di/DetailsScope.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.di
2 |
3 | import javax.inject.Scope
4 |
5 | @Scope
6 | @Retention(AnnotationRetention.RUNTIME)
7 | annotation class DetailsScope
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/exceptions/DetailsExceptions.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.exceptions
2 |
3 | interface DetailsExceptions {
4 | class NoComments : Exception()
5 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/model/DetailsDataContract.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.model
2 |
3 | import com.karntrehan.posts.commons.data.local.Comment
4 | import com.mpaani.core.networking.Outcome
5 | import io.reactivex.Flowable
6 | import io.reactivex.Single
7 | import io.reactivex.subjects.PublishSubject
8 |
9 | interface DetailsDataContract {
10 | interface Repository {
11 | val commentsFetchOutcome: PublishSubject>>
12 | fun fetchCommentsFor(postId: Int?)
13 | fun refreshComments(postId: Int)
14 | fun saveCommentsForPost(comments: List)
15 | fun handleError(error: Throwable)
16 | }
17 |
18 | interface Local {
19 | fun getCommentsForPost(postId: Int): Flowable>
20 | fun saveComments(comments: List)
21 | }
22 |
23 | interface Remote {
24 | fun getCommentsForPost(postId: Int): Single>
25 | }
26 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/model/DetailsLocalData.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.model
2 |
3 | import com.karntrehan.posts.commons.data.local.Comment
4 | import com.karntrehan.posts.commons.data.local.PostDb
5 | import com.karntrehan.posts.core.extensions.performOnBack
6 | import com.karntrehan.posts.core.networking.Scheduler
7 | import io.reactivex.Completable
8 | import io.reactivex.Flowable
9 |
10 | class DetailsLocalData(private val postDb: PostDb, private val scheduler: Scheduler) : DetailsDataContract.Local {
11 |
12 | override fun getCommentsForPost(postId: Int): Flowable> {
13 | return postDb.commentDao().getForPost(postId)
14 | }
15 |
16 | override fun saveComments(comments: List) {
17 | Completable.fromAction {
18 | postDb.commentDao().upsertAll(comments)
19 | }
20 | .performOnBack(scheduler)
21 | .subscribe()
22 | }
23 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/model/DetailsRemoteData.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.model
2 |
3 | import com.karntrehan.posts.commons.data.remote.PostService
4 |
5 | class DetailsRemoteData(private val postService: PostService) : DetailsDataContract.Remote {
6 |
7 | override fun getCommentsForPost(postId: Int) = postService.getComments(postId)
8 |
9 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/model/DetailsRepository.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.model
2 |
3 | import com.karntrehan.posts.commons.data.local.Comment
4 | import com.karntrehan.posts.core.extensions.*
5 | import com.karntrehan.posts.core.networking.Scheduler
6 | import com.karntrehan.posts.core.networking.synk.Synk
7 | import com.karntrehan.posts.core.networking.synk.SynkKeys
8 | import com.karntrehan.posts.details.exceptions.DetailsExceptions
9 | import com.mpaani.core.networking.Outcome
10 | import io.reactivex.disposables.CompositeDisposable
11 | import io.reactivex.subjects.PublishSubject
12 | import java.util.concurrent.TimeUnit
13 |
14 | class DetailsRepository(
15 | private val local: DetailsDataContract.Local,
16 | private val remote: DetailsDataContract.Remote,
17 | private val scheduler: Scheduler,
18 | private val compositeDisposable: CompositeDisposable
19 | ) : DetailsDataContract.Repository {
20 |
21 | override val commentsFetchOutcome: PublishSubject>> =
22 | PublishSubject.create>>()
23 |
24 | override fun fetchCommentsFor(postId: Int?) {
25 | if (postId == null)
26 | return
27 |
28 | commentsFetchOutcome.loading(true)
29 | local.getCommentsForPost(postId)
30 | .performOnBackOutOnMain(scheduler)
31 | .doAfterNext {
32 | if (Synk.shouldSync(SynkKeys.POST_DETAILS + "_" + postId, 2, TimeUnit.HOURS))
33 | refreshComments(postId)
34 | }
35 | .subscribe({ comments ->
36 | commentsFetchOutcome.success(comments)
37 | }, { error -> handleError(error) })
38 | .addTo(compositeDisposable)
39 | }
40 |
41 | override fun refreshComments(postId: Int) {
42 | commentsFetchOutcome.loading(true)
43 | remote.getCommentsForPost(postId)
44 | .performOnBackOutOnMain(scheduler)
45 | .subscribe(
46 | { comments -> saveCommentsForPost(comments) },
47 | { error -> handleError(error) })
48 | .addTo(compositeDisposable)
49 | }
50 |
51 | override fun saveCommentsForPost(comments: List) {
52 | if (comments.isNotEmpty()) {
53 | local.saveComments(comments)
54 | } else
55 | commentsFetchOutcome.failed(DetailsExceptions.NoComments())
56 | }
57 |
58 | override fun handleError(error: Throwable) {
59 | commentsFetchOutcome.failed(error)
60 | }
61 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/viewmodel/DetailsViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.viewmodel
2 |
3 | import androidx.lifecycle.LiveData
4 | import androidx.lifecycle.ViewModel
5 | import com.karntrehan.posts.commons.PostDH
6 | import com.karntrehan.posts.commons.data.local.Comment
7 | import com.karntrehan.posts.core.extensions.toLiveData
8 | import com.karntrehan.posts.details.model.DetailsDataContract
9 | import com.mpaani.core.networking.Outcome
10 | import io.reactivex.disposables.CompositeDisposable
11 |
12 | class DetailsViewModel(private val repo: DetailsDataContract.Repository, private val compositeDisposable: CompositeDisposable) : ViewModel() {
13 |
14 | val commentsOutcome: LiveData>> by lazy {
15 | repo.commentsFetchOutcome.toLiveData(compositeDisposable)
16 | }
17 |
18 | fun loadCommentsFor(postId: Int?) {
19 | repo.fetchCommentsFor(postId)
20 | }
21 |
22 | fun refreshCommentsFor(postId: Int?) {
23 | if (postId != null)
24 | repo.refreshComments(postId)
25 | }
26 |
27 | override fun onCleared() {
28 | super.onCleared()
29 | //clear the disposables when the viewmodel is cleared
30 | compositeDisposable.clear()
31 | PostDH.destroyDetailsComponent()
32 | }
33 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/details/viewmodel/DetailsViewModelFactory.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.viewmodel
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.ViewModelProvider
5 | import com.karntrehan.posts.details.model.DetailsDataContract
6 | import io.reactivex.disposables.CompositeDisposable
7 |
8 | @Suppress("UNCHECKED_CAST")
9 | class DetailsViewModelFactory(private val repository: DetailsDataContract.Repository, private val compositeDisposable: CompositeDisposable) :
10 | ViewModelProvider.Factory {
11 | override fun create(modelClass: Class): T {
12 | return DetailsViewModel(repository,compositeDisposable) as T
13 | }
14 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/list/ListActivity.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list
2 |
3 | import android.content.Context
4 | import android.os.Bundle
5 | import android.util.Log
6 | import android.widget.ImageView
7 | import android.widget.TextView
8 | import android.widget.Toast
9 | import androidx.lifecycle.Observer
10 | import androidx.lifecycle.ViewModelProviders
11 | import com.karntrehan.posts.R
12 | import com.karntrehan.posts.commons.PostDH
13 | import com.karntrehan.posts.commons.data.PostWithUser
14 | import com.karntrehan.posts.core.application.BaseActivity
15 | import com.karntrehan.posts.details.DetailsActivity
16 | import com.karntrehan.posts.list.viewmodel.ListViewModel
17 | import com.karntrehan.posts.list.viewmodel.ListViewModelFactory
18 | import com.mpaani.core.networking.Outcome
19 | import kotlinx.android.synthetic.main.activity_list.*
20 | import java.io.IOException
21 | import javax.inject.Inject
22 |
23 | class ListActivity : BaseActivity(), PostListAdapter.Interaction {
24 |
25 | private val component by lazy { PostDH.listComponent() }
26 |
27 | @Inject
28 | lateinit var viewModelFactory: ListViewModelFactory
29 |
30 | @Inject
31 | lateinit var adapter: PostListAdapter
32 |
33 | private val viewModel: ListViewModel by lazy {
34 | ViewModelProviders.of(this, viewModelFactory).get(ListViewModel::class.java)
35 | }
36 |
37 | private val context: Context by lazy { this }
38 |
39 | private val TAG = "ListActivity"
40 |
41 | override fun onCreate(savedInstanceState: Bundle?) {
42 | super.onCreate(savedInstanceState)
43 | setContentView(R.layout.activity_list)
44 | component.inject(this)
45 |
46 | adapter.interaction = this
47 | rvPosts.adapter = adapter
48 | srlPosts.setOnRefreshListener { viewModel.refreshPosts() }
49 |
50 | viewModel.getPosts()
51 | initiateDataListener()
52 | }
53 |
54 | private fun initiateDataListener() {
55 | //Observe the outcome and update state of the screen accordingly
56 | viewModel.postsOutcome.observe(this, Observer>> { outcome ->
57 | Log.d(TAG, "initiateDataListener: $outcome")
58 | when (outcome) {
59 |
60 | is Outcome.Progress -> srlPosts.isRefreshing = outcome.loading
61 |
62 | is Outcome.Success -> {
63 | Log.d(TAG, "initiateDataListener: Successfully loaded data")
64 | adapter.swapData(outcome.data)
65 | }
66 |
67 | is Outcome.Failure -> {
68 |
69 | if (outcome.e is IOException)
70 | Toast.makeText(
71 | context,
72 | R.string.need_internet_posts,
73 | Toast.LENGTH_LONG
74 | ).show()
75 | else
76 | Toast.makeText(
77 | context,
78 | R.string.failed_post_try_again,
79 | Toast.LENGTH_LONG
80 | ).show()
81 | }
82 |
83 | }
84 | })
85 | }
86 |
87 | override fun postClicked(
88 | post: PostWithUser,
89 | tvTitle: TextView,
90 | tvBody: TextView,
91 | tvAuthorName: TextView,
92 | ivAvatar: ImageView
93 | ) {
94 | DetailsActivity.start(context, post, tvTitle, tvBody, tvAuthorName, ivAvatar)
95 | }
96 |
97 | }
98 |
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/list/PostListAdapter.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list
2 |
3 | import android.view.LayoutInflater
4 | import android.view.View
5 | import android.view.View.OnClickListener
6 | import android.view.ViewGroup
7 | import android.widget.ImageView
8 | import android.widget.TextView
9 | import androidx.core.view.ViewCompat
10 | import androidx.recyclerview.widget.DiffUtil
11 | import androidx.recyclerview.widget.ListAdapter
12 | import androidx.recyclerview.widget.RecyclerView
13 | import com.karntrehan.posts.R
14 | import com.karntrehan.posts.commons.data.PostWithUser
15 | import com.squareup.picasso.Picasso
16 | import kotlinx.android.synthetic.main.post_item.view.*
17 |
18 | class PostListAdapter(private val picasso: Picasso)
19 | : ListAdapter(PostWithUserDC()) {
20 |
21 | var interaction: Interaction? = null
22 |
23 | override fun onCreateViewHolder(
24 | parent: ViewGroup,
25 | viewType: Int
26 | ) = ListViewHolder(LayoutInflater.from(parent.context)
27 | .inflate(R.layout.post_item, parent, false), interaction)
28 |
29 | override fun onBindViewHolder(
30 | holder: ListViewHolder,
31 | position: Int
32 | ) = holder.bind(getItem(position), picasso)
33 |
34 | fun swapData(data: List) {
35 | submitList(data.toMutableList())
36 | }
37 |
38 | inner class ListViewHolder(
39 | itemView: View,
40 | private val interaction: Interaction?
41 | ) : RecyclerView.ViewHolder(itemView), OnClickListener {
42 |
43 | init {
44 | itemView.setOnClickListener(this)
45 | }
46 |
47 | override fun onClick(v: View?) {
48 | val clicked = getItem(adapterPosition)
49 | interaction?.postClicked(clicked, itemView.tvTitle, itemView.tvBody, itemView.tvAuthorName, itemView.ivAvatar)
50 | }
51 |
52 | fun bind(item: PostWithUser, picasso: Picasso) = with(itemView) {
53 | tvTitle.text = item.postTitle
54 | tvBody.text = item.getFormattedPostBody()
55 | tvAuthorName.text = item.userName
56 | picasso.load(item.getAvatarPhoto())
57 | .into(itemView.ivAvatar)
58 |
59 | //SharedItem transition
60 | ViewCompat.setTransitionName(tvTitle, item.postTitle)
61 | ViewCompat.setTransitionName(tvBody, item.postBody)
62 | ViewCompat.setTransitionName(tvAuthorName, item.userName)
63 | ViewCompat.setTransitionName(ivAvatar, item.getAvatarPhoto())
64 | }
65 | }
66 |
67 | interface Interaction {
68 | fun postClicked(
69 | post: PostWithUser,
70 | tvTitle: TextView,
71 | tvBody: TextView,
72 | tvAuthorName: TextView,
73 | ivAvatar: ImageView)
74 | }
75 |
76 | private class PostWithUserDC : DiffUtil.ItemCallback() {
77 | override fun areItemsTheSame(oldItem: PostWithUser, newItem: PostWithUser) = oldItem.postId == newItem.postId
78 |
79 | override fun areContentsTheSame(oldItem: PostWithUser, newItem: PostWithUser) = oldItem == newItem
80 | }
81 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/list/di/ListComponent.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.di
2 |
3 | import androidx.room.Room
4 | import android.content.Context
5 | import com.karntrehan.posts.commons.data.local.PostDb
6 | import com.karntrehan.posts.commons.data.remote.PostService
7 | import com.karntrehan.posts.core.constants.Constants
8 | import com.karntrehan.posts.core.di.CoreComponent
9 | import com.karntrehan.posts.core.networking.Scheduler
10 | import com.karntrehan.posts.list.ListActivity
11 | import com.karntrehan.posts.list.PostListAdapter
12 | import com.karntrehan.posts.list.model.ListDataContract
13 | import com.karntrehan.posts.list.model.ListLocalData
14 | import com.karntrehan.posts.list.model.ListRemoteData
15 | import com.karntrehan.posts.list.model.ListRepository
16 | import com.karntrehan.posts.list.viewmodel.ListViewModelFactory
17 | import com.squareup.picasso.Picasso
18 | import dagger.Component
19 | import dagger.Module
20 | import dagger.Provides
21 | import io.reactivex.disposables.CompositeDisposable
22 | import retrofit2.Retrofit
23 |
24 | @ListScope
25 | @Component(dependencies = [CoreComponent::class], modules = [ListModule::class])
26 | interface ListComponent {
27 |
28 | //Expose to dependent components
29 | fun postDb(): PostDb
30 |
31 | fun postService(): PostService
32 | fun picasso(): Picasso
33 | fun scheduler(): Scheduler
34 |
35 | fun inject(listActivity: ListActivity)
36 | }
37 |
38 | @Module
39 | @ListScope
40 | class ListModule {
41 |
42 | /*Adapter*/
43 | @Provides
44 | @ListScope
45 | fun adapter(picasso: Picasso): PostListAdapter = PostListAdapter(picasso)
46 |
47 | /*ViewModel*/
48 | @Provides
49 | @ListScope
50 | fun listViewModelFactory(repository: ListDataContract.Repository,compositeDisposable: CompositeDisposable): ListViewModelFactory = ListViewModelFactory(repository,compositeDisposable)
51 |
52 | /*Repository*/
53 | @Provides
54 | @ListScope
55 | fun listRepo(local: ListDataContract.Local, remote: ListDataContract.Remote, scheduler: Scheduler, compositeDisposable: CompositeDisposable): ListDataContract.Repository = ListRepository(local, remote, scheduler, compositeDisposable)
56 |
57 | @Provides
58 | @ListScope
59 | fun remoteData(postService: PostService): ListDataContract.Remote = ListRemoteData(postService)
60 |
61 | @Provides
62 | @ListScope
63 | fun localData(postDb: PostDb, scheduler: Scheduler): ListDataContract.Local = ListLocalData(postDb, scheduler)
64 |
65 | @Provides
66 | @ListScope
67 | fun compositeDisposable(): CompositeDisposable = CompositeDisposable()
68 |
69 | /*Parent providers to dependents*/
70 | @Provides
71 | @ListScope
72 | fun postDb(context: Context): PostDb = Room.databaseBuilder(context, PostDb::class.java, Constants.Posts.DB_NAME).build()
73 |
74 | @Provides
75 | @ListScope
76 | fun postService(retrofit: Retrofit): PostService = retrofit.create(PostService::class.java)
77 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/list/di/ListScope.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.di
2 |
3 | import javax.inject.Scope
4 |
5 | @Scope
6 | @Retention(AnnotationRetention.RUNTIME)
7 | annotation class ListScope
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/list/model/ListDataContract.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.model
2 |
3 | import com.karntrehan.posts.commons.data.PostWithUser
4 | import com.karntrehan.posts.commons.data.local.Post
5 | import com.karntrehan.posts.commons.data.local.User
6 | import com.mpaani.core.networking.Outcome
7 | import io.reactivex.Flowable
8 | import io.reactivex.Single
9 | import io.reactivex.subjects.PublishSubject
10 |
11 | interface ListDataContract {
12 | interface Repository {
13 | val postFetchOutcome: PublishSubject>>
14 | fun fetchPosts()
15 | fun refreshPosts()
16 | fun saveUsersAndPosts(users: List, posts: List)
17 | fun handleError(error: Throwable)
18 | }
19 |
20 | interface Local {
21 | fun getPostsWithUsers(): Flowable>
22 | fun saveUsersAndPosts(users: List, posts: List)
23 | }
24 |
25 | interface Remote {
26 | fun getUsers(): Single>
27 | fun getPosts(): Single>
28 | }
29 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/list/model/ListLocalData.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.model
2 |
3 | import com.karntrehan.posts.commons.data.PostWithUser
4 | import com.karntrehan.posts.commons.data.local.Post
5 | import com.karntrehan.posts.commons.data.local.PostDb
6 | import com.karntrehan.posts.commons.data.local.User
7 | import com.karntrehan.posts.core.extensions.performOnBack
8 | import com.karntrehan.posts.core.networking.Scheduler
9 | import io.reactivex.Completable
10 | import io.reactivex.Flowable
11 |
12 | class ListLocalData(private val postDb: PostDb, private val scheduler: Scheduler) : ListDataContract.Local {
13 |
14 | override fun getPostsWithUsers(): Flowable> {
15 | return postDb.postDao().getAllPostsWithUser()
16 | }
17 |
18 | override fun saveUsersAndPosts(users: List, posts: List) {
19 | Completable.fromAction {
20 | postDb.userDao().upsertAll(users)
21 | postDb.postDao().upsertAll(posts)
22 | }
23 | .performOnBack(scheduler)
24 | .subscribe()
25 | }
26 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/list/model/ListRemoteData.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.model
2 |
3 | import com.karntrehan.posts.commons.data.remote.PostService
4 |
5 | class ListRemoteData(private val postService: PostService) : ListDataContract.Remote {
6 |
7 | override fun getUsers() = postService.getUsers()
8 |
9 | override fun getPosts() = postService.getPosts()
10 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/list/model/ListRepository.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.model
2 |
3 | import com.karntrehan.posts.commons.data.PostWithUser
4 | import com.karntrehan.posts.commons.data.local.Post
5 | import com.karntrehan.posts.commons.data.local.User
6 | import com.karntrehan.posts.core.extensions.*
7 | import com.karntrehan.posts.core.networking.Scheduler
8 | import com.karntrehan.posts.core.networking.synk.Synk
9 | import com.karntrehan.posts.core.networking.synk.SynkKeys
10 | import com.mpaani.core.networking.Outcome
11 | import io.reactivex.Single
12 | import io.reactivex.disposables.CompositeDisposable
13 | import io.reactivex.functions.BiFunction
14 | import io.reactivex.subjects.PublishSubject
15 | import java.util.concurrent.TimeUnit
16 |
17 |
18 | class ListRepository(
19 | private val local: ListDataContract.Local,
20 | private val remote: ListDataContract.Remote,
21 | private val scheduler: Scheduler,
22 | private val compositeDisposable: CompositeDisposable
23 | ) : ListDataContract.Repository {
24 |
25 | override val postFetchOutcome: PublishSubject>> =
26 | PublishSubject.create>>()
27 |
28 | override fun fetchPosts() {
29 | postFetchOutcome.loading(true)
30 | //Observe changes to the db
31 | local.getPostsWithUsers()
32 | .performOnBackOutOnMain(scheduler)
33 | .doAfterNext {
34 | if (Synk.shouldSync(SynkKeys.POSTS_HOME, 2, TimeUnit.HOURS))
35 | refreshPosts()
36 | }
37 | .subscribe({ postsWithUsers ->
38 | postFetchOutcome.success(postsWithUsers)
39 | }, { error -> handleError(error) })
40 | .addTo(compositeDisposable)
41 | }
42 |
43 | override fun refreshPosts() {
44 | postFetchOutcome.loading(true)
45 | Single.zip(
46 | remote.getUsers(),
47 | remote.getPosts(),
48 | zipUsersAndPosts()
49 | )
50 | .performOnBackOutOnMain(scheduler)
51 | .updateSynkStatus(key = SynkKeys.POSTS_HOME)
52 | .subscribe({}, { error -> handleError(error) })
53 | .addTo(compositeDisposable)
54 | }
55 |
56 | private fun zipUsersAndPosts() =
57 | BiFunction, List, Unit> { users, posts ->
58 | saveUsersAndPosts(users, posts)
59 | }
60 |
61 | override fun saveUsersAndPosts(users: List, posts: List) {
62 | local.saveUsersAndPosts(users, posts)
63 | }
64 |
65 | override fun handleError(error: Throwable) {
66 | postFetchOutcome.failed(error)
67 | }
68 |
69 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/list/viewmodel/ListViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.viewmodel
2 |
3 | import androidx.lifecycle.LiveData
4 | import androidx.lifecycle.ViewModel
5 | import com.karntrehan.posts.commons.PostDH
6 | import com.karntrehan.posts.commons.data.PostWithUser
7 | import com.karntrehan.posts.core.extensions.toLiveData
8 | import com.karntrehan.posts.list.model.ListDataContract
9 | import com.mpaani.core.networking.Outcome
10 | import io.reactivex.disposables.CompositeDisposable
11 |
12 | class ListViewModel(private val repo: ListDataContract.Repository,
13 | private val compositeDisposable: CompositeDisposable) : ViewModel() {
14 |
15 | val postsOutcome: LiveData>> by lazy {
16 | //Convert publish subject to livedata
17 | repo.postFetchOutcome.toLiveData(compositeDisposable)
18 | }
19 |
20 | fun getPosts() {
21 | if (postsOutcome.value == null)
22 | repo.fetchPosts()
23 | }
24 |
25 | fun refreshPosts() {
26 | repo.refreshPosts()
27 | }
28 |
29 | override fun onCleared() {
30 | super.onCleared()
31 | //clear the disposables when the viewmodel is cleared
32 | compositeDisposable.clear()
33 | PostDH.destroyListComponent()
34 | }
35 | }
--------------------------------------------------------------------------------
/posts/src/main/java/com/karntrehan/posts/list/viewmodel/ListViewModelFactory.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.viewmodel
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.ViewModelProvider
5 | import com.karntrehan.posts.list.model.ListDataContract
6 | import io.reactivex.disposables.CompositeDisposable
7 |
8 | @Suppress("UNCHECKED_CAST")
9 | class ListViewModelFactory(private val repository: ListDataContract.Repository, private val compositeDisposable: CompositeDisposable) :
10 | ViewModelProvider.Factory {
11 | override fun create(modelClass: Class): T {
12 | return ListViewModel(repository, compositeDisposable) as T
13 | }
14 | }
--------------------------------------------------------------------------------
/posts/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
13 |
19 |
22 |
25 |
26 |
27 |
28 |
34 |
35 |
--------------------------------------------------------------------------------
/posts/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/posts/src/main/res/layout/activity_details.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
15 |
16 |
22 |
23 |
30 |
31 |
35 |
36 |
43 |
44 |
53 |
54 |
55 |
59 |
60 |
66 |
67 |
74 |
75 |
85 |
86 |
87 |
88 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
110 |
111 |
119 |
120 |
121 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/posts/src/main/res/layout/activity_list.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
18 |
19 |
--------------------------------------------------------------------------------
/posts/src/main/res/layout/comment_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
19 |
20 |
27 |
28 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/posts/src/main/res/layout/post_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
19 |
20 |
27 |
28 |
36 |
37 |
42 |
43 |
50 |
51 |
61 |
62 |
63 |
64 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/posts/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/karntrehan/Posts/b22ec155b9ddf831a7670cf216187dde3b0264af/posts/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/posts/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 64dp
4 |
--------------------------------------------------------------------------------
/posts/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Posts
3 | Know More >
4 | Need internet to fetch latest posts!
5 | Failed to load posts. Please try again later.
6 | User\'s Avatar Image
7 | movie
8 | b
9 | No comments found for this post!
10 |
11 |
--------------------------------------------------------------------------------
/posts/src/main/resources/api-response/comments.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "postId": 1,
4 | "id": 1,
5 | "name": "id labore ex et quam laborum",
6 | "email": "Eliseo@gardner.biz",
7 | "body": "laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
8 | },
9 | {
10 | "postId": 1,
11 | "id": 2,
12 | "name": "quo vero reiciendis velit similique earum",
13 | "email": "Jayne_Kuhic@sydney.com",
14 | "body": "est natus enim nihil est dolore omnis voluptatem numquam\net omnis occaecati quod ullam at\nvoluptatem error expedita pariatur\nnihil sint nostrum voluptatem reiciendis et"
15 | },
16 | {
17 | "postId": 1,
18 | "id": 3,
19 | "name": "odio adipisci rerum aut animi",
20 | "email": "Nikita@garfield.biz",
21 | "body": "quia molestiae reprehenderit quasi aspernatur\naut expedita occaecati aliquam eveniet laudantium\nomnis quibusdam delectus saepe quia accusamus maiores nam est\ncum et ducimus et vero voluptates excepturi deleniti ratione"
22 | },
23 | {
24 | "postId": 1,
25 | "id": 4,
26 | "name": "alias odio sit",
27 | "email": "Lew@alysha.tv",
28 | "body": "non et atque\noccaecati deserunt quas accusantium unde odit nobis qui voluptatem\nquia voluptas consequuntur itaque dolor\net qui rerum deleniti ut occaecati"
29 | },
30 | {
31 | "postId": 1,
32 | "id": 5,
33 | "name": "vero eaque aliquid doloribus et culpa",
34 | "email": "Hayden@althea.biz",
35 | "body": "harum non quasi et ratione\ntempore iure ex voluptates in ratione\nharum architecto fugit inventore cupiditate\nvoluptates magni quo et"
36 | }
37 | ]
--------------------------------------------------------------------------------
/posts/src/main/resources/api-response/posts.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "userId": 1,
4 | "id": 1,
5 | "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
6 | "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
7 | },
8 | {
9 | "userId": 1,
10 | "id": 2,
11 | "title": "qui est esse",
12 | "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"
13 | },
14 | {
15 | "userId": 1,
16 | "id": 3,
17 | "title": "ea molestias quasi exercitationem repellat qui ipsa sit aut",
18 | "body": "et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut"
19 | },
20 | {
21 | "userId": 1,
22 | "id": 4,
23 | "title": "eum et est occaecati",
24 | "body": "ullam et saepe reiciendis voluptatem adipisci\nsit amet autem assumenda provident rerum culpa\nquis hic commodi nesciunt rem tenetur doloremque ipsam iure\nquis sunt voluptatem rerum illo velit"
25 | },
26 | {
27 | "userId": 1,
28 | "id": 5,
29 | "title": "nesciunt quas odio",
30 | "body": "repudiandae veniam quaerat sunt sed\nalias aut fugiat sit autem sed est\nvoluptatem omnis possimus esse voluptatibus quis\nest aut tenetur dolor neque"
31 | },
32 | {
33 | "userId": 1,
34 | "id": 6,
35 | "title": "dolorem eum magni eos aperiam quia",
36 | "body": "ut aspernatur corporis harum nihil quis provident sequi\nmollitia nobis aliquid molestiae\nperspiciatis et ea nemo ab reprehenderit accusantium quas\nvoluptate dolores velit et doloremque molestiae"
37 | },
38 | {
39 | "userId": 1,
40 | "id": 7,
41 | "title": "magnam facilis autem",
42 | "body": "dolore placeat quibusdam ea quo vitae\nmagni quis enim qui quis quo nemo aut saepe\nquidem repellat excepturi ut quia\nsunt ut sequi eos ea sed quas"
43 | },
44 | {
45 | "userId": 1,
46 | "id": 8,
47 | "title": "dolorem dolore est ipsam",
48 | "body": "dignissimos aperiam dolorem qui eum\nfacilis quibusdam animi sint suscipit qui sint possimus cum\nquaerat magni maiores excepturi\nipsam ut commodi dolor voluptatum modi aut vitae"
49 | },
50 | {
51 | "userId": 1,
52 | "id": 9,
53 | "title": "nesciunt iure omnis dolorem tempora et accusantium",
54 | "body": "consectetur animi nesciunt iure dolore\nenim quia ad\nveniam autem ut quam aut nobis\net est aut quod aut provident voluptas autem voluptas"
55 | },
56 | {
57 | "userId": 1,
58 | "id": 10,
59 | "title": "optio molestias id quia eum",
60 | "body": "quo et expedita modi cum officia vel magni\ndoloribus qui repudiandae\nvero nisi sit\nquos veniam quod sed accusamus veritatis error"
61 | }
62 | ]
--------------------------------------------------------------------------------
/posts/src/main/resources/api-response/users.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "name": "Leanne Graham",
5 | "username": "Bret",
6 | "email": "Sincere@april.biz",
7 | "address": {
8 | "street": "Kulas Light",
9 | "suite": "Apt. 556",
10 | "city": "Gwenborough",
11 | "zipcode": "92998-3874",
12 | "geo": {
13 | "lat": "-37.3159",
14 | "lng": "81.1496"
15 | }
16 | },
17 | "phone": "1-770-736-8031 x56442",
18 | "website": "hildegard.org",
19 | "company": {
20 | "name": "Romaguera-Crona",
21 | "catchPhrase": "Multi-layered client-server neural-net",
22 | "bs": "harness real-time e-markets"
23 | }
24 | },
25 | {
26 | "id": 2,
27 | "name": "Ervin Howell",
28 | "username": "Antonette",
29 | "email": "Shanna@melissa.tv",
30 | "address": {
31 | "street": "Victor Plains",
32 | "suite": "Suite 879",
33 | "city": "Wisokyburgh",
34 | "zipcode": "90566-7771",
35 | "geo": {
36 | "lat": "-43.9509",
37 | "lng": "-34.4618"
38 | }
39 | },
40 | "phone": "010-692-6593 x09125",
41 | "website": "anastasia.net",
42 | "company": {
43 | "name": "Deckow-Crist",
44 | "catchPhrase": "Proactive didactic contingency",
45 | "bs": "synergize scalable supply-chains"
46 | }
47 | },
48 | {
49 | "id": 3,
50 | "name": "Clementine Bauch",
51 | "username": "Samantha",
52 | "email": "Nathan@yesenia.net",
53 | "address": {
54 | "street": "Douglas Extension",
55 | "suite": "Suite 847",
56 | "city": "McKenziehaven",
57 | "zipcode": "59590-4157",
58 | "geo": {
59 | "lat": "-68.6102",
60 | "lng": "-47.0653"
61 | }
62 | },
63 | "phone": "1-463-123-4447",
64 | "website": "ramiro.info",
65 | "company": {
66 | "name": "Romaguera-Jacobson",
67 | "catchPhrase": "Face to face bifurcated interface",
68 | "bs": "e-enable strategic applications"
69 | }
70 | },
71 | {
72 | "id": 4,
73 | "name": "Patricia Lebsack",
74 | "username": "Karianne",
75 | "email": "Julianne.OConner@kory.org",
76 | "address": {
77 | "street": "Hoeger Mall",
78 | "suite": "Apt. 692",
79 | "city": "South Elvis",
80 | "zipcode": "53919-4257",
81 | "geo": {
82 | "lat": "29.4572",
83 | "lng": "-164.2990"
84 | }
85 | },
86 | "phone": "493-170-9623 x156",
87 | "website": "kale.biz",
88 | "company": {
89 | "name": "Robel-Corkery",
90 | "catchPhrase": "Multi-tiered zero tolerance productivity",
91 | "bs": "transition cutting-edge web services"
92 | }
93 | },
94 | {
95 | "id": 5,
96 | "name": "Chelsey Dietrich",
97 | "username": "Kamren",
98 | "email": "Lucio_Hettinger@annie.ca",
99 | "address": {
100 | "street": "Skiles Walks",
101 | "suite": "Suite 351",
102 | "city": "Roscoeview",
103 | "zipcode": "33263",
104 | "geo": {
105 | "lat": "-31.8129",
106 | "lng": "62.5342"
107 | }
108 | },
109 | "phone": "(254)954-1289",
110 | "website": "demarco.info",
111 | "company": {
112 | "name": "Keebler LLC",
113 | "catchPhrase": "User-centric fault-tolerant solution",
114 | "bs": "revolutionize end-to-end systems"
115 | }
116 | },
117 | {
118 | "id": 6,
119 | "name": "Mrs. Dennis Schulist",
120 | "username": "Leopoldo_Corkery",
121 | "email": "Karley_Dach@jasper.info",
122 | "address": {
123 | "street": "Norberto Crossing",
124 | "suite": "Apt. 950",
125 | "city": "South Christy",
126 | "zipcode": "23505-1337",
127 | "geo": {
128 | "lat": "-71.4197",
129 | "lng": "71.7478"
130 | }
131 | },
132 | "phone": "1-477-935-8478 x6430",
133 | "website": "ola.org",
134 | "company": {
135 | "name": "Considine-Lockman",
136 | "catchPhrase": "Synchronised bottom-line interface",
137 | "bs": "e-enable innovative applications"
138 | }
139 | },
140 | {
141 | "id": 7,
142 | "name": "Kurtis Weissnat",
143 | "username": "Elwyn.Skiles",
144 | "email": "Telly.Hoeger@billy.biz",
145 | "address": {
146 | "street": "Rex Trail",
147 | "suite": "Suite 280",
148 | "city": "Howemouth",
149 | "zipcode": "58804-1099",
150 | "geo": {
151 | "lat": "24.8918",
152 | "lng": "21.8984"
153 | }
154 | },
155 | "phone": "210.067.6132",
156 | "website": "elvis.io",
157 | "company": {
158 | "name": "Johns Group",
159 | "catchPhrase": "Configurable multimedia task-force",
160 | "bs": "generate enterprise e-tailers"
161 | }
162 | },
163 | {
164 | "id": 8,
165 | "name": "Nicholas Runolfsdottir V",
166 | "username": "Maxime_Nienow",
167 | "email": "Sherwood@rosamond.me",
168 | "address": {
169 | "street": "Ellsworth Summit",
170 | "suite": "Suite 729",
171 | "city": "Aliyaview",
172 | "zipcode": "45169",
173 | "geo": {
174 | "lat": "-14.3990",
175 | "lng": "-120.7677"
176 | }
177 | },
178 | "phone": "586.493.6943 x140",
179 | "website": "jacynthe.com",
180 | "company": {
181 | "name": "Abernathy Group",
182 | "catchPhrase": "Implemented secondary concept",
183 | "bs": "e-enable extensible e-tailers"
184 | }
185 | },
186 | {
187 | "id": 9,
188 | "name": "Glenna Reichert",
189 | "username": "Delphine",
190 | "email": "Chaim_McDermott@dana.io",
191 | "address": {
192 | "street": "Dayna Park",
193 | "suite": "Suite 449",
194 | "city": "Bartholomebury",
195 | "zipcode": "76495-3109",
196 | "geo": {
197 | "lat": "24.6463",
198 | "lng": "-168.8889"
199 | }
200 | },
201 | "phone": "(775)976-6794 x41206",
202 | "website": "conrad.com",
203 | "company": {
204 | "name": "Yost and Sons",
205 | "catchPhrase": "Switchable contextually-based project",
206 | "bs": "aggregate real-time technologies"
207 | }
208 | },
209 | {
210 | "id": 10,
211 | "name": "Clementina DuBuque",
212 | "username": "Moriah.Stanton",
213 | "email": "Rey.Padberg@karina.biz",
214 | "address": {
215 | "street": "Kattie Turnpike",
216 | "suite": "Suite 198",
217 | "city": "Lebsackbury",
218 | "zipcode": "31428-2261",
219 | "geo": {
220 | "lat": "-38.2386",
221 | "lng": "57.2232"
222 | }
223 | },
224 | "phone": "024-648-3804",
225 | "website": "ambrose.net",
226 | "company": {
227 | "name": "Hoeger LLC",
228 | "catchPhrase": "Centralized empowering task-force",
229 | "bs": "target end-to-end models"
230 | }
231 | }
232 | ]
--------------------------------------------------------------------------------
/posts/src/test/java/com/karntrehan/posts/commons/data/remote/PostServiceTest.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.commons.data.remote
2 |
3 | import com.karntrehan.posts.core.testing.DependencyProvider
4 | import okhttp3.mockwebserver.MockResponse
5 | import okhttp3.mockwebserver.MockWebServer
6 | import org.junit.After
7 | import org.junit.Assert
8 | import org.junit.Before
9 | import org.junit.Test
10 | import org.junit.runner.RunWith
11 | import org.robolectric.RobolectricTestRunner
12 | import java.io.IOException
13 |
14 | /**
15 | * Tests for [PostService]
16 | */
17 | @RunWith(RobolectricTestRunner::class)
18 | class PostServiceTest {
19 |
20 | private lateinit var postService: PostService
21 | private lateinit var mockWebServer: MockWebServer
22 |
23 | @Before
24 | fun init() {
25 | mockWebServer = MockWebServer()
26 | postService = DependencyProvider
27 | .getRetrofit(mockWebServer.url("/"))
28 | .create(PostService::class.java)
29 |
30 | }
31 |
32 | @After
33 | @Throws(IOException::class)
34 | fun tearDown() {
35 | mockWebServer.shutdown()
36 | }
37 |
38 | @Test
39 | fun getUsers() {
40 | queueResponse {
41 | setResponseCode(200)
42 | setBody(DependencyProvider.getResponseFromJson("users"))
43 | }
44 |
45 | postService
46 | .getUsers()
47 | .test()
48 | .run {
49 | assertNoErrors()
50 | assertValueCount(1)
51 | Assert.assertEquals(values()[0].size, 10)
52 | Assert.assertEquals(values()[0][0].userName, "Leanne Graham")
53 | Assert.assertEquals(values()[0][0].id, 1)
54 | }
55 | }
56 |
57 | @Test
58 | fun getPosts() {
59 | queueResponse {
60 | setResponseCode(200)
61 | setBody(DependencyProvider.getResponseFromJson("posts"))
62 | }
63 |
64 | postService
65 | .getPosts()
66 | .test()
67 | .run {
68 | assertNoErrors()
69 | assertValueCount(1)
70 | Assert.assertEquals(values()[0].size, 10)
71 | Assert.assertEquals(values()[0][0].postTitle, "sunt aut facere repellat " +
72 | "provident occaecati excepturi optio reprehenderit")
73 | Assert.assertEquals(values()[0][0].userId, 1)
74 | }
75 | }
76 |
77 | @Test
78 | fun getComments() {
79 | queueResponse {
80 | setResponseCode(200)
81 | setBody(DependencyProvider.getResponseFromJson("comments"))
82 | }
83 |
84 | postService
85 | .getComments(1)
86 | .test()
87 | .run {
88 | assertNoErrors()
89 | assertValueCount(1)
90 | Assert.assertEquals(values()[0].size, 5)
91 | Assert.assertEquals(values()[0][0].id, 1)
92 | Assert.assertEquals(values()[0][0].email, "Eliseo@gardner.biz")
93 | }
94 | }
95 |
96 |
97 | private fun queueResponse(block: MockResponse.() -> Unit) {
98 | mockWebServer.enqueue(MockResponse().apply(block))
99 | }
100 | }
--------------------------------------------------------------------------------
/posts/src/test/java/com/karntrehan/posts/details/model/DetailsRemoteDataTest.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.model
2 |
3 | import com.karntrehan.posts.commons.data.remote.PostService
4 | import com.karntrehan.posts.commons.testing.DummyData
5 | import com.karntrehan.posts.list.model.ListRemoteData
6 | import com.nhaarman.mockito_kotlin.mock
7 | import com.nhaarman.mockito_kotlin.whenever
8 | import io.reactivex.Single
9 | import org.junit.Assert.assertEquals
10 | import org.junit.Test
11 | import org.junit.runner.RunWith
12 | import org.robolectric.RobolectricTestRunner
13 |
14 | /**
15 | * Tests for [ListRemoteData]
16 | */
17 |
18 | @RunWith(RobolectricTestRunner::class)
19 | class DetailsRemoteDataTest {
20 | private val postService = mock()
21 |
22 | @Test
23 | fun getCommentsForPost() {
24 | val userId = 1
25 | whenever(postService.getComments(userId)).thenReturn(
26 | Single.just(
27 | listOf(
28 | DummyData.Comment(userId, 101),
29 | DummyData.Comment(userId, 102),
30 | DummyData.Comment(userId, 103)
31 | )
32 | )
33 | )
34 |
35 | DetailsRemoteData(postService).getCommentsForPost(userId).test().run {
36 | assertNoErrors()
37 | assertValueCount(1)
38 | assertEquals(values()[0].size, 3)
39 | assertEquals(values()[0][0].id, 101)
40 | assertEquals(values()[0][1].id, 102)
41 | assertEquals(values()[0][2].id, 103)
42 | }
43 | }
44 | }
--------------------------------------------------------------------------------
/posts/src/test/java/com/karntrehan/posts/details/model/DetailsRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.model
2 |
3 | import com.karntrehan.posts.commons.testing.DummyData
4 | import com.karntrehan.posts.core.testing.TestScheduler
5 | import com.karntrehan.posts.commons.data.local.Comment
6 | import com.mpaani.core.networking.Outcome
7 | import com.nhaarman.mockito_kotlin.*
8 | import io.reactivex.Flowable
9 | import io.reactivex.Single
10 | import io.reactivex.disposables.CompositeDisposable
11 | import io.reactivex.observers.TestObserver
12 | import org.junit.Before
13 | import org.junit.Test
14 | import org.junit.runner.RunWith
15 | import org.robolectric.RobolectricTestRunner
16 | import java.io.IOException
17 |
18 | /**
19 | * Tests for [DetailsRepository]
20 | * */
21 | @RunWith(RobolectricTestRunner::class)
22 | class DetailsRepositoryTest {
23 | private lateinit var repository: DetailsRepository
24 | private val local: DetailsDataContract.Local = mock()
25 | private val remote: DetailsDataContract.Remote = mock()
26 | private val compositeDisposable = CompositeDisposable()
27 | private val postId = 1
28 |
29 | @Before
30 | fun init() {
31 | repository = DetailsRepository(local, remote, TestScheduler(), compositeDisposable)
32 | whenever(local.getCommentsForPost(postId)).doReturn(Flowable.just(emptyList()))
33 | whenever(remote.getCommentsForPost(postId)).doReturn(Single.just(emptyList()))
34 | }
35 |
36 | /**
37 | * Verify if calling [DetailsRepository.fetchCommentsFor] triggers [DetailsDataContract.Local.getCommentsForPost]
38 | * and it's result is added to the [DetailsRepository.commentsFetchOutcome]
39 | * */
40 | @Test
41 | fun testFetchCommentsFor() {
42 | val comments = listOf(DummyData.Comment(postId, 1), DummyData.Comment(postId, 2))
43 | whenever(local.getCommentsForPost(postId)).doReturn(Flowable.just(comments))
44 |
45 | val testObs = TestObserver>>()
46 | repository.commentsFetchOutcome.subscribe(testObs)
47 |
48 | testObs.assertEmpty()
49 |
50 | repository.fetchCommentsFor(postId)
51 |
52 | verify(local).getCommentsForPost(postId)
53 |
54 | testObs.assertValueAt(0, Outcome.loading(true))
55 | testObs.assertValueAt(1, Outcome.loading(false))
56 | testObs.assertValueAt(2, Outcome.success(comments))
57 | }
58 |
59 | /**
60 | * Verify successful refresh of posts and users triggers [DetailsDataContract.Local.saveComments]
61 | * */
62 | @Test
63 | fun testRefreshPostsTriggersSave() {
64 | val dummyComments = listOf(DummyData.Comment(postId, 1), DummyData.Comment(postId, 2))
65 | whenever(remote.getCommentsForPost(postId)).doReturn(Single.just(dummyComments))
66 |
67 | repository.refreshComments(postId)
68 | verify(local).saveComments(dummyComments)
69 | }
70 |
71 | /**
72 | * Verify erred refresh of posts and users pushes to [DetailsDataContract.Repository.commentsFetchOutcome]
73 | * with error
74 | * */
75 | @Test
76 | fun testRefreshPostsFailurePushesToOutcome() {
77 | val exception = IOException()
78 | whenever(remote.getCommentsForPost(postId)).doReturn(Single.error(exception))
79 |
80 | val obs = TestObserver>>()
81 | repository.commentsFetchOutcome.subscribe(obs)
82 |
83 | repository.refreshComments(postId)
84 |
85 | obs.assertValueAt(0, Outcome.loading(true))
86 | obs.assertValueAt(1, Outcome.loading(false))
87 | obs.assertValueAt(2, Outcome.failure(exception))
88 | }
89 | }
--------------------------------------------------------------------------------
/posts/src/test/java/com/karntrehan/posts/details/viewmodel/DetailsViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.details.viewmodel
2 |
3 | import androidx.lifecycle.Observer
4 | import com.karntrehan.posts.commons.testing.DummyData
5 | import com.karntrehan.posts.commons.data.local.Comment
6 | import com.karntrehan.posts.details.model.DetailsDataContract
7 | import com.mpaani.core.networking.Outcome
8 | import com.nhaarman.mockito_kotlin.doReturn
9 | import com.nhaarman.mockito_kotlin.mock
10 | import com.nhaarman.mockito_kotlin.verify
11 | import com.nhaarman.mockito_kotlin.whenever
12 | import io.reactivex.disposables.CompositeDisposable
13 | import io.reactivex.subjects.PublishSubject
14 | import org.junit.Before
15 | import org.junit.Test
16 | import org.junit.runner.RunWith
17 | import org.robolectric.RobolectricTestRunner
18 | import java.io.IOException
19 |
20 |
21 | /**
22 | * Tests for [DetailsViewModel]
23 | * */
24 | @RunWith(RobolectricTestRunner::class)
25 | class DetailsViewModelTest {
26 | private lateinit var viewModel: DetailsViewModel
27 | private val repo: DetailsDataContract.Repository = mock()
28 | private val compositeDisposable = CompositeDisposable()
29 | private val outcome: Observer>> = mock()
30 |
31 | private val postId = 1
32 |
33 | @Before
34 | fun init() {
35 | viewModel = DetailsViewModel(repo, compositeDisposable)
36 | whenever(repo.commentsFetchOutcome).doReturn(PublishSubject.create())
37 | viewModel.commentsOutcome.observeForever(outcome)
38 | }
39 |
40 |
41 | /**
42 | * Test [DetailsViewModel.loadCommentsFor] triggers [DetailsDataContract.Repository.fetchCommentsFor] &
43 | * livedata [DetailsViewModel.commentsOutcome] gets outcomes pushed
44 | * from [DetailsDataContract.Repository.commentsFetchOutcome]
45 | * */
46 | @Test
47 | fun testLoadCommentsSuccess() {
48 | viewModel.loadCommentsFor(postId)
49 | verify(repo).fetchCommentsFor(postId)
50 |
51 | repo.commentsFetchOutcome.onNext(Outcome.loading(true))
52 | verify(outcome).onChanged(Outcome.loading(true))
53 |
54 | repo.commentsFetchOutcome.onNext(Outcome.loading(false))
55 | verify(outcome).onChanged(Outcome.loading(false))
56 |
57 | val data = listOf(DummyData.Comment(postId, 1), DummyData.Comment(postId, 2))
58 | repo.commentsFetchOutcome.onNext(Outcome.success(data))
59 | verify(outcome).onChanged(Outcome.success(data))
60 | }
61 |
62 | /**
63 | * Test that [DetailsDataContract.Repository.commentsFetchOutcome] on exception passes exception to
64 | * live [DetailsViewModel.commentsOutcome]
65 | * */
66 | @Test
67 | fun testLoadCommentsError() {
68 | val exception = IOException()
69 | repo.commentsFetchOutcome.onNext(Outcome.failure(exception))
70 | verify(outcome).onChanged(Outcome.failure(exception))
71 | }
72 |
73 | /**
74 | * Verify [DetailsViewModel.refreshCommentsFor] triggers [DetailsDataContract.Repository.refreshComments]
75 | **/
76 | @Test
77 | fun testRefreshComments() {
78 | viewModel.refreshCommentsFor(postId)
79 | verify(repo).refreshComments(postId)
80 | }
81 | }
--------------------------------------------------------------------------------
/posts/src/test/java/com/karntrehan/posts/list/model/ListRemoteDataTest.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.model
2 |
3 | import com.karntrehan.posts.commons.data.remote.PostService
4 | import com.karntrehan.posts.commons.testing.DummyData
5 | import com.nhaarman.mockito_kotlin.mock
6 | import com.nhaarman.mockito_kotlin.whenever
7 | import io.reactivex.Single
8 | import org.junit.Assert.assertEquals
9 | import org.junit.Test
10 | import org.junit.runner.RunWith
11 | import org.robolectric.RobolectricTestRunner
12 |
13 | /**
14 | * Tests for [ListRemoteData]
15 | */
16 |
17 | @RunWith(RobolectricTestRunner::class)
18 | class ListRemoteDataTest {
19 |
20 | private val postService = mock()
21 |
22 | @Test
23 | fun getPosts() {
24 | whenever(postService.getPosts()).thenReturn(
25 | Single.just(
26 | listOf(
27 | DummyData.Post(1, 101),
28 | DummyData.Post(2, 102)
29 | )
30 | )
31 | )
32 |
33 | ListRemoteData(postService).getPosts().test().run {
34 | assertNoErrors()
35 | assertValueCount(1)
36 | assertEquals(values()[0].size, 2)
37 | assertEquals(values()[0][0].userId, 1)
38 | assertEquals(values()[0][0].postId, 101)
39 | assertEquals(values()[0][1].userId, 2)
40 | assertEquals(values()[0][1].postId, 102)
41 |
42 | }
43 | }
44 |
45 | @Test
46 | fun getUsers() {
47 | whenever(postService.getUsers()).thenReturn(
48 | Single.just(
49 | listOf(
50 | DummyData.User(201),
51 | DummyData.User(202)
52 | )
53 | )
54 | )
55 |
56 | ListRemoteData(postService).getUsers().test().run {
57 | assertNoErrors()
58 | assertValueCount(1)
59 | assertEquals(values()[0].size, 2)
60 | assertEquals(values()[0][0].id, 201)
61 | assertEquals(values()[0][1].id, 202)
62 | }
63 | }
64 | }
--------------------------------------------------------------------------------
/posts/src/test/java/com/karntrehan/posts/list/model/ListRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.model
2 |
3 | import com.karntrehan.posts.commons.testing.DummyData
4 | import com.karntrehan.posts.core.testing.TestScheduler
5 | import com.karntrehan.posts.commons.data.PostWithUser
6 | import com.mpaani.core.networking.Outcome
7 | import com.nhaarman.mockito_kotlin.*
8 | import io.reactivex.Flowable
9 | import io.reactivex.Single
10 | import io.reactivex.disposables.CompositeDisposable
11 | import io.reactivex.observers.TestObserver
12 | import org.junit.Before
13 | import org.junit.Test
14 | import org.junit.runner.RunWith
15 | import org.robolectric.RobolectricTestRunner
16 | import java.io.IOException
17 |
18 | /**
19 | * Tests for [ListRepository]
20 | * */
21 | @RunWith(RobolectricTestRunner::class)
22 | class ListRepositoryTest {
23 | private val local: ListDataContract.Local = mock()
24 | private val remote: ListDataContract.Remote = mock()
25 |
26 | private lateinit var repository: ListRepository
27 | private val compositeDisposable = CompositeDisposable()
28 |
29 | @Before
30 | fun init() {
31 | repository = ListRepository(local, remote, TestScheduler(), compositeDisposable)
32 | whenever(local.getPostsWithUsers()).doReturn(Flowable.just(emptyList()))
33 | whenever(remote.getUsers()).doReturn(Single.just(emptyList()))
34 | whenever(remote.getPosts()).doReturn(Single.just(emptyList()))
35 | }
36 |
37 | /**
38 | * Verify if calling [ListRepository.fetchPosts] triggers [ListDataContract.Local.getPostsWithUsers]
39 | * and it's result is added to the [ListRepository.postFetchOutcome]
40 | * */
41 | @Test
42 | fun testFetchPosts() {
43 | val postWithUsersSuccess = listOf(DummyData.PostWithUser(1), DummyData.PostWithUser(2))
44 | whenever(local.getPostsWithUsers()).doReturn(Flowable.just(postWithUsersSuccess))
45 |
46 | val obs = TestObserver>>()
47 |
48 | repository.postFetchOutcome.subscribe(obs)
49 | obs.assertEmpty()
50 |
51 | repository.fetchPosts()
52 | verify(local).getPostsWithUsers()
53 |
54 | obs.assertValueAt(0, Outcome.loading(true))
55 | obs.assertValueAt(1, Outcome.loading(false))
56 | obs.assertValueAt(2, Outcome.success(postWithUsersSuccess))
57 | }
58 |
59 | /**
60 | * Verify successful refresh of posts and users triggers [ListDataContract.Local.saveUsersAndPosts]
61 | * */
62 | @Test
63 | fun testRefreshPostsTriggersSave() {
64 | val userId = 1
65 | val dummyUsers = listOf(DummyData.User(userId))
66 | val dummyPosts = listOf(DummyData.Post(userId, 1))
67 | whenever(remote.getUsers()).doReturn(Single.just(dummyUsers))
68 | whenever(remote.getPosts()).doReturn(Single.just(dummyPosts))
69 |
70 | repository.refreshPosts()
71 | verify(local).saveUsersAndPosts(dummyUsers, dummyPosts)
72 | }
73 |
74 | /**
75 | * Verify erred refresh of posts and users pushes to [ListDataContract.Repository.postFetchOutcome]
76 | * with error
77 | * */
78 | @Test
79 | fun testRefreshPostsFailurePushesToOutcome() {
80 | val exception = IOException()
81 | whenever(remote.getUsers()).doReturn(Single.error(exception))
82 |
83 | val obs = TestObserver>>()
84 | repository.postFetchOutcome.subscribe(obs)
85 |
86 | repository.refreshPosts()
87 |
88 | obs.assertValueAt(0, Outcome.loading(true))
89 | obs.assertValueAt(1, Outcome.loading(false))
90 | obs.assertValueAt(2, Outcome.failure(exception))
91 | }
92 | }
--------------------------------------------------------------------------------
/posts/src/test/java/com/karntrehan/posts/list/viewmodel/ListViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.karntrehan.posts.list.viewmodel
2 |
3 | import androidx.lifecycle.Observer
4 | import com.karntrehan.posts.commons.testing.DummyData
5 | import com.karntrehan.posts.commons.data.PostWithUser
6 | import com.karntrehan.posts.list.model.ListDataContract
7 | import com.mpaani.core.networking.Outcome
8 | import com.nhaarman.mockito_kotlin.doReturn
9 | import com.nhaarman.mockito_kotlin.mock
10 | import com.nhaarman.mockito_kotlin.verify
11 | import com.nhaarman.mockito_kotlin.whenever
12 | import io.reactivex.disposables.CompositeDisposable
13 | import io.reactivex.subjects.PublishSubject
14 | import org.junit.Before
15 | import org.junit.Test
16 | import org.junit.runner.RunWith
17 | import org.robolectric.RobolectricTestRunner
18 | import java.io.IOException
19 |
20 | /**
21 | * Tests for [ListViewModel]
22 | * */
23 | @RunWith(RobolectricTestRunner::class)
24 | class ListViewModelTest {
25 |
26 | private lateinit var viewModel: ListViewModel
27 |
28 | private val repo: ListDataContract.Repository = mock()
29 |
30 | private val outcome: Observer>> = mock()
31 |
32 | @Before
33 | fun init() {
34 | viewModel = ListViewModel(repo, CompositeDisposable())
35 | whenever(repo.postFetchOutcome).doReturn(PublishSubject.create())
36 | viewModel.postsOutcome.observeForever(outcome)
37 | }
38 |
39 | /**
40 | * Test [ListViewModel.getPosts] triggers [ListDataContract.Repository.fetchPosts] &
41 | * livedata [ListViewModel.postsOutcome] gets outcomes pushed
42 | * from [ListDataContract.Repository.postFetchOutcome]
43 | * */
44 | @Test
45 | fun testGetPostsSuccess() {
46 | viewModel.getPosts()
47 | verify(repo).fetchPosts()
48 |
49 | repo.postFetchOutcome.onNext(Outcome.loading(true))
50 | verify(outcome).onChanged(Outcome.loading(true))
51 |
52 | repo.postFetchOutcome.onNext(Outcome.loading(false))
53 | verify(outcome).onChanged(Outcome.loading(false))
54 |
55 | val data = listOf(DummyData.PostWithUser(1), DummyData.PostWithUser(2))
56 | repo.postFetchOutcome.onNext(Outcome.success(data))
57 | verify(outcome).onChanged(Outcome.success(data))
58 | }
59 |
60 | /**
61 | * Test that [ListDataContract.Repository.postFetchOutcome] on exception passes exception to
62 | * live [ListViewModel.postsOutcome]
63 | * */
64 | @Test
65 | fun testGetPostsError() {
66 | val exception = IOException()
67 | repo.postFetchOutcome.onNext(Outcome.failure(exception))
68 | verify(outcome).onChanged(Outcome.failure(exception))
69 | }
70 |
71 | /**
72 | * Verify [ListViewModel.refreshPosts] triggers [ListDataContract.Repository.refreshPosts]
73 | * */
74 | @Test
75 | fun testRefreshPosts() {
76 | viewModel.refreshPosts()
77 | verify(repo).refreshPosts()
78 | }
79 | }
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':core', ':posts'
2 |
--------------------------------------------------------------------------------