├── module_androidtest_only
├── .gitignore
├── src
│ └── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── com
│ │ └── hitherejoe
│ │ └── module_androidtest_only
│ │ ├── injection
│ │ ├── component
│ │ │ ├── TestComponent.java
│ │ │ └── DataManagerTestComponent.java
│ │ ├── module
│ │ │ ├── DataManagerTestModule.java
│ │ │ └── ApplicationTestModule.java
│ │ └── TestComponentRule.java
│ │ ├── util
│ │ └── TestDataManager.java
│ │ └── MainActivityTest.java
├── proguard-rules.pro
└── build.gradle
├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── mipmap-hdpi
│ │ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ └── ic_launcher.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ └── ic_launcher.png
│ │ │ ├── drawable-v21
│ │ │ │ └── touchable_background_white.xml
│ │ │ ├── layout
│ │ │ │ ├── activity_main.xml
│ │ │ │ ├── activity_user.xml
│ │ │ │ ├── layout_offline.xml
│ │ │ │ ├── item_comments_header.xml
│ │ │ │ ├── fragment_stories.xml
│ │ │ │ ├── activity_view_story.xml
│ │ │ │ ├── item_comment.xml
│ │ │ │ ├── activity_comments.xml
│ │ │ │ └── item_post.xml
│ │ │ ├── drawable
│ │ │ │ ├── selector_button.xml
│ │ │ │ └── touchable_background_white.xml
│ │ │ ├── values-w820dp
│ │ │ │ └── dimens.xml
│ │ │ ├── menu
│ │ │ │ ├── main.xml
│ │ │ │ └── view_story.xml
│ │ │ ├── values
│ │ │ │ ├── dimens.xml
│ │ │ │ ├── colors.xml
│ │ │ │ ├── styles.xml
│ │ │ │ └── strings.xml
│ │ │ └── xml
│ │ │ │ └── app_tracker.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── hitherejoe
│ │ │ │ └── mvvm_hackernews
│ │ │ │ ├── model
│ │ │ │ ├── User.java
│ │ │ │ ├── Comment.java
│ │ │ │ └── Post.java
│ │ │ │ ├── util
│ │ │ │ ├── ViewUtils.java
│ │ │ │ ├── DataUtils.java
│ │ │ │ ├── DialogFactory.java
│ │ │ │ └── MockModelsUtil.java
│ │ │ │ ├── injection
│ │ │ │ ├── scope
│ │ │ │ │ └── PerDataManager.java
│ │ │ │ ├── component
│ │ │ │ │ ├── DataManagerComponent.java
│ │ │ │ │ └── ApplicationComponent.java
│ │ │ │ └── module
│ │ │ │ │ ├── ApplicationModule.java
│ │ │ │ │ └── DataManagerModule.java
│ │ │ │ ├── data
│ │ │ │ ├── remote
│ │ │ │ │ ├── RetrofitHelper.java
│ │ │ │ │ └── HackerNewsService.java
│ │ │ │ └── DataManager.java
│ │ │ │ ├── view
│ │ │ │ ├── activity
│ │ │ │ │ ├── BaseActivity.java
│ │ │ │ │ ├── UserActivity.java
│ │ │ │ │ ├── MainActivity.java
│ │ │ │ │ ├── CommentsActivity.java
│ │ │ │ │ └── ViewStoryActivity.java
│ │ │ │ ├── adapter
│ │ │ │ │ ├── PostAdapter.java
│ │ │ │ │ └── CommentAdapter.java
│ │ │ │ └── fragment
│ │ │ │ │ └── StoriesFragment.java
│ │ │ │ ├── viewModel
│ │ │ │ ├── CommentHeaderViewModel.java
│ │ │ │ ├── CommentViewModel.java
│ │ │ │ └── PostViewModel.java
│ │ │ │ └── HackerNewsApplication.java
│ │ └── AndroidManifest.xml
│ └── test
│ │ └── java
│ │ └── com
│ │ └── hitherejoe
│ │ └── mvvm_hackernews
│ │ ├── util
│ │ └── DefaultConfig.java
│ │ ├── CommentHeaderViewModelTest.java
│ │ ├── CommentViewModelTest.java
│ │ ├── PostViewModelTest.java
│ │ └── DataManagerTest.java
├── proguard-rules.pro
├── build.gradle
└── manifest-merger-release-report.txt
├── settings.gradle
├── images
└── screens.png
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── .travis.yml
├── circle.yml
├── gradle.properties
├── gradlew.bat
├── README.md
└── gradlew
/module_androidtest_only/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 | *iml
3 | *.iml
4 | .idea
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app', ':module_androidtest_only'
2 |
--------------------------------------------------------------------------------
/images/screens.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YiuChoi/MVVM_Hacker_News/master/images/screens.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YiuChoi/MVVM_Hacker_News/master/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YiuChoi/MVVM_Hacker_News/master/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YiuChoi/MVVM_Hacker_News/master/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YiuChoi/MVVM_Hacker_News/master/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YiuChoi/MVVM_Hacker_News/master/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YiuChoi/MVVM_Hacker_News/master/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .gradle
2 | /local.properties
3 | /.idea/workspace.xml
4 | .DS_Store
5 | /build
6 | .idea/
7 | *iml
8 | *.iml
9 | */build
10 | app/hackernews.apk
11 | app/hackernews12.apk
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v21/touchable_background_white.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_user.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sat Jan 03 10:20:28 GMT 2015
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-2.2.1-all.zip
7 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/selector_button.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/model/User.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.model;
2 |
3 | import java.util.List;
4 |
5 | public class User {
6 |
7 | public String about;
8 | public String id;
9 | public long karma;
10 | public List submitted;
11 |
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/test/java/com/hitherejoe/mvvm_hackernews/util/DefaultConfig.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.util;
2 |
3 | public class DefaultConfig {
4 | //The api level that Robolectric will use to run the unit tests
5 | public static final int EMULATE_SDK = 21;
6 | public static final String MANIFEST = "./src/main/AndroidManifest.xml";
7 | }
--------------------------------------------------------------------------------
/module_androidtest_only/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/touchable_background_white.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values-w820dp/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 64dp
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/util/ViewUtils.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.util;
2 |
3 | import android.content.Context;
4 | import android.util.DisplayMetrics;
5 |
6 | public class ViewUtils {
7 |
8 | public static float convertPixelsToDp(float px, Context context){
9 | DisplayMetrics metrics = context.getResources().getDisplayMetrics();
10 | return px / (metrics.densityDpi / 160f);
11 | }
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/main.xml:
--------------------------------------------------------------------------------
1 |
2 |
11 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/util/DataUtils.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.util;
2 |
3 | import android.content.Context;
4 | import android.net.ConnectivityManager;
5 |
6 | public class DataUtils {
7 |
8 | public static boolean isNetworkAvailable(Context context) {
9 | ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
10 | return connectivityManager.getActiveNetworkInfo() != null;
11 | }
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/injection/scope/PerDataManager.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.injection.scope;
2 |
3 | import java.lang.annotation.Retention;
4 | import java.lang.annotation.RetentionPolicy;
5 |
6 | import javax.inject.Scope;
7 |
8 | /**
9 | * A scoping annotation to permit objects whose lifetime should
10 | * conform to the life of the DataManager to be memorised in the
11 | * correct component.
12 | */
13 | @Scope
14 | @Retention(RetentionPolicy.RUNTIME)
15 | public @interface PerDataManager {
16 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 | 16dp
5 |
6 | 24sp
7 | 22sp
8 | 20sp
9 | 18sp
10 | 16sp
11 | 14sp
12 |
13 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: android
2 | android:
3 | components:
4 | - platform-tools
5 | - tools
6 |
7 | # The BuildTools version used by your project
8 | - build-tools-23.0.0
9 |
10 | # The SDK version used to compile your project
11 | - android-22
12 |
13 | # Additional components
14 | # - extra-google-google_play_services
15 | # - extra-google-m2repository
16 | - extra-android-m2repository
17 |
18 | before_script:
19 | - chmod +x gradlew
20 | #Build, and run tests
21 | script: "./gradlew build testDebugUnitTest"
22 | sudo: false
--------------------------------------------------------------------------------
/module_androidtest_only/src/main/java/com/hitherejoe/module_androidtest_only/injection/component/TestComponent.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.module_androidtest_only.injection.component;
2 |
3 |
4 | import com.hitherejoe.module_androidtest_only.injection.module.ApplicationTestModule;
5 | import com.hitherejoe.mvvm_hackernews.injection.component.ApplicationComponent;
6 |
7 | import javax.inject.Singleton;
8 |
9 | import dagger.Component;
10 |
11 | @Singleton
12 | @Component(modules = ApplicationTestModule.class)
13 | public interface TestComponent extends ApplicationComponent {
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/injection/component/DataManagerComponent.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.injection.component;
2 |
3 | import com.hitherejoe.mvvm_hackernews.data.DataManager;
4 | import com.hitherejoe.mvvm_hackernews.injection.module.DataManagerModule;
5 | import com.hitherejoe.mvvm_hackernews.injection.scope.PerDataManager;
6 |
7 | import dagger.Component;
8 |
9 | @PerDataManager
10 | @Component(dependencies = ApplicationComponent.class, modules = DataManagerModule.class)
11 | public interface DataManagerComponent {
12 |
13 | void inject(DataManager dataManager);
14 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FF6600
4 | #e65c00
5 | #FF8C3F
6 | #D4D4D4
7 | #E9E9E9
8 | #A8A8A8
9 | #FFFFFF
10 | #000000
11 | #FAFAFA
12 | #D7D7D7
13 | #DD000000
14 |
--------------------------------------------------------------------------------
/module_androidtest_only/src/main/java/com/hitherejoe/module_androidtest_only/injection/component/DataManagerTestComponent.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.module_androidtest_only.injection.component;
2 |
3 |
4 | import com.hitherejoe.module_androidtest_only.injection.module.DataManagerTestModule;
5 | import com.hitherejoe.mvvm_hackernews.injection.component.DataManagerComponent;
6 | import com.hitherejoe.mvvm_hackernews.injection.scope.PerDataManager;
7 |
8 | import dagger.Component;
9 |
10 | @PerDataManager
11 | @Component(dependencies = TestComponent.class, modules = DataManagerTestModule.class)
12 | public interface DataManagerTestComponent extends DataManagerComponent {
13 | }
--------------------------------------------------------------------------------
/app/src/main/res/menu/view_story.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/injection/component/ApplicationComponent.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.injection.component;
2 |
3 | import android.app.Application;
4 |
5 | import com.hitherejoe.mvvm_hackernews.data.DataManager;
6 | import com.hitherejoe.mvvm_hackernews.injection.module.ApplicationModule;
7 | import com.hitherejoe.mvvm_hackernews.view.activity.MainActivity;
8 |
9 | import javax.inject.Singleton;
10 |
11 | import dagger.Component;
12 |
13 | @Singleton
14 | @Component(modules = ApplicationModule.class)
15 | public interface ApplicationComponent {
16 |
17 | void inject(MainActivity mainActivity);
18 |
19 | Application application();
20 | DataManager dataManager();
21 | }
--------------------------------------------------------------------------------
/circle.yml:
--------------------------------------------------------------------------------
1 | test:
2 | override:
3 | # start the emulator
4 | - emulator -avd circleci-android22 -no-audio -no-window:
5 | background: true
6 | parallel: true
7 | # wait for it to have booted
8 | - circle-android wait-for-boot
9 | - sleep 30
10 | - adb shell input touchscreen swipe 370 735 370 400
11 | - sleep 30
12 | - adb shell input keyevent 82
13 | - sleep 30
14 | # run tests against the emulator.
15 | - ./gradlew connectedAndroidTest
16 | # copy the build outputs to artifacts
17 | - cp -r app/build/outputs $CIRCLE_ARTIFACTS
18 | # copy the test results to the test results directory.
19 | - cp -r app/build/outputs/androidTest-results/* $CIRCLE_TEST_REPORTS
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/data/remote/RetrofitHelper.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.data.remote;
2 |
3 | import com.google.gson.GsonBuilder;
4 |
5 | import retrofit.RestAdapter;
6 | import retrofit.converter.GsonConverter;
7 |
8 | public class RetrofitHelper {
9 |
10 | public HackerNewsService newHackerNewsService() {
11 | RestAdapter restAdapter = new RestAdapter.Builder()
12 | .setEndpoint(HackerNewsService.ENDPOINT)
13 | .setLogLevel(RestAdapter.LogLevel.FULL)
14 | .setConverter(new GsonConverter(new GsonBuilder().create()))
15 | .build();
16 | return restAdapter.create(HackerNewsService.class);
17 | }
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # By default, the flags in this file are appended to flags specified
3 | # in /Applications/Android Studio.app/sdk/tools/proguard/proguard-android.txt
4 | # You can edit the include path and order by changing the proguardFiles
5 | # directive in build.gradle.
6 | #
7 | # For more details, see
8 | # http://developer.android.com/guide/developing/tools/proguard.html
9 |
10 | # Add any project specific keep options here:
11 |
12 | # If your project uses WebView with JS, uncomment the following
13 | # and specify the fully qualified class name to the JavaScript interface
14 | # class:
15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
16 | # public *;
17 | #}
18 |
--------------------------------------------------------------------------------
/module_androidtest_only/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # By default, the flags in this file are appended to flags specified
3 | # in /Users/hitherejoe/Dev/android-sdk-macosx/tools/proguard/proguard-android.txt
4 | # You can edit the include path and order by changing the proguardFiles
5 | # directive in build.gradle.
6 | #
7 | # For more details, see
8 | # http://developer.android.com/guide/developing/tools/proguard.html
9 |
10 | # Add any project specific keep options here:
11 |
12 | # If your project uses WebView with JS, uncomment the following
13 | # and specify the fully qualified class name to the JavaScript interface
14 | # class:
15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
16 | # public *;
17 | #}
18 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Settings specified in this file will override any Gradle settings
5 | # configured through the IDE.
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 | # Default value: -Xmx10248m -XX:MaxPermSize=256m
13 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
14 |
15 | # When configured, Gradle will run in incubating parallel mode.
16 | # This option should only be used with decoupled projects. More details, visit
17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
18 | # org.gradle.parallel=true
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/view/activity/BaseActivity.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.view.activity;
2 |
3 | import android.app.FragmentManager;
4 | import android.support.v7.app.AppCompatActivity;
5 | import android.view.MenuItem;
6 |
7 | public class BaseActivity extends AppCompatActivity {
8 |
9 | @Override
10 | public boolean onOptionsItemSelected(MenuItem item) {
11 | switch (item.getItemId()) {
12 | case android.R.id.home:
13 | FragmentManager fm = getFragmentManager();
14 | if (fm.getBackStackEntryCount() > 0) {
15 | fm.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE);
16 | } else {
17 | finish();
18 | }
19 | return true;
20 | default:
21 | return super.onOptionsItemSelected(item);
22 | }
23 | }
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/injection/module/ApplicationModule.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.injection.module;
2 |
3 | import android.app.Application;
4 |
5 | import com.hitherejoe.mvvm_hackernews.data.DataManager;
6 |
7 | import javax.inject.Singleton;
8 |
9 | import dagger.Module;
10 | import dagger.Provides;
11 |
12 | /**
13 | * Provide application-level dependencies. Mainly singleton object that can be injected from
14 | * anywhere in the app.
15 | */
16 | @Module
17 | public class ApplicationModule {
18 | protected final Application mApplication;
19 |
20 | public ApplicationModule(Application application) {
21 | mApplication = application;
22 | }
23 |
24 | @Provides
25 | @Singleton
26 | Application provideApplication() {
27 | return mApplication;
28 | }
29 |
30 | @Provides
31 | @Singleton
32 | DataManager provideDataManager() {
33 | return new DataManager(mApplication);
34 | }
35 |
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/injection/module/DataManagerModule.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.injection.module;
2 |
3 | import com.hitherejoe.mvvm_hackernews.data.remote.HackerNewsService;
4 | import com.hitherejoe.mvvm_hackernews.data.remote.RetrofitHelper;
5 | import com.hitherejoe.mvvm_hackernews.injection.scope.PerDataManager;
6 |
7 | import dagger.Module;
8 | import dagger.Provides;
9 | import rx.Scheduler;
10 | import rx.schedulers.Schedulers;
11 |
12 | /**
13 | * Provide dependencies to the DataManager, mainly Helper classes and Retrofit services.
14 | */
15 | @Module
16 | public class DataManagerModule {
17 |
18 | public DataManagerModule() {
19 |
20 | }
21 |
22 | @Provides
23 | @PerDataManager
24 | HackerNewsService provideHackerNewsService() {
25 | return new RetrofitHelper().newHackerNewsService();
26 | }
27 |
28 | @Provides
29 | @PerDataManager
30 | Scheduler provideSubscribeScheduler() {
31 | return Schedulers.io();
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/layout_offline.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
16 |
17 |
27 |
28 |
--------------------------------------------------------------------------------
/module_androidtest_only/src/main/java/com/hitherejoe/module_androidtest_only/injection/module/DataManagerTestModule.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.module_androidtest_only.injection.module;
2 |
3 | import android.content.Context;
4 |
5 | import com.hitherejoe.mvvm_hackernews.data.remote.HackerNewsService;
6 | import com.hitherejoe.mvvm_hackernews.injection.scope.PerDataManager;
7 |
8 | import dagger.Module;
9 | import dagger.Provides;
10 | import rx.Scheduler;
11 | import rx.schedulers.Schedulers;
12 |
13 | import static org.mockito.Mockito.mock;
14 |
15 | /**
16 | * Provides dependencies for an app running on a testing environment
17 | * This allows injecting mocks if necessary
18 | */
19 | @Module
20 | public class DataManagerTestModule {
21 |
22 | public DataManagerTestModule() { }
23 |
24 | @Provides
25 | @PerDataManager
26 | HackerNewsService provideWatchTowerService() {
27 | return mock(HackerNewsService.class);
28 | }
29 |
30 | @Provides
31 | @PerDataManager
32 | Scheduler provideSubscribeScheduler() {
33 | return Schedulers.immediate();
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/module_androidtest_only/src/main/java/com/hitherejoe/module_androidtest_only/injection/module/ApplicationTestModule.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.module_androidtest_only.injection.module;
2 |
3 | import android.app.Application;
4 |
5 | import com.hitherejoe.module_androidtest_only.util.TestDataManager;
6 | import com.hitherejoe.mvvm_hackernews.data.DataManager;
7 |
8 | import javax.inject.Singleton;
9 |
10 | import dagger.Module;
11 | import dagger.Provides;
12 |
13 | /**
14 | * Provides application-level dependencies for an app running on a testing environment
15 | * This allows injecting mocks if necessary.
16 | */
17 | @Module
18 | public class ApplicationTestModule {
19 | private final Application mApplication;
20 |
21 | public ApplicationTestModule(Application application) {
22 | mApplication = application;
23 | }
24 |
25 | @Provides
26 | @Singleton
27 | Application provideApplication() {
28 | return mApplication;
29 | }
30 |
31 | @Provides
32 | @Singleton
33 | DataManager provideDataManager() {
34 | return new TestDataManager(mApplication);
35 | }
36 |
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/viewModel/CommentHeaderViewModel.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.viewModel;
2 |
3 | import android.content.Context;
4 | import android.databinding.BaseObservable;
5 | import android.text.Html;
6 |
7 | import com.hitherejoe.mvvm_hackernews.R;
8 | import com.hitherejoe.mvvm_hackernews.model.Post;
9 |
10 | import org.ocpsoft.prettytime.PrettyTime;
11 |
12 | import java.util.Date;
13 |
14 | public class CommentHeaderViewModel extends BaseObservable {
15 |
16 | private Context context;
17 | private Post post;
18 |
19 | public CommentHeaderViewModel(Context context, Post post) {
20 | this.context = context;
21 | this.post = post;
22 | }
23 |
24 | public String getCommentText() {
25 | return Html.fromHtml(post.text.trim()).toString();
26 | }
27 |
28 | public String getCommentAuthor() {
29 | return context.getResources().getString(R.string.text_comment_author, post.by);
30 | }
31 |
32 | public String getCommentDate() {
33 | return new PrettyTime().format(new Date(post.time * 1000));
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/data/remote/HackerNewsService.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.data.remote;
2 |
3 | import com.hitherejoe.mvvm_hackernews.model.Comment;
4 | import com.hitherejoe.mvvm_hackernews.model.Post;
5 | import com.hitherejoe.mvvm_hackernews.model.User;
6 |
7 | import java.util.List;
8 |
9 | import retrofit.http.GET;
10 | import retrofit.http.Path;
11 | import rx.Observable;
12 |
13 | public interface HackerNewsService {
14 |
15 | String ENDPOINT = "https://hacker-news.firebaseio.com/v0/";
16 |
17 | /**
18 | * Return a list of the latest post IDs.
19 | */
20 | @GET("/topstories.json")
21 | Observable> getTopStories();
22 |
23 | /**
24 | * Return a list of a users post IDs.
25 | */
26 | @GET("/user/{user}.json")
27 | Observable getUser(@Path("user") String user);
28 |
29 | /**
30 | * Return story item.
31 | */
32 | @GET("/item/{itemId}.json")
33 | Observable getStoryItem(@Path("itemId") String itemId);
34 |
35 | /**
36 | * Returns a comment item.
37 | */
38 | @GET("/item/{itemId}.json")
39 | Observable getCommentItem(@Path("itemId") String itemId);
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/app_tracker.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 300
4 |
5 | true
6 |
7 |
8 | HackerNewsReader MainActivity
9 |
10 |
11 | HackerNewsReader AboutActivity
12 |
13 |
14 | HackerNewsReader BookmarksActivity
15 |
16 |
17 | HackerNewsReader CommentsActivity
18 |
19 |
20 | HackerNewsReader UserActivity
21 |
22 |
23 | HackerNewsReader WebPageActivity
24 |
25 |
26 | UA-00000000-0
27 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/HackerNewsApplication.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews;
2 |
3 | import android.app.Application;
4 | import android.content.Context;
5 |
6 | import com.hitherejoe.mvvm_hackernews.injection.component.ApplicationComponent;
7 | import com.hitherejoe.mvvm_hackernews.injection.component.DaggerApplicationComponent;
8 | import com.hitherejoe.mvvm_hackernews.injection.module.ApplicationModule;
9 |
10 | import timber.log.Timber;
11 |
12 |
13 | public class HackerNewsApplication extends Application {
14 |
15 | ApplicationComponent mApplicationComponent;
16 |
17 | @Override
18 | public void onCreate() {
19 | super.onCreate();
20 | if (BuildConfig.DEBUG) Timber.plant(new Timber.DebugTree());
21 | mApplicationComponent = DaggerApplicationComponent.builder()
22 | .applicationModule(new ApplicationModule(this))
23 | .build();
24 | }
25 |
26 | public static HackerNewsApplication get(Context context) {
27 | return (HackerNewsApplication) context.getApplicationContext();
28 | }
29 |
30 | public ApplicationComponent getComponent() {
31 | return mApplicationComponent;
32 | }
33 |
34 | // Needed to replace the component with a test specific one
35 | public void setComponent(ApplicationComponent applicationComponent) {
36 | mApplicationComponent = applicationComponent;
37 | }
38 |
39 | }
40 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/view/activity/UserActivity.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.view.activity;
2 |
3 | import android.content.Context;
4 | import android.content.Intent;
5 | import android.os.Bundle;
6 | import android.support.v4.app.Fragment;
7 |
8 | import com.hitherejoe.mvvm_hackernews.R;
9 | import com.hitherejoe.mvvm_hackernews.view.fragment.StoriesFragment;
10 |
11 | public class UserActivity extends BaseActivity {
12 |
13 | public static final String EXTRA_USER =
14 | "com.hitherejoe.mvvm_hackernews.ui.activity.UserActivity.EXTRA_USER";
15 |
16 | public static Intent getStartIntent(Context context, String user) {
17 | Intent intent = new Intent(context, UserActivity.class);
18 | intent.putExtra(EXTRA_USER, user);
19 | return intent;
20 | }
21 |
22 | @Override
23 | protected void onCreate(Bundle savedInstanceState) {
24 | super.onCreate(savedInstanceState);
25 | setContentView(R.layout.activity_user);
26 | String username = getIntent().getStringExtra(EXTRA_USER);
27 | if (username == null) throw new IllegalArgumentException("UserActivity requires a user object!");
28 | addStoriesFragment(username);
29 | }
30 |
31 | private void addStoriesFragment(String username) {
32 | Fragment storiesFragment = StoriesFragment.newInstance(username);
33 | getSupportFragmentManager()
34 | .beginTransaction()
35 | .replace(R.id.content_frame, storiesFragment)
36 | .commit();
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/view/activity/MainActivity.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.view.activity;
2 |
3 | import android.content.Context;
4 | import android.content.Intent;
5 | import android.os.Bundle;
6 | import android.view.Menu;
7 | import android.view.MenuItem;
8 |
9 | import com.hitherejoe.mvvm_hackernews.R;
10 | import com.hitherejoe.mvvm_hackernews.view.fragment.StoriesFragment;
11 |
12 | public class MainActivity extends BaseActivity {
13 |
14 | public static Intent getStartIntent(Context context) {
15 | return new Intent(context, MainActivity.class);
16 | }
17 |
18 | @Override
19 | protected void onCreate(Bundle savedInstanceState) {
20 | super.onCreate(savedInstanceState);
21 | setContentView(R.layout.activity_main);
22 | addStoriesFragment();
23 | }
24 |
25 | @Override
26 | public boolean onCreateOptionsMenu(Menu menu) {
27 | getMenuInflater().inflate(R.menu.main, menu);
28 | return true;
29 | }
30 |
31 | @Override
32 | public boolean onOptionsItemSelected(MenuItem item) {
33 | switch (item.getItemId()) {
34 | case R.id.action_view_on_github:
35 | //TODO: Add github link
36 | return true;
37 | default:
38 | return super.onOptionsItemSelected(item);
39 | }
40 | }
41 |
42 | private void addStoriesFragment() {
43 | getSupportFragmentManager()
44 | .beginTransaction()
45 | .replace(R.id.content_frame, new StoriesFragment())
46 | .commit();
47 | }
48 |
49 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/util/DialogFactory.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.util;
2 |
3 |
4 | import android.app.AlertDialog;
5 | import android.app.Dialog;
6 | import android.content.Context;
7 | import android.content.DialogInterface;
8 |
9 | import com.hitherejoe.mvvm_hackernews.R;
10 |
11 | public class DialogFactory {
12 |
13 | public static Dialog createSimpleOkErrorDialog(Context context, String message) {
14 | AlertDialog.Builder alertDialog = new AlertDialog.Builder(context)
15 | .setTitle(context.getString(R.string.dialog_error_title))
16 | .setMessage(message)
17 | .setNeutralButton(R.string.dialog_action_ok, null);
18 | return alertDialog.create();
19 | }
20 |
21 | public static Dialog createRateDialog(Context context,
22 | DialogInterface.OnClickListener onClickListener) {
23 | AlertDialog rateDialog = new AlertDialog.Builder(context).create();
24 | rateDialog.setTitle(context.getString(R.string.dialog_rate_app_title));
25 | rateDialog.setMessage(context.getString(R.string.dialog_rate_app_text));
26 | rateDialog.setButton(AlertDialog.BUTTON_POSITIVE, context.getString(R.string.dialog_positive_button_text), onClickListener);
27 | rateDialog.setButton(AlertDialog.BUTTON_NEGATIVE, context.getString(R.string.dialog_negative_button_text), onClickListener);
28 | rateDialog.setButton(AlertDialog.BUTTON_NEUTRAL, context.getString(R.string.dialog_neutral_button_text), onClickListener);
29 | return rateDialog;
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/module_androidtest_only/src/main/java/com/hitherejoe/module_androidtest_only/util/TestDataManager.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.module_androidtest_only.util;
2 |
3 | import android.content.Context;
4 |
5 | import com.hitherejoe.module_androidtest_only.injection.component.DaggerDataManagerTestComponent;
6 | import com.hitherejoe.module_androidtest_only.injection.component.TestComponent;
7 | import com.hitherejoe.module_androidtest_only.injection.module.DataManagerTestModule;
8 | import com.hitherejoe.mvvm_hackernews.HackerNewsApplication;
9 | import com.hitherejoe.mvvm_hackernews.data.DataManager;
10 | import com.hitherejoe.mvvm_hackernews.data.remote.HackerNewsService;
11 |
12 | /**
13 | * Extension of DataManager to be used on a testing environment.
14 | * It uses DataManagerTestComponent to inject dependencies that are different to the
15 | * normal runtime ones. e.g. mock objects etc.
16 | * It also exposes some helpers like the DatabaseHelper or the Retrofit service that are helpful
17 | * during testing.
18 | */
19 | public class TestDataManager extends DataManager {
20 |
21 | public TestDataManager(Context context) {
22 | super(context);
23 | }
24 |
25 | @Override
26 | protected void injectDependencies(Context context) {
27 | TestComponent testComponent = (TestComponent)
28 | HackerNewsApplication.get(context).getComponent();
29 | DaggerDataManagerTestComponent.builder()
30 | .testComponent(testComponent)
31 | .dataManagerTestModule(new DataManagerTestModule())
32 | .build()
33 | .inject(this);
34 | }
35 |
36 | public HackerNewsService getWatchTowerService() {
37 | return mHackerNewsService;
38 | }
39 |
40 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
15 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
30 |
34 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/module_androidtest_only/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.test'
2 | apply plugin: 'com.neenbedankt.android-apt'
3 |
4 | android {
5 | compileSdkVersion 22
6 | buildToolsVersion "23.0.0"
7 |
8 | defaultConfig {
9 | testApplicationId "com.hitherejoe.mvvm_hackernews.test"
10 | testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner'
11 | minSdkVersion 16
12 | targetSdkVersion 22
13 | }
14 |
15 | lintOptions {
16 | abortOnError false
17 | }
18 |
19 | targetProjectPath ':app'
20 | }
21 |
22 | dependencies {
23 | final SUPPORT_LIBRARY_VERSION = '22.2.1'
24 | final DAGGER_VERSION = '2.0.1'
25 | final DEXMAKER_VERSION = '1.2'
26 | final MOCKITO_VERSION = '1.10.19'
27 | final ESPRESSO_VERSION = '2.2'
28 |
29 | compile fileTree(dir: 'libs', include: ['*.jar'])
30 | compile "com.android.support:appcompat-v7:$SUPPORT_LIBRARY_VERSION"
31 |
32 | compile "com.google.dagger:dagger:$DAGGER_VERSION"
33 | apt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
34 | provided 'org.glassfish:javax.annotation:10.0-b28'
35 |
36 | compile 'junit:junit:4.12'
37 | compile "com.android.support.test.espresso:espresso-core:$ESPRESSO_VERSION"
38 | compile "com.android.support:support-annotations:$SUPPORT_LIBRARY_VERSION"
39 | compile "org.mockito:mockito-core:$MOCKITO_VERSION"
40 | compile 'com.android.support.test:runner:0.3'
41 | compile 'com.android.support.test:rules:0.3'
42 | compile "com.google.dexmaker:dexmaker:$DEXMAKER_VERSION"
43 | compile "com.google.dexmaker:dexmaker-mockito:$DEXMAKER_VERSION"
44 | compile("com.android.support.test.espresso:espresso-contrib:$ESPRESSO_VERSION") {
45 | exclude group: 'com.android.support', module: 'appcompat'
46 | exclude group: 'com.android.support', module: 'support-v4'
47 | exclude module: 'recyclerview-v7'
48 | }
49 |
50 | apt 'com.google.dagger:dagger-compiler:2.0.1'
51 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/hitherejoe/mvvm_hackernews/CommentHeaderViewModelTest.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews;
2 |
3 | import android.content.Context;
4 |
5 | import com.hitherejoe.mvvm_hackernews.model.Post;
6 | import com.hitherejoe.mvvm_hackernews.util.DefaultConfig;
7 | import com.hitherejoe.mvvm_hackernews.util.MockModelsUtil;
8 | import com.hitherejoe.mvvm_hackernews.viewModel.CommentHeaderViewModel;
9 |
10 | import org.junit.Before;
11 | import org.junit.Test;
12 | import org.junit.runner.RunWith;
13 | import org.ocpsoft.prettytime.PrettyTime;
14 | import org.robolectric.RobolectricTestRunner;
15 | import org.robolectric.RuntimeEnvironment;
16 | import org.robolectric.annotation.Config;
17 |
18 | import java.util.Date;
19 |
20 | import static junit.framework.Assert.assertEquals;
21 |
22 | @RunWith(RobolectricTestRunner.class)
23 | @Config(constants = BuildConfig.class, sdk = DefaultConfig.EMULATE_SDK, manifest = DefaultConfig.MANIFEST)
24 | public class CommentHeaderViewModelTest {
25 |
26 | private CommentHeaderViewModel commentHeaderViewModel;
27 | private Post mPost;
28 |
29 | @Before
30 | public void setUp() {
31 | mPost = MockModelsUtil.createMockStoryWithText();
32 | commentHeaderViewModel = new CommentHeaderViewModel(RuntimeEnvironment.application, mPost);
33 | }
34 |
35 | @Test
36 | public void shouldGetCommentText() throws Exception {
37 | assertEquals(commentHeaderViewModel.getCommentText(), mPost.text);
38 | }
39 |
40 | @Test
41 | public void shouldGetCommentAuthor() throws Exception {
42 | Context context =RuntimeEnvironment.application;
43 | String author =
44 | context.getResources().getString(R.string.text_comment_author, mPost.by);
45 | assertEquals(commentHeaderViewModel.getCommentAuthor(), author);
46 | }
47 |
48 | @Test
49 | public void shouldGetCommentDate() throws Exception {
50 | String date = new PrettyTime().format(new Date(mPost.time * 1000));
51 | assertEquals(commentHeaderViewModel.getCommentDate(), date);
52 | }
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
18 |
19 |
25 |
26 |
30 |
31 |
35 |
36 |
39 |
40 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/model/Comment.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.model;
2 |
3 | import java.util.ArrayList;
4 |
5 | public class Comment {
6 |
7 | public String text;
8 | public Long time;
9 | public String by;
10 | public Long id;
11 | public String type;
12 | public ArrayList kids;
13 | public ArrayList comments;
14 | public int depth;
15 | public boolean isTopLevelComment;
16 |
17 | public Comment() {
18 | comments = new ArrayList<>();
19 | isTopLevelComment = false;
20 | depth = 0;
21 | }
22 |
23 | @Override
24 | public boolean equals(Object o) {
25 | if (this == o) return true;
26 | if (o == null || getClass() != o.getClass()) return false;
27 |
28 | Comment comment = (Comment) o;
29 |
30 | if (depth != comment.depth) return false;
31 | if (isTopLevelComment != comment.isTopLevelComment) return false;
32 | if (by != null ? !by.equals(comment.by) : comment.by != null) return false;
33 | if (comments != null ? !comments.equals(comment.comments) : comment.comments != null)
34 | return false;
35 | if (id != null ? !id.equals(comment.id) : comment.id != null) return false;
36 | if (kids != null ? !kids.equals(comment.kids) : comment.kids != null) return false;
37 | if (text != null ? !text.equals(comment.text) : comment.text != null) return false;
38 | if (time != null ? !time.equals(comment.time) : comment.time != null) return false;
39 | if (type != null ? !type.equals(comment.type) : comment.type != null) return false;
40 |
41 | return true;
42 | }
43 |
44 | @Override
45 | public int hashCode() {
46 | int result = text != null ? text.hashCode() : 0;
47 | result = 31 * result + (time != null ? time.hashCode() : 0);
48 | result = 31 * result + (by != null ? by.hashCode() : 0);
49 | result = 31 * result + (id != null ? id.hashCode() : 0);
50 | result = 31 * result + (type != null ? type.hashCode() : 0);
51 | result = 31 * result + (kids != null ? kids.hashCode() : 0);
52 | result = 31 * result + (comments != null ? comments.hashCode() : 0);
53 | result = 31 * result + depth;
54 | result = 31 * result + (isTopLevelComment ? 1 : 0);
55 | return result;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_comments_header.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
19 |
20 |
24 |
25 |
33 |
34 |
42 |
43 |
44 |
45 |
53 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/app/src/test/java/com/hitherejoe/mvvm_hackernews/CommentViewModelTest.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews;
2 |
3 | import android.content.Context;
4 |
5 | import com.hitherejoe.mvvm_hackernews.model.Comment;
6 | import com.hitherejoe.mvvm_hackernews.util.DefaultConfig;
7 | import com.hitherejoe.mvvm_hackernews.util.MockModelsUtil;
8 | import com.hitherejoe.mvvm_hackernews.viewModel.CommentViewModel;
9 |
10 | import org.junit.Before;
11 | import org.junit.Test;
12 | import org.junit.runner.RunWith;
13 | import org.ocpsoft.prettytime.PrettyTime;
14 | import org.robolectric.Robolectric;
15 | import org.robolectric.RobolectricTestRunner;
16 | import org.robolectric.RuntimeEnvironment;
17 | import org.robolectric.annotation.Config;
18 |
19 | import java.util.Date;
20 |
21 | import static junit.framework.Assert.assertEquals;
22 |
23 | @RunWith(RobolectricTestRunner.class)
24 | @Config(constants = BuildConfig.class, sdk = DefaultConfig.EMULATE_SDK, manifest = DefaultConfig.MANIFEST)
25 | public class CommentViewModelTest {
26 |
27 | private CommentViewModel mCommentViewModel;
28 | private Comment mComment;
29 |
30 | @Before
31 | public void setUp() {
32 | mComment = MockModelsUtil.createMockComment();
33 | mCommentViewModel = new CommentViewModel(RuntimeEnvironment.application, mComment);
34 | }
35 |
36 | @Test
37 | public void shouldGetCommentText() throws Exception {
38 | assertEquals(mCommentViewModel.getCommentText(), mComment.text);
39 | }
40 |
41 | @Test
42 | public void shouldGetCommentAuthor() throws Exception {
43 | Context context =RuntimeEnvironment.application;
44 | String author =
45 | context.getResources().getString(R.string.text_comment_author, mComment.by);
46 | assertEquals(mCommentViewModel.getCommentAuthor(), author);
47 | }
48 |
49 | @Test
50 | public void shouldGetCommentDate() throws Exception {
51 | String date = new PrettyTime().format(new Date(mComment.time * 1000));
52 | assertEquals(mCommentViewModel.getCommentDate(), date);
53 | }
54 |
55 | @Test
56 | public void shouldGetCommentDepth() throws Exception {
57 | assertEquals(mCommentViewModel.getCommentDepth(), mComment.depth);
58 | }
59 |
60 | @Test
61 | public void shouldGetTopLevelComment() throws Exception {
62 | assertEquals(mCommentViewModel.getCommentIsTopLevel(), mComment.isTopLevelComment);
63 | }
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/view/adapter/PostAdapter.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.view.adapter;
2 |
3 | import android.content.Context;
4 | import android.databinding.DataBindingUtil;
5 | import android.databinding.ViewDataBinding;
6 | import android.support.v7.widget.RecyclerView;
7 | import android.view.LayoutInflater;
8 | import android.view.View;
9 | import android.view.ViewGroup;
10 |
11 | import com.hitherejoe.mvvm_hackernews.R;
12 | import com.hitherejoe.mvvm_hackernews.databinding.ItemPostBinding;
13 | import com.hitherejoe.mvvm_hackernews.model.Post;
14 | import com.hitherejoe.mvvm_hackernews.viewModel.PostViewModel;
15 |
16 | import java.util.ArrayList;
17 | import java.util.List;
18 |
19 | public class PostAdapter extends RecyclerView.Adapter {
20 | private List mPosts;
21 | private Context mContext;
22 | private boolean mIsUserPosts;
23 |
24 | public PostAdapter(Context context, boolean isUserPosts) {
25 | mContext = context;
26 | mIsUserPosts = isUserPosts;
27 | mPosts = new ArrayList<>();
28 | }
29 |
30 | @Override
31 | public BindingHolder onCreateViewHolder(ViewGroup parent, int viewType) {
32 | return new BindingHolder(
33 | LayoutInflater.from(parent.getContext()).inflate(R.layout.item_post, parent, false));
34 | }
35 |
36 | @Override
37 | public void onBindViewHolder(BindingHolder holder, int position) {
38 | ItemPostBinding postBinding = DataBindingUtil.bind(holder.itemView);
39 | postBinding.setViewModel(new PostViewModel(mContext, mPosts.get(position), mIsUserPosts));
40 | holder.getBinding().executePendingBindings();
41 | }
42 |
43 | @Override
44 | public int getItemCount() {
45 | return mPosts.size();
46 | }
47 |
48 | public void setItems(List posts) {
49 | mPosts = posts;
50 | notifyDataSetChanged();
51 | }
52 |
53 | public void addItem(Post post) {
54 | mPosts.add(post);
55 | notifyDataSetChanged();
56 | }
57 |
58 | public static class BindingHolder extends RecyclerView.ViewHolder {
59 | private ViewDataBinding binding;
60 |
61 | public BindingHolder(View rowView) {
62 | super(rowView);
63 | binding = DataBindingUtil.bind(rowView);
64 | }
65 | public ViewDataBinding getBinding() {
66 | return binding;
67 | }
68 | }
69 |
70 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_stories.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
13 |
14 |
18 |
19 |
28 |
29 |
30 |
31 |
37 |
38 |
39 |
40 |
41 |
42 |
46 |
47 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/app/src/test/java/com/hitherejoe/mvvm_hackernews/PostViewModelTest.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews;
2 |
3 | import android.content.Context;
4 | import android.view.View;
5 |
6 | import com.hitherejoe.mvvm_hackernews.model.Post;
7 | import com.hitherejoe.mvvm_hackernews.util.DefaultConfig;
8 | import com.hitherejoe.mvvm_hackernews.util.MockModelsUtil;
9 | import com.hitherejoe.mvvm_hackernews.viewModel.PostViewModel;
10 |
11 | import org.junit.Before;
12 | import org.junit.Test;
13 | import org.junit.runner.RunWith;
14 | import org.robolectric.RobolectricTestRunner;
15 | import org.robolectric.RuntimeEnvironment;
16 | import org.robolectric.annotation.Config;
17 |
18 | import java.util.ArrayList;
19 |
20 | import static junit.framework.Assert.assertEquals;
21 |
22 | @RunWith(RobolectricTestRunner.class)
23 | @Config(constants = BuildConfig.class, sdk = DefaultConfig.EMULATE_SDK, manifest = DefaultConfig.MANIFEST)
24 | public class PostViewModelTest {
25 |
26 | private Context mContext;
27 | private PostViewModel mPostViewModel;
28 | private Post mPost;
29 |
30 | @Before
31 | public void setUp() {
32 | mContext = RuntimeEnvironment.application;
33 | mPost = MockModelsUtil.createMockStory();
34 | mPostViewModel = new PostViewModel(mContext, mPost, false);
35 | }
36 |
37 | @Test
38 | public void shouldGetPostScore() throws Exception {
39 | String postScore = mPost.score + mContext.getResources().getString(R.string.story_points);
40 | assertEquals(mPostViewModel.getPostScore(), postScore);
41 | }
42 |
43 | @Test
44 | public void shouldGetPostTitle() throws Exception {
45 | assertEquals(mPostViewModel.getPostTitle(), mPost.title);
46 | }
47 |
48 | @Test
49 | public void shouldGetPostAuthor() throws Exception {
50 | String author = mContext.getString(R.string.text_post_author, mPost.by);
51 | assertEquals(mPostViewModel.getPostAuthor().toString(), author);
52 | }
53 |
54 | @Test
55 | public void shouldGetCommentsVisibility() throws Exception {
56 | // Our mock post is of the type story, so this should return gone
57 | mPost.kids = null;
58 | assertEquals(mPostViewModel.getCommentsVisibility(), View.GONE);
59 | mPost.kids = new ArrayList<>();
60 | assertEquals(mPostViewModel.getCommentsVisibility(), View.VISIBLE);
61 | mPost.kids = null;
62 | mPost.postType = Post.PostType.ASK;
63 | assertEquals(mPostViewModel.getCommentsVisibility(), View.VISIBLE);
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/module_androidtest_only/src/main/java/com/hitherejoe/module_androidtest_only/injection/TestComponentRule.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.module_androidtest_only.injection;
2 |
3 | import android.support.test.InstrumentationRegistry;
4 |
5 | import com.hitherejoe.module_androidtest_only.injection.component.DaggerTestComponent;
6 | import com.hitherejoe.module_androidtest_only.injection.component.TestComponent;
7 | import com.hitherejoe.module_androidtest_only.injection.module.ApplicationTestModule;
8 | import com.hitherejoe.module_androidtest_only.util.TestDataManager;
9 | import com.hitherejoe.mvvm_hackernews.HackerNewsApplication;
10 | import com.hitherejoe.mvvm_hackernews.data.remote.HackerNewsService;
11 |
12 | import org.junit.rules.TestRule;
13 | import org.junit.runner.Description;
14 | import org.junit.runners.model.Statement;
15 |
16 | /**
17 | * Test rule that creates and sets a Dagger TestComponent into the application overriding the
18 | * existing application component.
19 | * Use this rule in your test case in order for the app to use mock dependencies.
20 | * It also exposes some of the dependencies so they can be easily accessed from the tests, e.g. to
21 | * stub mocks etc.
22 | */
23 | public class TestComponentRule implements TestRule {
24 |
25 | private TestComponent mTestComponent;
26 |
27 | public TestDataManager getDataManager() {
28 | return (TestDataManager) mTestComponent.dataManager();
29 | }
30 |
31 | public HackerNewsService getMockHackerNewsService() {
32 | return getDataManager().getWatchTowerService();
33 | }
34 |
35 | private void setupDaggerTestComponentInApplication() {
36 | HackerNewsApplication application = HackerNewsApplication
37 | .get(InstrumentationRegistry.getTargetContext());
38 | if (application.getComponent() instanceof TestComponent) {
39 | mTestComponent = (TestComponent) application.getComponent();
40 | } else {
41 | mTestComponent = DaggerTestComponent.builder()
42 | .applicationTestModule(new ApplicationTestModule(application))
43 | .build();
44 | application.setComponent(mTestComponent);
45 | }
46 | }
47 |
48 | @Override
49 | public Statement apply(final Statement base, Description description) {
50 | return new Statement() {
51 | @Override
52 | public void evaluate() throws Throwable {
53 | try {
54 | setupDaggerTestComponentInApplication();
55 | base.evaluate();
56 | } finally {
57 | mTestComponent = null;
58 | }
59 | }
60 | };
61 | }
62 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Hacker News
5 | Loading…
6 |
7 |
8 | Bookmarks
9 |
10 |
11 | View on GitHub
12 | View in Browser
13 | Share
14 | Bookmark
15 | View Bookmarks
16 |
17 |
18 | %s -
19 | No comments yet!
20 |
21 |
22 | VIEW
23 | - %s
24 | points
25 | -
26 | via
27 |
28 |
29 | COMMENTS
30 | REMOVE
31 |
32 |
33 | No bookmarks yet!
34 | Bookmark added
35 | Bookmark removed
36 | Bookmark already exists
37 | There was a problem adding the bookmark
38 |
39 |
40 | Sorry, unable to load stories!
41 | Sorry, unable to load comments!
42 | Sorry, unable to load post!
43 | Sorry, an error occurred!
44 | No internet connection detected
45 | Error retrieving bookmarks
46 | Error removing bookmark
47 | Try again
48 |
49 |
50 | OK
51 | Oops
52 | Rate
53 | Enjoying this app? Why not rate it!
54 | Later
55 | No, thanks
56 | Rate
57 |
58 |
59 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/viewModel/CommentViewModel.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.viewModel;
2 |
3 | import android.content.Context;
4 | import android.databinding.BaseObservable;
5 | import android.databinding.Bindable;
6 | import android.databinding.BindingAdapter;
7 | import android.text.Html;
8 | import android.view.View;
9 | import android.view.ViewGroup;
10 | import android.widget.RelativeLayout;
11 |
12 | import com.hitherejoe.mvvm_hackernews.R;
13 | import com.hitherejoe.mvvm_hackernews.model.Comment;
14 | import com.hitherejoe.mvvm_hackernews.util.ViewUtils;
15 |
16 | import org.ocpsoft.prettytime.PrettyTime;
17 |
18 | import java.util.Date;
19 |
20 | public class CommentViewModel extends BaseObservable {
21 |
22 | private Context context;
23 | private Comment comment;
24 |
25 | public CommentViewModel(Context context, Comment comment) {
26 | this.context = context;
27 | this.comment = comment;
28 | }
29 |
30 | @BindingAdapter("containerMargin")
31 | public static void setContainerMargin(View view, boolean isTopLevelComment) {
32 | if (view.getTag() == null) {
33 | view.setTag(true);
34 | ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams)
35 | view.getLayoutParams();
36 | float horizontalMargin = view.getContext().getResources().getDimension(R.dimen.activity_horizontal_margin);
37 | float topMargin = isTopLevelComment
38 | ? view.getContext().getResources().getDimension(R.dimen.activity_vertical_margin) : 0;
39 | layoutParams.setMargins((int) horizontalMargin, (int) topMargin, (int) horizontalMargin, 0);
40 | view.setLayoutParams(layoutParams);
41 | }
42 | }
43 |
44 | @BindingAdapter("commentDepth")
45 | public static void setCommentIndent(View view, int depth) {
46 | if (view.getTag() == null) {
47 | view.setTag(true);
48 | RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams)
49 | view.getLayoutParams();
50 | float margin = ViewUtils.convertPixelsToDp(depth * 20, view.getContext());
51 | layoutParams.setMargins((int) margin, 0, 0, 0);
52 | view.setLayoutParams(layoutParams);
53 | }
54 | }
55 |
56 | public String getCommentText() {
57 | return Html.fromHtml(comment.text.trim()).toString();
58 | }
59 |
60 | public String getCommentAuthor() {
61 | return context.getResources().getString(R.string.text_comment_author, comment.by);
62 | }
63 |
64 | public String getCommentDate() {
65 | return new PrettyTime().format(new Date(comment.time * 1000));
66 | }
67 |
68 | public int getCommentDepth() {
69 | return comment.depth;
70 | }
71 |
72 | public boolean getCommentIsTopLevel() {
73 | return comment.isTopLevelComment;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_view_story.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
13 |
14 |
18 |
19 |
23 |
24 |
32 |
33 |
39 |
40 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
59 |
60 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 | apply plugin: 'com.android.databinding'
3 | apply plugin: 'com.neenbedankt.android-apt'
4 |
5 | android {
6 | compileSdkVersion 22
7 | buildToolsVersion '23.0.0'
8 | publishNonDefault true
9 |
10 | defaultConfig {
11 | applicationId "com.hitherejoe.mvvm_hackernews"
12 | minSdkVersion 16
13 | targetSdkVersion 22
14 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
15 | versionCode 1
16 | versionName "1.0"
17 | }
18 |
19 | buildTypes {
20 | release {
21 | minifyEnabled false
22 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
23 | }
24 | }
25 |
26 | packagingOptions {
27 | exclude 'META-INF/DEPENDENCIES'
28 | exclude 'LICENSE.txt'
29 | exclude 'META-INF/LICENSE'
30 | exclude 'META-INF/LICENSE.txt'
31 | exclude 'META-INF/NOTICE'
32 | }
33 |
34 | lintOptions {
35 | abortOnError false
36 | }
37 | }
38 |
39 | dependencies {
40 | final SUPPORT_LIBRARY_VERSION = '22.2.1'
41 | final DAGGER_VERSION = '2.0.1'
42 | final HAMCREST_VERSION = '1.3'
43 | final MOCKITO_VERSION = '1.10.19'
44 |
45 | compile fileTree(dir: 'libs', include: ['*.jar'])
46 |
47 | compile "com.android.support:support-v4:$SUPPORT_LIBRARY_VERSION"
48 | compile "com.android.support:appcompat-v7:$SUPPORT_LIBRARY_VERSION"
49 | compile "com.android.support:cardview-v7:$SUPPORT_LIBRARY_VERSION"
50 | compile "com.android.support:design:$SUPPORT_LIBRARY_VERSION"
51 | compile "com.android.support:recyclerview-v7:$SUPPORT_LIBRARY_VERSION"
52 | compile "com.android.support:support-annotations:$SUPPORT_LIBRARY_VERSION"
53 | compile 'com.squareup.sqlbrite:sqlbrite:0.2.1'
54 | compile 'com.squareup.retrofit:retrofit:1.9.0'
55 | compile 'com.squareup.okhttp:okhttp-urlconnection:2.4.0'
56 | compile 'com.squareup.okhttp:okhttp:2.4.0'
57 | compile 'io.reactivex:rxandroid:0.25.0'
58 | compile 'com.jakewharton:butterknife:7.0.1'
59 | compile 'com.jakewharton.timber:timber:3.1.0'
60 | compile 'org.ocpsoft.prettytime:prettytime:3.2.7.Final'
61 |
62 | compile "com.google.dagger:dagger:$DAGGER_VERSION"
63 | apt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
64 | provided 'org.glassfish:javax.annotation:10.0-b28'
65 |
66 | testCompile 'junit:junit:4.12'
67 | testCompile "org.hamcrest:hamcrest-core:$HAMCREST_VERSION"
68 | testCompile "org.hamcrest:hamcrest-library:$HAMCREST_VERSION"
69 | testCompile "org.hamcrest:hamcrest-integration:$HAMCREST_VERSION"
70 | testCompile "org.mockito:mockito-core:$MOCKITO_VERSION"
71 | testCompile('org.robolectric:robolectric:3.0') {
72 | exclude group: 'commons-logging', module: 'commons-logging'
73 | exclude group: 'org.apache.httpcomponents', module: 'httpclient'
74 | }
75 |
76 | androidTestApt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
77 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/viewModel/PostViewModel.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.viewModel;
2 |
3 | import android.content.Context;
4 | import android.databinding.BaseObservable;
5 | import android.text.Spannable;
6 | import android.text.SpannableString;
7 | import android.text.style.UnderlineSpan;
8 | import android.view.View;
9 |
10 | import com.hitherejoe.mvvm_hackernews.R;
11 | import com.hitherejoe.mvvm_hackernews.model.Post;
12 | import com.hitherejoe.mvvm_hackernews.view.activity.CommentsActivity;
13 | import com.hitherejoe.mvvm_hackernews.view.activity.UserActivity;
14 | import com.hitherejoe.mvvm_hackernews.view.activity.ViewStoryActivity;
15 |
16 | public class PostViewModel extends BaseObservable {
17 |
18 | private Context context;
19 | private Post post;
20 | private Boolean isUserPosts;
21 |
22 | public PostViewModel(Context context, Post post, boolean isUserPosts) {
23 | this.context = context;
24 | this.post = post;
25 | this.isUserPosts = isUserPosts;
26 | }
27 |
28 | public String getPostScore() {
29 | return String.valueOf(post.score) + context.getString(R.string.story_points);
30 | }
31 |
32 | public String getPostTitle() {
33 | return post.title;
34 | }
35 |
36 | public Spannable getPostAuthor() {
37 | String author = context.getString(R.string.text_post_author, post.by);
38 | SpannableString content = new SpannableString(author);
39 | int index = author.indexOf(post.by);
40 | if (!isUserPosts) content.setSpan(new UnderlineSpan(), index, post.by.length() + index, 0);
41 | return content;
42 | }
43 |
44 | public int getCommentsVisibility() {
45 | return post.postType == Post.PostType.STORY && post.kids == null ? View.GONE : View.VISIBLE;
46 | }
47 |
48 | public View.OnClickListener onClickPost() {
49 | return new View.OnClickListener() {
50 | @Override
51 | public void onClick(View v) {
52 | Post.PostType postType = post.postType;
53 | if (postType == Post.PostType.JOB || postType == Post.PostType.STORY) {
54 | launchStoryActivity();
55 | } else if (postType == Post.PostType.ASK) {
56 | launchCommentsActivity();
57 | }
58 | }
59 | };
60 | }
61 |
62 | public View.OnClickListener onClickAuthor() {
63 | return new View.OnClickListener() {
64 | @Override
65 | public void onClick(View v) {
66 | context.startActivity(UserActivity.getStartIntent(context, post.by));
67 | }
68 | };
69 | }
70 |
71 | public View.OnClickListener onClickComments() {
72 | return new View.OnClickListener() {
73 | @Override
74 | public void onClick(View v) {
75 | launchCommentsActivity();
76 | }
77 | };
78 | }
79 |
80 | private void launchStoryActivity() {
81 | context.startActivity(ViewStoryActivity.getStartIntent(context, post));
82 | }
83 |
84 | private void launchCommentsActivity() {
85 | context.startActivity(CommentsActivity.getStartIntent(context, post));
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_comment.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
18 |
19 |
26 |
27 |
32 |
33 |
37 |
38 |
46 |
47 |
55 |
56 |
57 |
58 |
66 |
67 |
68 |
69 |
70 |
71 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/util/MockModelsUtil.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.util;
2 |
3 | import com.hitherejoe.mvvm_hackernews.model.Comment;
4 | import com.hitherejoe.mvvm_hackernews.model.Post;
5 | import com.hitherejoe.mvvm_hackernews.model.User;
6 |
7 | import java.util.ArrayList;
8 | import java.util.Date;
9 | import java.util.List;
10 | import java.util.Random;
11 | import java.util.UUID;
12 |
13 | public class MockModelsUtil {
14 |
15 | public static Long generateRandomLong() {
16 | return new Random().nextLong();
17 | }
18 |
19 | public static String generateRandomString() {
20 | return UUID.randomUUID().toString();
21 | }
22 |
23 | public static int generateRandomInt() {
24 | return new Random().nextInt(80 - 65) + 65;
25 | }
26 |
27 | public static Post createMockStory() {
28 | Post story = new Post();
29 | story.id = generateRandomLong();
30 | story.postType = Post.PostType.STORY;
31 | story.url = "http://www.hitherejoe.com";
32 | story.title = generateRandomString();
33 | story.score = 1000L;
34 | story.by = "JoeBirch";
35 | story.time = new Date().getTime();
36 | return story;
37 | }
38 |
39 | public static Post createMockStoryWithText() {
40 | Post story = createMockStory();
41 | story.text = generateRandomString();
42 | return story;
43 | }
44 |
45 | public static Post createMockStoryWithTitle(String title) {
46 | Post story = createMockStory();
47 | story.title = title;
48 | story.postType = Post.PostType.STORY;
49 | return story;
50 | }
51 |
52 | public static Post createMockJobWithTitle(String title) {
53 | Post story = createMockStory();
54 | story.title = title;
55 | story.postType = Post.PostType.JOB;
56 | return story;
57 | }
58 |
59 | public static Post createMockStoryWithId(long id) {
60 | Post story = createMockStory();
61 | story.id = id;
62 | return story;
63 | }
64 |
65 | public static Post createMockAskStoryWithTitle(String title) {
66 | Post story = createMockStory();
67 | story.title = title;
68 | story.postType = Post.PostType.ASK;
69 | story.url = "";
70 | return story;
71 | }
72 |
73 | public static Comment createMockComment() {
74 | Comment comment = new Comment();
75 | comment.by = generateRandomString();
76 | comment.comments = new ArrayList<>();
77 | comment.depth = generateRandomInt();
78 | comment.id = generateRandomLong();
79 | comment.isTopLevelComment = false;
80 | comment.text = generateRandomString();
81 | comment.time = new Date().getTime();
82 | return comment;
83 | }
84 |
85 | public static User createMockUser() {
86 | User user = new User();
87 | user.id = generateRandomString();
88 | user.about = "about";
89 | user.karma = 100;
90 | user.submitted = new ArrayList<>();
91 | user.submitted.add(102234L);
92 | user.submitted.add(123454L);
93 | user.submitted.add(773454L);
94 | user.submitted.add(666454L);
95 | return user;
96 | }
97 |
98 | public static List createMockPostIdList(int count) {
99 | List idList = new ArrayList<>();
100 | for (int i = 0; i < count; i++) {
101 | idList.add(generateRandomLong());
102 | }
103 | return idList;
104 | }
105 |
106 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_comments.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
12 |
13 |
20 |
21 |
27 |
28 |
36 |
37 |
43 |
44 |
50 |
51 |
52 |
53 |
54 |
55 |
64 |
65 |
66 |
67 |
71 |
72 |
79 |
80 |
81 |
82 |
83 |
84 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/view/adapter/CommentAdapter.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.view.adapter;
2 |
3 | import android.content.Context;
4 | import android.databinding.DataBindingUtil;
5 | import android.databinding.ViewDataBinding;
6 | import android.support.v7.widget.RecyclerView;
7 | import android.view.LayoutInflater;
8 | import android.view.View;
9 | import android.view.ViewGroup;
10 |
11 | import com.hitherejoe.mvvm_hackernews.R;
12 | import com.hitherejoe.mvvm_hackernews.databinding.ItemCommentsHeaderBinding;
13 | import com.hitherejoe.mvvm_hackernews.model.Comment;
14 | import com.hitherejoe.mvvm_hackernews.viewModel.CommentHeaderViewModel;
15 | import com.hitherejoe.mvvm_hackernews.viewModel.CommentViewModel;
16 | import com.hitherejoe.mvvm_hackernews.model.Post;
17 | import com.hitherejoe.mvvm_hackernews.databinding.ItemCommentBinding;
18 |
19 | import java.util.List;
20 |
21 | public class CommentAdapter extends RecyclerView.Adapter {
22 |
23 | private static final int VIEW_TYPE_COMMENT = 0;
24 | private static final int VIEW_TYPE_HEADER = 1;
25 |
26 | private Context mContext;
27 | private Post mPost;
28 | private List mComments;
29 |
30 | public CommentAdapter(Context context, Post post, List comments) {
31 | mContext = context;
32 | mPost = post;
33 | mComments = comments;
34 | }
35 |
36 | @Override
37 | public BindingHolder onCreateViewHolder(ViewGroup parent, int viewType) {
38 | if (viewType == VIEW_TYPE_COMMENT) {
39 | return new BindingHolder(
40 | LayoutInflater.from(parent.getContext()).inflate(R.layout.item_comment, parent, false));
41 | }
42 | return new BindingHolder(
43 | LayoutInflater.from(parent.getContext()).inflate(R.layout.item_comments_header, parent, false));
44 | }
45 |
46 | @Override
47 | public void onBindViewHolder(BindingHolder holder, int position) {
48 | if (getItemViewType(position) == VIEW_TYPE_HEADER) {
49 | ItemCommentsHeaderBinding commentsHeaderBinding = DataBindingUtil.bind(holder.itemView);
50 | commentsHeaderBinding.setViewModel(new CommentHeaderViewModel(mContext, mPost));
51 | } else {
52 | int actualPosition = (postHasText()) ? position - 1 : position;
53 | ItemCommentBinding commentsBinding = DataBindingUtil.bind(holder.itemView);
54 | mComments.get(actualPosition).isTopLevelComment = actualPosition == 0;
55 | commentsBinding.setViewModel(new CommentViewModel(mContext, mComments.get(actualPosition)));
56 | }
57 | holder.getBinding().executePendingBindings();
58 | }
59 |
60 |
61 | @Override
62 | public int getItemCount() {
63 | return postHasText() ? mComments.size() + 1 : mComments.size();
64 | }
65 |
66 | @Override
67 | public int getItemViewType(int position) {
68 | // If the post has text, then it's an ASK post - so we show the text as a header comment
69 | if (position == 0 && postHasText()) {
70 | return VIEW_TYPE_HEADER;
71 | } else {
72 | return VIEW_TYPE_COMMENT;
73 | }
74 | }
75 |
76 | private boolean postHasText() {
77 | return mPost.text != null && !mPost.text.equals("");
78 | }
79 |
80 | public static class BindingHolder extends RecyclerView.ViewHolder {
81 | private ViewDataBinding binding;
82 |
83 | public BindingHolder(View rowView) {
84 | super(rowView);
85 | binding = DataBindingUtil.bind(rowView);
86 | }
87 | public ViewDataBinding getBinding() {
88 | return binding;
89 | }
90 | }
91 |
92 | }
93 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | MVVM using Data Binding [](https://travis-ci.org/hitherejoe/MVVM_Hacker_News)
2 | =======================
3 |
4 | I wanted to experiment creating an MVVM structured project using the official Data Binding library,
5 | so I stripped back an [old project](https://github.com/hitherejoe/HackerNewsReader) of mine and replaced relevant codebase with an MVVM approach. This is still experimental,
6 | so I'd love to hear any suggestion / improvements to the approach!
7 |
8 |
9 |
10 |
11 |
12 | Currently writing a Medium article which should explain more... but for now:
13 |
14 | Posts
15 | -----
16 |
17 | Post cards displayed on the Post Screen (left, above) are built using the following classes:
18 |
19 | [Post](https://github.com/hitherejoe/MVVM_Hacker_News/blob/master/app/src/main/java/com/hitherejoe/mvvm_hackernews/model/Post.java) - The post object
20 |
21 | [PostAdapter](https://github.com/hitherejoe/MVVM_Hacker_News/blob/master/app/src/main/java/com/hitherejoe/mvvm_hackernews/view/adapter/PostAdapter.java) - The RecyclerView adapter used to set the view model
22 |
23 | [PostViewModel](https://github.com/hitherejoe/MVVM_Hacker_News/blob/master/app/src/main/java/com/hitherejoe/mvvm_hackernews/viewModel/PostViewModel.java) - The view model used to manage the display of the posts
24 |
25 | [item_post](https://github.com/hitherejoe/MVVM_Hacker_News/blob/master/app/src/main/res/layout/item_post.xml) - The XML layout file which displays the post card
26 |
27 | Comments
28 | --------
29 |
30 | Comments displayed on the Comment Screen (right, above) are built using the following classes:
31 |
32 | [Comment](https://github.com/hitherejoe/MVVM_Hacker_News/blob/master/app/src/main/java/com/hitherejoe/mvvm_hackernews/model/Comment.java) - The comment object
33 |
34 | [CommentAdapter](https://github.com/hitherejoe/MVVM_Hacker_News/blob/master/app/src/main/java/com/hitherejoe/mvvm_hackernews/view/adapter/CommentAdapter.java) - The RecyclerView adapter used to set the corresponding view model. Comments work a bit differently
35 | from posts as this adapter uses logic to choose to use one of two view models based on the comment type
36 |
37 | [CommentViewModel](https://github.com/hitherejoe/MVVM_Hacker_News/blob/master/app/src/main/java/com/hitherejoe/mvvm_hackernews/viewModel/CommentViewModel.java) - The view model used for standard comments on a post
38 |
39 | [CommentsHeaderViewModel](https://github.com/hitherejoe/MVVM_Hacker_News/blob/master/app/src/main/java/com/hitherejoe/mvvm_hackernews/viewModel/CommentHeaderViewModel.java) - The view model used as a header for the post text with an ASK post
40 |
41 | [item_comment](https://github.com/hitherejoe/MVVM_Hacker_News/blob/master/app/src/main/res/layout/item_comment.xml) - The XML layout file which displays a standard comment
42 |
43 | [item_comments_header](https://github.com/hitherejoe/MVVM_Hacker_News/blob/master/app/src/main/res/layout/item_comments_header.xml) - The XML layout file used to display the header for an ASK post
44 |
45 | Requirements
46 | ------------
47 |
48 | - [Android SDK](http://developer.android.com/sdk/index.html).
49 | - Android [5.1 (API 22) ](http://developer.android.com/tools/revisions/platforms.html#5.1).
50 | - Android SDK Tools
51 | - Android SDK Build tools 23.0.0.0
52 | - Android Support Repository
53 | - Android Support library
54 |
55 | Building
56 | --------
57 |
58 | To build, install and run a debug version, run this from the root of the project:
59 |
60 | ./gradlew installRunDebug
61 |
62 | Testing
63 | --------
64 |
65 | To run **unit** tests on your machine using [Robolectric] (http://robolectric.org/):
66 |
67 | ./gradlew testDebugUnitTest
68 |
69 | To run automated tests on connected devices:
70 |
71 | ./gradlew connectedAndroidTest
72 |
--------------------------------------------------------------------------------
/app/manifest-merger-release-report.txt:
--------------------------------------------------------------------------------
1 | -- Merging decision tree log ---
2 | manifest
3 | ADDED from AndroidManifest.xml:2:1
4 | package
5 | ADDED from AndroidManifest.xml:3:5
6 | INJECTED from AndroidManifest.xml:0:0
7 | INJECTED from AndroidManifest.xml:0:0
8 | android:versionName
9 | INJECTED from AndroidManifest.xml:0:0
10 | INJECTED from AndroidManifest.xml:0:0
11 | xmlns:android
12 | ADDED from AndroidManifest.xml:2:11
13 | android:versionCode
14 | INJECTED from AndroidManifest.xml:0:0
15 | INJECTED from AndroidManifest.xml:0:0
16 | uses-permission#android.permission.INTERNET
17 | ADDED from AndroidManifest.xml:5:5
18 | android:name
19 | ADDED from AndroidManifest.xml:5:22
20 | uses-permission#android.permission.ACCESS_NETWORK_STATE
21 | ADDED from AndroidManifest.xml:6:5
22 | android:name
23 | ADDED from AndroidManifest.xml:6:22
24 | uses-permission#android.permission.ACCESS_WIFI_STATE
25 | ADDED from AndroidManifest.xml:7:5
26 | android:name
27 | ADDED from AndroidManifest.xml:7:22
28 | application
29 | ADDED from AndroidManifest.xml:9:5
30 | MERGED from com.android.support:recyclerview-v7:21.0.0:17:5
31 | MERGED from com.android.support:support-v4:21.0.0:16:5
32 | MERGED from com.android.support:appcompat-v7:21.0.0:16:5
33 | MERGED from com.android.support:support-v4:21.0.0:16:5
34 | MERGED from com.android.support:cardview-v7:21.0.0:16:5
35 | android:label
36 | ADDED from AndroidManifest.xml:13:9
37 | android:allowBackup
38 | ADDED from AndroidManifest.xml:11:9
39 | android:icon
40 | ADDED from AndroidManifest.xml:12:9
41 | android:theme
42 | ADDED from AndroidManifest.xml:14:9
43 | android:name
44 | ADDED from AndroidManifest.xml:10:9
45 | activity#com.hitherejoe.hackernews.ui.activity.MainActivity
46 | ADDED from AndroidManifest.xml:15:9
47 | android:label
48 | ADDED from AndroidManifest.xml:17:13
49 | android:name
50 | ADDED from AndroidManifest.xml:16:13
51 | intent-filter#android.intent.action.MAIN+android.intent.category.LAUNCHER
52 | ADDED from AndroidManifest.xml:18:13
53 | action#android.intent.action.MAIN
54 | ADDED from AndroidManifest.xml:19:17
55 | android:name
56 | ADDED from AndroidManifest.xml:19:25
57 | category#android.intent.category.LAUNCHER
58 | ADDED from AndroidManifest.xml:21:17
59 | android:name
60 | ADDED from AndroidManifest.xml:21:27
61 | activity#com.hitherejoe.hackernews.ui.activity.UserActivity
62 | ADDED from AndroidManifest.xml:25:9
63 | android:name
64 | ADDED from AndroidManifest.xml:25:19
65 | activity#com.hitherejoe.hackernews.ui.activity.CommentsActivity
66 | ADDED from AndroidManifest.xml:26:9
67 | android:name
68 | ADDED from AndroidManifest.xml:26:19
69 | activity#com.hitherejoe.hackernews.ui.activity.WebPageActivity
70 | ADDED from AndroidManifest.xml:27:9
71 | android:name
72 | ADDED from AndroidManifest.xml:27:19
73 | activity#com.hitherejoe.hackernews.ui.activity.AboutActivity
74 | ADDED from AndroidManifest.xml:28:9
75 | android:name
76 | ADDED from AndroidManifest.xml:28:19
77 | activity#com.hitherejoe.hackernews.ui.activity.BookmarksActivity
78 | ADDED from AndroidManifest.xml:29:9
79 | android:name
80 | ADDED from AndroidManifest.xml:29:19
81 | uses-sdk
82 | INJECTED from AndroidManifest.xml:0:0 reason: use-sdk injection requested
83 | MERGED from com.android.support:recyclerview-v7:21.0.0:15:5
84 | MERGED from com.android.support:support-v4:21.0.0:15:5
85 | MERGED from uk.co.ribot:easyadapter:1.4.0:6:5
86 | MERGED from com.android.support:appcompat-v7:21.0.0:15:5
87 | MERGED from com.android.support:support-v4:21.0.0:15:5
88 | MERGED from com.android.support:cardview-v7:21.0.0:15:5
89 | android:targetSdkVersion
90 | INJECTED from AndroidManifest.xml:0:0
91 | INJECTED from AndroidManifest.xml:0:0
92 | android:minSdkVersion
93 | INJECTED from AndroidManifest.xml:0:0
94 | INJECTED from AndroidManifest.xml:0:0
95 | activity#android.support.v7.widget.TestActivity
96 | ADDED from com.android.support:recyclerview-v7:21.0.0:18:9
97 | android:label
98 | ADDED from com.android.support:recyclerview-v7:21.0.0:18:19
99 | android:name
100 | ADDED from com.android.support:recyclerview-v7:21.0.0:18:60
101 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/model/Post.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.model;
2 |
3 | import android.os.Parcel;
4 | import android.os.Parcelable;
5 |
6 | import com.google.gson.annotations.SerializedName;
7 |
8 | import java.util.ArrayList;
9 |
10 | public class Post implements Parcelable {
11 |
12 | public Long id;
13 | public String by;
14 | public Long time;
15 | public ArrayList kids;
16 | public String url;
17 | public Long score;
18 | public String title;
19 | public String text;
20 | @SerializedName("type")
21 | public PostType postType;
22 |
23 | public enum PostType {
24 | @SerializedName("story")
25 | STORY("story"),
26 | @SerializedName("ask")
27 | ASK("ask"),
28 | @SerializedName("job")
29 | JOB("job");
30 |
31 | private String string;
32 |
33 | PostType(String string) {
34 | this.string = string;
35 | }
36 |
37 | public static PostType fromString(String string) {
38 | if (string != null) {
39 | for (PostType postType : PostType.values()) {
40 | if (string.equalsIgnoreCase(postType.string)) return postType;
41 | }
42 | }
43 | return null;
44 | }
45 | }
46 |
47 | public Post() { }
48 |
49 | @Override
50 | public boolean equals(Object o) {
51 | if (this == o) return true;
52 | if (o == null || getClass() != o.getClass()) return false;
53 |
54 | Post story = (Post) o;
55 |
56 | if (by != null ? !by.equals(story.by) : story.by != null) return false;
57 | if (id != null ? !id.equals(story.id) : story.id != null) return false;
58 | if (kids != null ? !kids.equals(story.kids) : story.kids != null) return false;
59 | if (score != null ? !score.equals(story.score) : story.score != null) return false;
60 | if (postType != story.postType) return false;
61 | if (time != null ? !time.equals(story.time) : story.time != null) return false;
62 | if (title != null ? !title.equals(story.title) : story.title != null) return false;
63 | if (text != null ? !text.equals(story.text) : story.text != null) return false;
64 | if (url != null ? !url.equals(story.url) : story.url != null) return false;
65 |
66 | return true;
67 | }
68 |
69 | @Override
70 | public int hashCode() {
71 | int result = by != null ? by.hashCode() : 0;
72 | result = 31 * result + (id != null ? id.hashCode() : 0);
73 | result = 31 * result + (time != null ? time.hashCode() : 0);
74 | result = 31 * result + (kids != null ? kids.hashCode() : 0);
75 | result = 31 * result + (url != null ? url.hashCode() : 0);
76 | result = 31 * result + (score != null ? score.hashCode() : 0);
77 | result = 31 * result + (title != null ? title.hashCode() : 0);
78 | result = 31 * result + (text != null ? text.hashCode() : 0);
79 | result = 31 * result + (postType != null ? postType.hashCode() : 0);
80 | return result;
81 | }
82 |
83 | @Override
84 | public int describeContents() {
85 | return 0;
86 | }
87 |
88 | @Override
89 | public void writeToParcel(Parcel dest, int flags) {
90 | dest.writeString(this.by);
91 | dest.writeValue(this.id);
92 | dest.writeValue(this.time);
93 | dest.writeSerializable(this.kids);
94 | dest.writeString(this.url);
95 | dest.writeValue(this.score);
96 | dest.writeString(this.title);
97 | dest.writeString(this.text);
98 | dest.writeInt(this.postType == null ? -1 : this.postType.ordinal());
99 | }
100 |
101 | private Post(Parcel in) {
102 | this.by = in.readString();
103 | this.id = (Long) in.readValue(Long.class.getClassLoader());
104 | this.time = (Long) in.readValue(Long.class.getClassLoader());
105 | this.kids = (ArrayList) in.readSerializable();
106 | this.url = in.readString();
107 | this.score = (Long) in.readValue(Long.class.getClassLoader());
108 | this.title = in.readString();
109 | this.text = in.readString();
110 | int tmpStoryType = in.readInt();
111 | this.postType = tmpStoryType == -1 ? null : PostType.values()[tmpStoryType];
112 | }
113 |
114 | public static final Parcelable.Creator CREATOR = new Parcelable.Creator() {
115 | public Post createFromParcel(Parcel source) {
116 | return new Post(source);
117 | }
118 |
119 | public Post[] newArray(int size) {
120 | return new Post[size];
121 | }
122 | };
123 | }
124 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/data/DataManager.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.data;
2 |
3 | import android.content.Context;
4 |
5 | import com.hitherejoe.mvvm_hackernews.HackerNewsApplication;
6 | import com.hitherejoe.mvvm_hackernews.model.Comment;
7 | import com.hitherejoe.mvvm_hackernews.model.Post;
8 | import com.hitherejoe.mvvm_hackernews.model.User;
9 | import com.hitherejoe.mvvm_hackernews.data.remote.HackerNewsService;
10 | import com.hitherejoe.mvvm_hackernews.injection.component.DaggerDataManagerComponent;
11 | import com.hitherejoe.mvvm_hackernews.injection.module.DataManagerModule;
12 |
13 | import java.util.List;
14 |
15 | import javax.inject.Inject;
16 |
17 | import rx.Observable;
18 | import rx.Scheduler;
19 | import rx.functions.Func1;
20 |
21 | public class DataManager {
22 |
23 | @Inject protected HackerNewsService mHackerNewsService;
24 | @Inject protected Scheduler mSubscribeScheduler;
25 |
26 | public DataManager(Context context) {
27 | injectDependencies(context);
28 | }
29 |
30 | /* This constructor is provided so we can set up a DataManager with mocks from unit test.
31 | * At the moment this is not possible to do with Dagger because the Gradle APT plugin doesn't
32 | * work for the unit test variant, plus Dagger 2 doesn't provide a nice way of overriding
33 | * modules */
34 | public DataManager(HackerNewsService watchTowerService,
35 | Scheduler subscribeScheduler) {
36 | mHackerNewsService = watchTowerService;
37 | mSubscribeScheduler = subscribeScheduler;
38 | }
39 |
40 | protected void injectDependencies(Context context) {
41 | DaggerDataManagerComponent.builder()
42 | .applicationComponent(HackerNewsApplication.get(context).getComponent())
43 | .dataManagerModule(new DataManagerModule())
44 | .build()
45 | .inject(this);
46 | }
47 |
48 | public Scheduler getScheduler() {
49 | return mSubscribeScheduler;
50 | }
51 |
52 | public Observable getTopStories() {
53 | return mHackerNewsService.getTopStories()
54 | .concatMap(new Func1, Observable extends Post>>() {
55 | @Override
56 | public Observable extends Post> call(List longs) {
57 | return getPostsFromIds(longs);
58 | }
59 | });
60 | }
61 |
62 | public Observable getUserPosts(String user) {
63 | return mHackerNewsService.getUser(user)
64 | .concatMap(new Func1>() {
65 | @Override
66 | public Observable extends Post> call(User user) {
67 | return getPostsFromIds(user.submitted);
68 | }
69 | });
70 | }
71 |
72 | public Observable getPostsFromIds(List storyIds) {
73 | return Observable.from(storyIds)
74 | .concatMap(new Func1>() {
75 | @Override
76 | public Observable call(Long aLong) {
77 | return mHackerNewsService.getStoryItem(String.valueOf(aLong));
78 | }
79 | }).flatMap(new Func1>() {
80 | @Override
81 | public Observable call(Post post) {
82 | return post.title != null ? Observable.just(post) : Observable.empty();
83 | }
84 | });
85 | }
86 |
87 | public Observable getPostComments(final List commentIds, final int depth) {
88 | return Observable.from(commentIds)
89 | .concatMap(new Func1>() {
90 | @Override
91 | public Observable call(Long aLong) {
92 | return mHackerNewsService.getCommentItem(String.valueOf(aLong));
93 | }
94 | }).concatMap(new Func1>() {
95 | @Override
96 | public Observable call(Comment comment) {
97 | comment.depth = depth;
98 | if (comment.kids == null || comment.kids.isEmpty()) {
99 | return Observable.just(comment);
100 | } else {
101 | return Observable.just(comment)
102 | .mergeWith(getPostComments(comment.kids, depth + 1));
103 | }
104 | }
105 | }).filter(new Func1() {
106 | @Override
107 | public Boolean call(Comment comment) {
108 | return (comment.by != null && !comment.by.trim().isEmpty()
109 | && comment.text != null && !comment.text.trim().isEmpty());
110 | }
111 | });
112 | }
113 |
114 | }
115 |
--------------------------------------------------------------------------------
/app/src/test/java/com/hitherejoe/mvvm_hackernews/DataManagerTest.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews;
2 |
3 | import com.hitherejoe.mvvm_hackernews.data.DataManager;
4 | import com.hitherejoe.mvvm_hackernews.model.Post;
5 | import com.hitherejoe.mvvm_hackernews.model.User;
6 | import com.hitherejoe.mvvm_hackernews.data.remote.HackerNewsService;
7 | import com.hitherejoe.mvvm_hackernews.util.DefaultConfig;
8 | import com.hitherejoe.mvvm_hackernews.util.MockModelsUtil;
9 |
10 | import org.junit.Before;
11 | import org.junit.Test;
12 | import org.junit.runner.RunWith;
13 | import org.robolectric.RobolectricTestRunner;
14 | import org.robolectric.annotation.Config;
15 |
16 | import java.util.ArrayList;
17 | import java.util.List;
18 |
19 | import rx.Observable;
20 | import rx.observers.TestSubscriber;
21 | import rx.schedulers.Schedulers;
22 |
23 | import static org.mockito.Matchers.any;
24 | import static org.mockito.Mockito.mock;
25 | import static org.mockito.Mockito.when;
26 |
27 | @RunWith(RobolectricTestRunner.class)
28 | @Config(constants = BuildConfig.class, sdk = DefaultConfig.EMULATE_SDK)
29 | public class DataManagerTest {
30 |
31 | private DataManager mDataManager;
32 | private HackerNewsService mMockHackerNewsService;
33 |
34 | @Before
35 | public void setUp() {
36 | mMockHackerNewsService = mock(HackerNewsService.class);
37 | mDataManager = new DataManager(mMockHackerNewsService, Schedulers.immediate());
38 | }
39 |
40 | @Test
41 | public void shouldGetTopStories() throws Exception {
42 | User mockUser = MockModelsUtil.createMockUser();
43 | Post mockStoryOne = MockModelsUtil.createMockStoryWithId(mockUser.submitted.get(0));
44 | Post mockStoryTwo = MockModelsUtil.createMockStoryWithId(mockUser.submitted.get(1));
45 | Post mockStoryThree = MockModelsUtil.createMockStoryWithId(mockUser.submitted.get(2));
46 | Post mockStoryFour = MockModelsUtil.createMockStoryWithId(mockUser.submitted.get(3));
47 |
48 | when(mMockHackerNewsService.getStoryItem(String.valueOf(mockUser.submitted.get(0))))
49 | .thenReturn(Observable.just(mockStoryOne));
50 | when(mMockHackerNewsService.getStoryItem(String.valueOf(mockUser.submitted.get(1))))
51 | .thenReturn(Observable.just(mockStoryTwo));
52 | when(mMockHackerNewsService.getStoryItem(String.valueOf(mockUser.submitted.get(2))))
53 | .thenReturn(Observable.just(mockStoryThree));
54 | when(mMockHackerNewsService.getStoryItem(String.valueOf(mockUser.submitted.get(3))))
55 | .thenReturn(Observable.just(mockStoryFour));
56 |
57 | final List storyIds = new ArrayList<>();
58 | storyIds.add(mockUser.submitted.get(0));
59 | storyIds.add(mockUser.submitted.get(1));
60 | storyIds.add(mockUser.submitted.get(2));
61 | storyIds.add(mockUser.submitted.get(3));
62 |
63 | List topStories = new ArrayList<>();
64 | topStories.add(mockStoryOne);
65 | topStories.add(mockStoryTwo);
66 | topStories.add(mockStoryThree);
67 | topStories.add(mockStoryFour);
68 |
69 | TestSubscriber result = new TestSubscriber<>();
70 | mDataManager.getPostsFromIds(storyIds).subscribe(result);
71 | result.assertNoErrors();
72 | result.assertReceivedOnNext(topStories);
73 | }
74 |
75 | @Test
76 | public void shouldGetUserStories() throws Exception {
77 | User mockUser = MockModelsUtil.createMockUser();
78 | when(mMockHackerNewsService.getUser(any(String.class)))
79 | .thenReturn(Observable.just(mockUser));
80 | Post mockStoryOne = MockModelsUtil.createMockStoryWithId(mockUser.submitted.get(0));
81 | Post mockStoryTwo = MockModelsUtil.createMockStoryWithId(mockUser.submitted.get(1));
82 | Post mockStoryThree = MockModelsUtil.createMockStoryWithId(mockUser.submitted.get(2));
83 | Post mockStoryFour = MockModelsUtil.createMockStoryWithId(mockUser.submitted.get(3));
84 |
85 | when(mMockHackerNewsService.getStoryItem(String.valueOf(mockUser.submitted.get(0))))
86 | .thenReturn(Observable.just(mockStoryOne));
87 | when(mMockHackerNewsService.getStoryItem(String.valueOf(mockUser.submitted.get(1))))
88 | .thenReturn(Observable.just(mockStoryTwo));
89 | when(mMockHackerNewsService.getStoryItem(String.valueOf(mockUser.submitted.get(2))))
90 | .thenReturn(Observable.just(mockStoryThree));
91 | when(mMockHackerNewsService.getStoryItem(String.valueOf(mockUser.submitted.get(3))))
92 | .thenReturn(Observable.just(mockStoryFour));
93 |
94 | List userStories = new ArrayList<>();
95 | userStories.add(mockStoryOne);
96 | userStories.add(mockStoryTwo);
97 | userStories.add(mockStoryThree);
98 | userStories.add(mockStoryFour);
99 |
100 | TestSubscriber result = new TestSubscriber<>();
101 | mDataManager.getUserPosts(mockUser.id).subscribe(result);
102 | result.assertNoErrors();
103 | result.assertReceivedOnNext(userStories);
104 |
105 | }
106 |
107 | }
108 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_post.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
17 |
18 |
25 |
26 |
32 |
33 |
42 |
43 |
46 |
47 |
55 |
56 |
66 |
67 |
68 |
69 |
70 |
71 |
75 |
76 |
81 |
82 |
94 |
95 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
--------------------------------------------------------------------------------
/module_androidtest_only/src/main/java/com/hitherejoe/module_androidtest_only/MainActivityTest.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.module_androidtest_only;
2 |
3 | import android.content.Intent;
4 | import android.support.test.InstrumentationRegistry;
5 | import android.support.test.espresso.contrib.RecyclerViewActions;
6 | import android.support.test.rule.ActivityTestRule;
7 | import android.support.test.runner.AndroidJUnit4;
8 |
9 | import com.hitherejoe.module_androidtest_only.injection.TestComponentRule;
10 | import com.hitherejoe.mvvm_hackernews.model.Post;
11 | import com.hitherejoe.mvvm_hackernews.util.MockModelsUtil;
12 | import com.hitherejoe.mvvm_hackernews.view.activity.MainActivity;
13 |
14 | import org.junit.Rule;
15 | import org.junit.Test;
16 | import org.junit.runner.RunWith;
17 |
18 | import java.util.ArrayList;
19 | import java.util.List;
20 |
21 | import rx.Observable;
22 |
23 | import static android.support.test.espresso.Espresso.onView;
24 | import static android.support.test.espresso.action.ViewActions.click;
25 | import static android.support.test.espresso.assertion.ViewAssertions.matches;
26 | import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed;
27 | import static android.support.test.espresso.matcher.ViewMatchers.withId;
28 | import static android.support.test.espresso.matcher.ViewMatchers.withText;
29 | import static org.hamcrest.Matchers.not;
30 | import static org.mockito.Mockito.when;
31 |
32 | import com.hitherejoe.mvvm_hackernews.R;
33 |
34 | @RunWith(AndroidJUnit4.class)
35 | public class MainActivityTest {
36 |
37 | @Rule
38 | public final ActivityTestRule main =
39 | new ActivityTestRule<>(MainActivity.class, false, false);
40 |
41 | @Rule
42 | public final TestComponentRule component = new TestComponentRule();
43 |
44 | @Test
45 | public void testPostsShowAndAreScrollableInFeed() {
46 | List postIdList = MockModelsUtil.createMockPostIdList(20);
47 | List postList = new ArrayList<>();
48 | for (Long id : postIdList) {
49 | postList.add(MockModelsUtil.createMockStoryWithId(id));
50 | }
51 |
52 | stubMockPosts(postIdList, postList);
53 | main.launchActivity(null);
54 |
55 | checkPostsDisplayOnRecyclerView(postList);
56 | }
57 |
58 | @Test
59 | public void testStoryPostHasViewButton() throws Exception {
60 | List postIdList = new ArrayList<>();
61 | Post mockPost = MockModelsUtil.createMockStoryWithTitle("Post with url");
62 | postIdList.add(mockPost.id);
63 | when(component.getMockHackerNewsService().getTopStories()).thenReturn(Observable.just(postIdList));
64 | when(component.getMockHackerNewsService().getStoryItem(mockPost.id.toString())).thenReturn(Observable.just(mockPost));
65 |
66 | Intent i = new Intent(MainActivity.getStartIntent(InstrumentationRegistry.getTargetContext()));
67 | main.launchActivity(i);
68 | onView(withId(R.id.text_view_post)).check(matches(isDisplayed()));
69 | }
70 |
71 | @Test
72 | public void testJobPostHasViewButton() throws Exception {
73 | List postIdList = new ArrayList<>();
74 | Post mockPost = MockModelsUtil.createMockJobWithTitle("Post with url");
75 | postIdList.add(mockPost.id);
76 | when(component.getMockHackerNewsService().getTopStories()).thenReturn(Observable.just(postIdList));
77 | when(component.getMockHackerNewsService().getStoryItem(mockPost.id.toString())).thenReturn(Observable.just(mockPost));
78 |
79 | Intent i = new Intent(MainActivity.getStartIntent(InstrumentationRegistry.getTargetContext()));
80 | main.launchActivity(i);
81 | onView(withId(R.id.text_view_post)).check(matches(isDisplayed()));
82 | }
83 |
84 | @Test
85 | public void testViewPost() throws Exception {
86 | List postIdList = new ArrayList<>();
87 | Post mockPost = MockModelsUtil.createMockStoryWithTitle("Post with url");
88 | postIdList.add(mockPost.id);
89 | when(component.getMockHackerNewsService().getTopStories()).thenReturn(Observable.just(postIdList));
90 | when(component.getMockHackerNewsService().getStoryItem(mockPost.id.toString())).thenReturn(Observable.just(mockPost));
91 |
92 | Intent i = new Intent(MainActivity.getStartIntent(InstrumentationRegistry.getTargetContext()));
93 | main.launchActivity(i);
94 | onView(withText(mockPost.title)).check(matches(isDisplayed()));
95 | onView(withText(mockPost.title)).perform(click());
96 | onView(withText(mockPost.title)).check(matches(isDisplayed()));
97 | }
98 |
99 | private void checkPostsDisplayOnRecyclerView(List postsToCheck) {
100 | for (int i = 0; i < postsToCheck.size(); i++) {
101 | onView(withId(R.id.recycler_stories))
102 | .perform(RecyclerViewActions.scrollToPosition(i));
103 | checkPostDisplays(postsToCheck.get(i));
104 | }
105 | }
106 |
107 | private void checkPostDisplays(Post post) {
108 | onView(withText(post.title))
109 | .check(matches(isDisplayed()));
110 | }
111 |
112 | private void stubMockPosts(List postIds, List mockPosts) {
113 | when(component.getMockHackerNewsService().getTopStories())
114 | .thenReturn(Observable.just(postIds));
115 | for (Long id : postIds) {
116 | for (Post post : mockPosts) {
117 | if (post.id.equals(id)) {
118 | when(component.getMockHackerNewsService().getStoryItem(id.toString()))
119 | .thenReturn(Observable.just(post));
120 | }
121 | }
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/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 | # For Cygwin, ensure paths are in UNIX format before anything is touched.
46 | if $cygwin ; then
47 | [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
48 | fi
49 |
50 | # Attempt to set APP_HOME
51 | # Resolve links: $0 may be a link
52 | PRG="$0"
53 | # Need this for relative symlinks.
54 | while [ -h "$PRG" ] ; do
55 | ls=`ls -ld "$PRG"`
56 | link=`expr "$ls" : '.*-> \(.*\)$'`
57 | if expr "$link" : '/.*' > /dev/null; then
58 | PRG="$link"
59 | else
60 | PRG=`dirname "$PRG"`"/$link"
61 | fi
62 | done
63 | SAVED="`pwd`"
64 | cd "`dirname \"$PRG\"`/" >&-
65 | APP_HOME="`pwd -P`"
66 | cd "$SAVED" >&-
67 |
68 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
69 |
70 | # Determine the Java command to use to start the JVM.
71 | if [ -n "$JAVA_HOME" ] ; then
72 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
73 | # IBM's JDK on AIX uses strange locations for the executables
74 | JAVACMD="$JAVA_HOME/jre/sh/java"
75 | else
76 | JAVACMD="$JAVA_HOME/bin/java"
77 | fi
78 | if [ ! -x "$JAVACMD" ] ; then
79 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
80 |
81 | Please set the JAVA_HOME variable in your environment to match the
82 | location of your Java installation."
83 | fi
84 | else
85 | JAVACMD="java"
86 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
87 |
88 | Please set the JAVA_HOME variable in your environment to match the
89 | location of your Java installation."
90 | fi
91 |
92 | # Increase the maximum file descriptors if we can.
93 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
94 | MAX_FD_LIMIT=`ulimit -H -n`
95 | if [ $? -eq 0 ] ; then
96 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
97 | MAX_FD="$MAX_FD_LIMIT"
98 | fi
99 | ulimit -n $MAX_FD
100 | if [ $? -ne 0 ] ; then
101 | warn "Could not set maximum file descriptor limit: $MAX_FD"
102 | fi
103 | else
104 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
105 | fi
106 | fi
107 |
108 | # For Darwin, add options to specify how the application appears in the dock
109 | if $darwin; then
110 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
111 | fi
112 |
113 | # For Cygwin, switch paths to Windows format before running java
114 | if $cygwin ; then
115 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
116 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
158 | function splitJvmOpts() {
159 | JVM_OPTS=("$@")
160 | }
161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
163 |
164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
165 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/view/activity/CommentsActivity.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.view.activity;
2 |
3 | import android.content.Context;
4 | import android.content.Intent;
5 | import android.os.Bundle;
6 | import android.support.v7.app.ActionBar;
7 | import android.support.v7.widget.LinearLayoutManager;
8 | import android.support.v7.widget.RecyclerView;
9 | import android.support.v7.widget.Toolbar;
10 | import android.view.View;
11 | import android.widget.LinearLayout;
12 | import android.widget.RelativeLayout;
13 | import android.widget.TextView;
14 |
15 | import com.hitherejoe.mvvm_hackernews.HackerNewsApplication;
16 | import com.hitherejoe.mvvm_hackernews.R;
17 | import com.hitherejoe.mvvm_hackernews.data.DataManager;
18 | import com.hitherejoe.mvvm_hackernews.model.Comment;
19 | import com.hitherejoe.mvvm_hackernews.model.Post;
20 | import com.hitherejoe.mvvm_hackernews.view.adapter.CommentAdapter;
21 | import com.hitherejoe.mvvm_hackernews.util.DataUtils;
22 | import com.hitherejoe.mvvm_hackernews.util.DialogFactory;
23 |
24 | import java.util.ArrayList;
25 | import java.util.List;
26 |
27 | import butterknife.Bind;
28 | import butterknife.ButterKnife;
29 | import butterknife.OnClick;
30 | import rx.Subscriber;
31 | import rx.android.schedulers.AndroidSchedulers;
32 | import rx.subscriptions.CompositeSubscription;
33 | import timber.log.Timber;
34 |
35 | public class CommentsActivity extends BaseActivity {
36 |
37 | @Bind(R.id.progress_indicator)
38 | LinearLayout mProgressBar;
39 |
40 | @Bind(R.id.layout_offline)
41 | LinearLayout mOfflineLayout;
42 |
43 | @Bind(R.id.recycler_comments)
44 | RecyclerView mCommentsRecycler;
45 |
46 | @Bind(R.id.layout_comments)
47 | RelativeLayout mCommentsLayout;
48 |
49 | @Bind(R.id.text_no_comments)
50 | TextView mNoCommentsText;
51 |
52 | @Bind(R.id.toolbar)
53 | Toolbar mToolbar;
54 |
55 | public static final String EXTRA_POST =
56 | "com.hitherejoe.mvvm_hackernews.ui.activity.CommentsActivity.EXTRA_POST";
57 |
58 | private ArrayList mComments;
59 | private CommentAdapter mCommentsAdapter;
60 | private DataManager mDataManager;
61 | private CompositeSubscription mSubscriptions;
62 | private Post mPost;
63 |
64 | public static Intent getStartIntent(Context context, Post post) {
65 | Intent intent = new Intent(context, CommentsActivity.class);
66 | intent.putExtra(EXTRA_POST, post);
67 | return intent;
68 | }
69 |
70 | @Override
71 | protected void onCreate(Bundle savedInstanceState) {
72 | super.onCreate(savedInstanceState);
73 | setContentView(R.layout.activity_comments);
74 | ButterKnife.bind(this);
75 | mPost = getIntent().getParcelableExtra(EXTRA_POST);
76 | if (mPost == null) throw new IllegalArgumentException("CommentsActivity requires a Post object!");
77 | mDataManager = HackerNewsApplication.get(this).getComponent().dataManager();
78 | mSubscriptions = new CompositeSubscription();
79 | mComments = new ArrayList<>();
80 | setupToolbar();
81 | setupRecyclerView();
82 | loadStoriesIfNetworkConnected();
83 | }
84 |
85 | @Override
86 | public void onDestroy() {
87 | super.onDestroy();
88 | mSubscriptions.unsubscribe();
89 | }
90 |
91 | @OnClick(R.id.button_try_again)
92 | public void onTryAgainClick() {
93 | loadStoriesIfNetworkConnected();
94 | }
95 |
96 | private void setupToolbar() {
97 | setSupportActionBar(mToolbar);
98 | ActionBar actionBar = getSupportActionBar();
99 | if (actionBar != null) {
100 | String title = mPost.title;
101 | if (title != null) actionBar.setTitle(title);
102 | actionBar.setDisplayHomeAsUpEnabled(true);
103 | }
104 | }
105 |
106 | private void setupRecyclerView() {
107 | mCommentsRecycler.setLayoutManager(new LinearLayoutManager(this));
108 | mCommentsAdapter = new CommentAdapter(this, mPost, mComments);
109 | mCommentsRecycler.setAdapter(mCommentsAdapter);
110 | }
111 |
112 | private void loadStoriesIfNetworkConnected() {
113 | if (DataUtils.isNetworkAvailable(this)) {
114 | showHideOfflineLayout(false);
115 | getStoryComments(mPost.kids);
116 | } else {
117 | showHideOfflineLayout(true);
118 | }
119 | }
120 |
121 | private void getStoryComments(List commentIds) {
122 | if (commentIds != null && !commentIds.isEmpty()) {
123 | mSubscriptions.add(mDataManager.getPostComments(commentIds, 0)
124 | .observeOn(AndroidSchedulers.mainThread())
125 | .subscribeOn(mDataManager.getScheduler())
126 | .subscribe(new Subscriber() {
127 | @Override
128 | public void onCompleted() {
129 | mProgressBar.setVisibility(View.GONE);
130 | }
131 |
132 | @Override
133 | public void onError(Throwable e) {
134 | mProgressBar.setVisibility(View.GONE);
135 | Timber.e("There was an error retrieving the comments " + e);
136 | DialogFactory.createSimpleOkErrorDialog(
137 | CommentsActivity.this,
138 | getString(R.string.error_comments)
139 | ).show();
140 | }
141 |
142 | @Override
143 | public void onNext(Comment comment) {
144 | addCommentViews(comment);
145 | }
146 | }));
147 | } else {
148 | mProgressBar.setVisibility(View.GONE);
149 | mCommentsRecycler.setVisibility(View.GONE);
150 | mNoCommentsText.setVisibility(View.VISIBLE);
151 | }
152 | }
153 |
154 | private void addCommentViews(Comment comment) {
155 | mComments.add(comment);
156 | mComments.addAll(comment.comments);
157 | mCommentsAdapter.notifyDataSetChanged();
158 | }
159 |
160 | private void showHideOfflineLayout(boolean isOffline) {
161 | mOfflineLayout.setVisibility(isOffline ? View.VISIBLE : View.GONE);
162 | mCommentsRecycler.setVisibility(isOffline ? View.GONE : View.VISIBLE);
163 | mProgressBar.setVisibility(isOffline ? View.GONE : View.VISIBLE);
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/view/activity/ViewStoryActivity.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.view.activity;
2 |
3 | import android.content.Context;
4 | import android.content.Intent;
5 | import android.net.Uri;
6 | import android.os.Bundle;
7 | import android.support.v4.view.MenuItemCompat;
8 | import android.support.v7.app.ActionBar;
9 | import android.support.v7.widget.ShareActionProvider;
10 | import android.support.v7.widget.Toolbar;
11 | import android.view.Menu;
12 | import android.view.MenuItem;
13 | import android.view.View;
14 | import android.webkit.WebChromeClient;
15 | import android.webkit.WebSettings;
16 | import android.webkit.WebView;
17 | import android.webkit.WebViewClient;
18 | import android.widget.LinearLayout;
19 | import android.widget.ProgressBar;
20 | import android.widget.RelativeLayout;
21 |
22 | import com.hitherejoe.mvvm_hackernews.R;
23 | import com.hitherejoe.mvvm_hackernews.model.Post;
24 | import com.hitherejoe.mvvm_hackernews.util.DataUtils;
25 |
26 | import butterknife.Bind;
27 | import butterknife.ButterKnife;
28 | import butterknife.OnClick;
29 |
30 | public class ViewStoryActivity extends BaseActivity {
31 |
32 | @Bind(R.id.progress_indicator)
33 | LinearLayout mProgressContainer;
34 |
35 | @Bind(R.id.layout_offline)
36 | LinearLayout mOfflineLayout;
37 |
38 | @Bind(R.id.layout_story)
39 | RelativeLayout mStoryLayout;
40 |
41 | @Bind(R.id.toolbar)
42 | Toolbar mToolbar;
43 |
44 | @Bind(R.id.web_view)
45 | WebView mWebView;
46 |
47 | public static final String EXTRA_POST =
48 | "com.hitherejoe.mvvm_hackernews.ui.activity.WebPageActivity.EXTRA_POST";
49 | private static final String KEY_PDF = "pdf";
50 | private static final String URL_GOOGLE_DOCS = "http://docs.google.com/gview?embedded=true&url=";
51 | private static final String URL_PLAY_STORE =
52 | "https://play.google.com/store/apps/details?id=com.hitherejoe.hackernews&hl=en_GB";
53 | private Post mPost;
54 |
55 | public static Intent getStartIntent(Context context, Post post) {
56 | Intent intent = new Intent(context, ViewStoryActivity.class);
57 | intent.putExtra(EXTRA_POST, post);
58 | return intent;
59 | }
60 |
61 | @Override
62 | protected void onCreate(Bundle savedInstanceState) {
63 | super.onCreate(savedInstanceState);
64 | setContentView(R.layout.activity_view_story);
65 | ButterKnife.bind(this);
66 | Bundle bundle = getIntent().getExtras();
67 | mPost = bundle.getParcelable(EXTRA_POST);
68 | if (mPost == null) throw new IllegalArgumentException("ViewStoryActivity requires a Post object!");
69 | setupToolbar();
70 | setupWebView();
71 | }
72 |
73 | @Override
74 | public boolean onCreateOptionsMenu(Menu menu) {
75 | getMenuInflater().inflate(R.menu.view_story, menu);
76 | setupShareActionProvider(menu);
77 | return true;
78 | }
79 |
80 | @Override
81 | public void onBackPressed() {
82 | if (mWebView.canGoBack()) {
83 | mWebView.goBack();
84 | } else {
85 | super.onBackPressed();
86 | }
87 | }
88 |
89 | @Override
90 | public boolean onOptionsItemSelected(MenuItem item) {
91 | switch (item.getItemId()) {
92 | case R.id.action_browser:
93 | startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(mPost.url)));
94 | return true;
95 | default:
96 | return super.onOptionsItemSelected(item);
97 | }
98 | }
99 |
100 | @OnClick(R.id.button_try_again)
101 | public void onTryAgainClick() {
102 | setupWebView();
103 | }
104 |
105 | private void setupToolbar() {
106 | setSupportActionBar(mToolbar);
107 | ActionBar actionBar = getSupportActionBar();
108 | if (actionBar != null) {
109 | actionBar.setDisplayHomeAsUpEnabled(true);
110 | actionBar.setTitle(mPost.title);
111 | }
112 | }
113 |
114 | private void setupShareActionProvider(Menu menu) {
115 | ShareActionProvider shareActionProvider =
116 | (ShareActionProvider) MenuItemCompat.getActionProvider(menu.findItem(R.id.menu_item_share));
117 | if (shareActionProvider != null) shareActionProvider.setShareIntent(getShareIntent());
118 | }
119 |
120 | private void setupWebView() {
121 | mWebView.setWebChromeClient(new WebChromeClient() {
122 | public void onProgressChanged(WebView view, int progress) {
123 | if (progress == 100) mProgressContainer.setVisibility(ProgressBar.GONE);
124 | }
125 | });
126 | mWebView.setWebViewClient(new ProgressWebViewClient());
127 | mWebView.setInitialScale(1);
128 | mWebView.getSettings().setBuiltInZoomControls(true);
129 | mWebView.getSettings().setDisplayZoomControls(true);
130 | mWebView.getSettings().setLoadsImagesAutomatically(true);
131 | mWebView.setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY);
132 | mWebView.getSettings().setJavaScriptEnabled(true);
133 | mWebView.getSettings().setLoadWithOverviewMode(true);
134 | mWebView.getSettings().setUseWideViewPort(true);
135 | mWebView.getSettings().setAllowFileAccess(true);
136 | mWebView.getSettings().setAppCacheEnabled(true);
137 | mWebView.getSettings().setCacheMode(WebSettings.LOAD_DEFAULT);
138 |
139 | if (DataUtils.isNetworkAvailable(this)) {
140 | showHideOfflineLayout(false);
141 | if (mPost.postType == Post.PostType.STORY) {
142 | String strippedUrl = mPost.url.split("\\?")[0].split("#")[0];
143 | mWebView.loadUrl(strippedUrl.endsWith(KEY_PDF) ? URL_GOOGLE_DOCS + mPost.url : mPost.url);
144 | } else {
145 | mWebView.loadUrl(mPost.url);
146 | }
147 | } else {
148 | showHideOfflineLayout(true);
149 | }
150 | }
151 |
152 | private Intent getShareIntent() {
153 | String shareText = mPost.title + " " + getString(R.string.seperator_name_points)
154 | + " " + mPost.url + " " + getString(R.string.via) + " " + URL_PLAY_STORE;
155 | return new Intent()
156 | .setAction(Intent.ACTION_SEND)
157 | .setType("text/plain")
158 | .putExtra(Intent.EXTRA_TEXT, shareText);
159 | }
160 |
161 | private void showHideOfflineLayout(boolean isOffline) {
162 | mOfflineLayout.setVisibility(isOffline ? View.VISIBLE : View.GONE);
163 | mWebView.setVisibility(isOffline ? View.GONE : View.VISIBLE);
164 | mProgressContainer.setVisibility(isOffline ? View.GONE : View.VISIBLE);
165 | }
166 |
167 | private class ProgressWebViewClient extends WebViewClient {
168 | @Override
169 | public boolean shouldOverrideUrlLoading(WebView view, String url) {
170 | view.loadUrl(url);
171 | return true;
172 | }
173 |
174 | @Override
175 | public void onPageFinished(WebView view, String page) {
176 | mProgressContainer.setVisibility(ProgressBar.GONE);
177 | }
178 | }
179 |
180 | }
181 |
--------------------------------------------------------------------------------
/app/src/main/java/com/hitherejoe/mvvm_hackernews/view/fragment/StoriesFragment.java:
--------------------------------------------------------------------------------
1 | package com.hitherejoe.mvvm_hackernews.view.fragment;
2 |
3 | import android.os.Bundle;
4 | import android.support.v4.app.Fragment;
5 | import android.support.v4.widget.SwipeRefreshLayout;
6 | import android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener;
7 | import android.support.v7.app.ActionBar;
8 | import android.support.v7.app.AppCompatActivity;
9 | import android.support.v7.widget.LinearLayoutManager;
10 | import android.support.v7.widget.RecyclerView;
11 | import android.support.v7.widget.Toolbar;
12 | import android.view.LayoutInflater;
13 | import android.view.View;
14 | import android.view.ViewGroup;
15 | import android.widget.LinearLayout;
16 | import android.widget.ProgressBar;
17 |
18 | import com.hitherejoe.mvvm_hackernews.HackerNewsApplication;
19 | import com.hitherejoe.mvvm_hackernews.R;
20 | import com.hitherejoe.mvvm_hackernews.data.DataManager;
21 | import com.hitherejoe.mvvm_hackernews.model.Post;
22 | import com.hitherejoe.mvvm_hackernews.view.adapter.PostAdapter;
23 | import com.hitherejoe.mvvm_hackernews.util.DataUtils;
24 | import com.hitherejoe.mvvm_hackernews.util.DialogFactory;
25 |
26 | import java.util.ArrayList;
27 | import java.util.List;
28 |
29 | import butterknife.Bind;
30 | import butterknife.ButterKnife;
31 | import butterknife.OnClick;
32 | import rx.Subscriber;
33 | import rx.android.schedulers.AndroidSchedulers;
34 | import rx.subscriptions.CompositeSubscription;
35 | import timber.log.Timber;
36 |
37 | public class StoriesFragment extends Fragment implements OnRefreshListener {
38 |
39 | @Bind(R.id.swipe_container)
40 | SwipeRefreshLayout mSwipeRefreshLayout;
41 |
42 | @Bind(R.id.recycler_stories)
43 | RecyclerView mListPosts;
44 |
45 | @Bind(R.id.layout_offline)
46 | LinearLayout mOfflineContainer;
47 |
48 | @Bind(R.id.progress_indicator)
49 | ProgressBar mProgressBar;
50 |
51 | @Bind(R.id.toolbar)
52 | Toolbar mToolbar;
53 |
54 | public static final String ARG_USER = "ARG_USER";
55 |
56 | private DataManager mDataManager;
57 | private PostAdapter mPostAdapter;
58 | private CompositeSubscription mSubscriptions;
59 | private List mStories;
60 | private String mUser;
61 |
62 | public static StoriesFragment newInstance(String user) {
63 | StoriesFragment storiesFragment = new StoriesFragment();
64 | Bundle args = new Bundle();
65 | args.putString(ARG_USER, user);
66 | storiesFragment.setArguments(args);
67 | return storiesFragment;
68 | }
69 |
70 | @Override
71 | public void onCreate(Bundle savedInstanceState) {
72 | super.onCreate(savedInstanceState);
73 | mSubscriptions = new CompositeSubscription();
74 | mStories = new ArrayList<>();
75 | mDataManager = HackerNewsApplication.get(getActivity()).getComponent().dataManager();
76 | Bundle bundle = getArguments();
77 | if (bundle != null) mUser = bundle.getString(ARG_USER, null);
78 | mPostAdapter = new PostAdapter(getActivity(), mUser != null);
79 | }
80 |
81 | @Override
82 | public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
83 | View fragmentView = inflater.inflate(R.layout.fragment_stories, container, false);
84 | ButterKnife.bind(this, fragmentView);
85 | mSwipeRefreshLayout.setOnRefreshListener(this);
86 | mSwipeRefreshLayout.setColorSchemeResources(R.color.hn_orange);
87 | setupToolbar();
88 | setupRecyclerView();
89 | loadStoriesIfNetworkConnected();
90 | return fragmentView;
91 | }
92 |
93 | @Override
94 | public void onDestroy() {
95 | super.onDestroy();
96 | mSubscriptions.unsubscribe();
97 | }
98 |
99 | @Override
100 | public void onRefresh() {
101 | mSubscriptions.unsubscribe();
102 | if (mPostAdapter != null) mPostAdapter.setItems(new ArrayList());
103 | if (mUser != null) {
104 | getUserStories();
105 | } else {
106 | getTopStories();
107 | }
108 | }
109 |
110 | @OnClick(R.id.button_try_again)
111 | public void onTryAgainClick() {
112 | loadStoriesIfNetworkConnected();
113 | }
114 |
115 | private void setupToolbar() {
116 | ((AppCompatActivity) getActivity()).setSupportActionBar(mToolbar);
117 | ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar();
118 | if (actionBar != null) {
119 | actionBar.setDisplayShowTitleEnabled(true);
120 | if (mUser != null) {
121 | actionBar.setTitle(mUser);
122 | actionBar.setDisplayHomeAsUpEnabled(true);
123 | }
124 | }
125 | }
126 |
127 | private void setupRecyclerView() {
128 | mListPosts.setLayoutManager(new LinearLayoutManager(getActivity()));
129 | mListPosts.setHasFixedSize(true);
130 | mPostAdapter.setItems(mStories);
131 | mListPosts.setAdapter(mPostAdapter);
132 | }
133 |
134 | private void loadStoriesIfNetworkConnected() {
135 | if (DataUtils.isNetworkAvailable(getActivity())) {
136 | showHideOfflineLayout(false);
137 | if (mUser != null) {
138 | getUserStories();
139 | } else {
140 | getTopStories();
141 | }
142 | } else {
143 | showHideOfflineLayout(true);
144 | }
145 | }
146 |
147 | private void getTopStories() {
148 | mSubscriptions.add(mDataManager.getTopStories()
149 | .observeOn(AndroidSchedulers.mainThread())
150 | .subscribeOn(mDataManager.getScheduler())
151 | .subscribe(new Subscriber() {
152 | @Override
153 | public void onCompleted() { }
154 |
155 | @Override
156 | public void onError(Throwable e) {
157 | hideLoadingViews();
158 | Timber.e("There was a problem loading the top stories " + e);
159 | e.printStackTrace();
160 | DialogFactory.createSimpleOkErrorDialog(
161 | getActivity(),
162 | getString(R.string.error_stories)
163 | ).show();
164 | }
165 |
166 | @Override
167 | public void onNext(Post post) {
168 | hideLoadingViews();
169 | mPostAdapter.addItem(post);
170 | }
171 | }));
172 | }
173 |
174 | private void getUserStories() {
175 | mSubscriptions.add(mDataManager.getUserPosts(mUser)
176 | .observeOn(AndroidSchedulers.mainThread())
177 | .subscribeOn(mDataManager.getScheduler())
178 | .subscribe(new Subscriber() {
179 | @Override
180 | public void onCompleted() { }
181 |
182 | @Override
183 | public void onError(Throwable e) {
184 | hideLoadingViews();
185 | Timber.e("There was a problem loading the user stories " + e);
186 | DialogFactory.createSimpleOkErrorDialog(
187 | getActivity(),
188 | getString(R.string.error_stories)
189 | ).show();
190 | }
191 |
192 | @Override
193 | public void onNext(Post story) {
194 | hideLoadingViews();
195 | mPostAdapter.addItem(story);
196 | }
197 | }));
198 | }
199 |
200 | private void hideLoadingViews() {
201 | mProgressBar.setVisibility(View.GONE);
202 | mSwipeRefreshLayout.setRefreshing(false);
203 | }
204 |
205 | private void showHideOfflineLayout(boolean isOffline) {
206 | mOfflineContainer.setVisibility(isOffline ? View.VISIBLE : View.GONE);
207 | mListPosts.setVisibility(isOffline ? View.GONE : View.VISIBLE);
208 | mProgressBar.setVisibility(isOffline ? View.GONE : View.VISIBLE);
209 | }
210 |
211 | }
212 |
--------------------------------------------------------------------------------