From af70c0c9e1e90c20da144bea9e2d326cb3661692 Mon Sep 17 00:00:00 2001 From: Hossain Khan Date: Mon, 8 Apr 2019 18:42:31 -0400 Subject: [PATCH] [FIXED] Using navigator pattern to clean up the activity. [ADDED] Dagger modules to support navigator [ADDED] Unit test for the browse viewmodel. --- .../browse/DefaultLayoutBrowseNavigator.kt | 38 +++++++ .../demo/browse/LayoutBrowseActivity.kt | 39 +------ .../demo/browse/LayoutBrowseNavigator.kt | 27 +++++ .../demo/browse/LayoutBrowseViewModel.kt | 38 ++++++- .../demo/dagger/ActivityBindingModule.kt | 4 +- .../demo/dagger/DemoApplicationComponent.kt | 3 +- ...odule.kt => LayoutBrowseActivityModule.kt} | 6 +- ...mponent.kt => LayoutBrowseSubcomponent.kt} | 2 +- .../android/demo/dagger/NavigatorModule.kt | 32 ++++++ .../LayoutPreviewViewModelFactory.kt | 6 +- .../demo/browse/LayoutBrowseViewModelTest.kt | 101 ++++++++++++++++++ 11 files changed, 248 insertions(+), 48 deletions(-) create mode 100644 app/src/main/java/com/hossainkhan/android/demo/browse/DefaultLayoutBrowseNavigator.kt create mode 100644 app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseNavigator.kt rename app/src/main/java/com/hossainkhan/android/demo/dagger/{MainActivityModule.kt => LayoutBrowseActivityModule.kt} (86%) rename app/src/main/java/com/hossainkhan/android/demo/dagger/{MainActivitySubcomponent.kt => LayoutBrowseSubcomponent.kt} (93%) create mode 100644 app/src/main/java/com/hossainkhan/android/demo/dagger/NavigatorModule.kt create mode 100644 app/src/test/java/com/hossainkhan/android/demo/browse/LayoutBrowseViewModelTest.kt diff --git a/app/src/main/java/com/hossainkhan/android/demo/browse/DefaultLayoutBrowseNavigator.kt b/app/src/main/java/com/hossainkhan/android/demo/browse/DefaultLayoutBrowseNavigator.kt new file mode 100644 index 0000000..49de004 --- /dev/null +++ b/app/src/main/java/com/hossainkhan/android/demo/browse/DefaultLayoutBrowseNavigator.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2019 Hossain Khan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hossainkhan.android.demo.browse + +import android.content.Context +import android.content.Intent +import com.hossainkhan.android.demo.layoutpreview.LayoutPreviewBaseActivity +import javax.inject.Inject + +class DefaultLayoutBrowseNavigator @Inject constructor(private val context: Context) : LayoutBrowseNavigator { + override fun loadLayoutPreview(layoutResId: Int) { + val startIntent = LayoutPreviewBaseActivity.createStartIntent(context, layoutResId) + // FIX IT: android.util.AndroidRuntimeException: Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag. Is this really what you want? + startIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(startIntent) + } + + override fun loadLayoutPreview(clazz: Class, layoutResId: Int) { + val startIntent = LayoutPreviewBaseActivity.createStartIntent(context, clazz, layoutResId) + // FIX IT: android.util.AndroidRuntimeException: Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag. Is this really what you want? + startIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(startIntent) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseActivity.kt b/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseActivity.kt index a199995..48a8ee5 100644 --- a/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseActivity.kt +++ b/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseActivity.kt @@ -22,14 +22,8 @@ import androidx.lifecycle.ViewModelProviders import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.hossainkhan.android.demo.R -import com.hossainkhan.android.demo.layoutpreview.LayoutChainStyleActivity -import com.hossainkhan.android.demo.layoutpreview.LayoutGuidelineBarrierActivity -import com.hossainkhan.android.demo.layoutpreview.LayoutGuidelineGroupActivity -import com.hossainkhan.android.demo.layoutpreview.LayoutPreviewBaseActivity -import com.hossainkhan.android.demo.layoutpreview.LayoutVisibilityGoneActivity import com.hossainkhan.android.demo.viewmodel.LayoutPreviewViewModelFactory import dagger.android.AndroidInjection -import timber.log.Timber import javax.inject.Inject /** @@ -61,7 +55,7 @@ class LayoutBrowseActivity : AppCompatActivity() { viewAdapter = LayoutBrowseAdapter( viewModel = viewModel, lifecycleOwner = this, - itemSelectedListener = this::onLayoutItemSelected) + itemSelectedListener = viewModel::onLayoutItemSelected) recyclerView = findViewById(R.id.recycler_view).apply { // use this setting to improve performance if you know that changes @@ -75,35 +69,4 @@ class LayoutBrowseActivity : AppCompatActivity() { adapter = viewAdapter } } - - private fun onLayoutItemSelected(layoutResId: Int) { - Timber.i("Selected layout id: %s", layoutResId) - - /* - * Where applicable, loads specific activity with interactive feature for user to try out feature. - */ - when (layoutResId) { - R.layout.preview_visibility_gone -> { - startActivity(LayoutPreviewBaseActivity.createStartIntent(this, - LayoutVisibilityGoneActivity::class.java, R.layout.preview_visibility_gone)) - } - R.layout.preview_chain_style_main -> { - startActivity(LayoutPreviewBaseActivity.createStartIntent(this, - LayoutChainStyleActivity::class.java, R.layout.preview_chain_style_main)) - } - R.layout.preview_virtual_helper_barrier -> { - startActivity(LayoutPreviewBaseActivity.createStartIntent(this, - LayoutGuidelineBarrierActivity::class.java, R.layout.preview_virtual_helper_barrier)) - } - R.layout.preview_virtual_helper_group -> { - startActivity(LayoutPreviewBaseActivity.createStartIntent(this, - LayoutGuidelineGroupActivity::class.java, R.layout.preview_virtual_helper_group)) - } - else -> { - // By default it loads the preview activity with the layout requested. - startActivity(LayoutPreviewBaseActivity.createStartIntent(this, layoutResId)) - } - } - - } } diff --git a/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseNavigator.kt b/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseNavigator.kt new file mode 100644 index 0000000..e7e7c49 --- /dev/null +++ b/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseNavigator.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2019 Hossain Khan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hossainkhan.android.demo.browse + +import androidx.annotation.LayoutRes + +/** + * Interface to navigate for Layout Browse view. + */ +interface LayoutBrowseNavigator { + fun loadLayoutPreview(@LayoutRes layoutResId: Int) + fun loadLayoutPreview(clazz: Class, @LayoutRes layoutResId: Int) +} \ No newline at end of file diff --git a/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseViewModel.kt b/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseViewModel.kt index 5fbac2c..6656f32 100644 --- a/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseViewModel.kt +++ b/app/src/main/java/com/hossainkhan/android/demo/browse/LayoutBrowseViewModel.kt @@ -19,12 +19,18 @@ package com.hossainkhan.android.demo.browse import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.hossainkhan.android.demo.R import com.hossainkhan.android.demo.data.AppDataStore import com.hossainkhan.android.demo.data.LayoutInformation +import com.hossainkhan.android.demo.layoutpreview.LayoutChainStyleActivity +import com.hossainkhan.android.demo.layoutpreview.LayoutGuidelineBarrierActivity +import com.hossainkhan.android.demo.layoutpreview.LayoutGuidelineGroupActivity +import com.hossainkhan.android.demo.layoutpreview.LayoutVisibilityGoneActivity import timber.log.Timber class LayoutBrowseViewModel( - appDataStore: AppDataStore) : ViewModel() { + appDataStore: AppDataStore, + private val browseNavigator: LayoutBrowseNavigator) : ViewModel() { private val layoutInfoListLiveData = MutableLiveData>() @@ -32,10 +38,38 @@ class LayoutBrowseViewModel( get() = layoutInfoListLiveData init { - Timber.d("Got data: ${appDataStore.isFirstTime()}") + Timber.d("Is first time user: ${appDataStore.isFirstTime()}") appDataStore.updateFirstTimeUser(false) layoutInfoListLiveData.value = appDataStore.layoutStore.supportedLayoutInfos } + + + fun onLayoutItemSelected(layoutResId: Int) { + Timber.i("Selected layout id: %s", layoutResId) + + /* + * Where applicable, loads specific activity with interactive feature for user to try out feature. + */ + when (layoutResId) { + R.layout.preview_visibility_gone -> { + browseNavigator.loadLayoutPreview(LayoutVisibilityGoneActivity::class.java, layoutResId) + } + R.layout.preview_chain_style_main -> { + browseNavigator.loadLayoutPreview(LayoutChainStyleActivity::class.java, layoutResId) + } + R.layout.preview_virtual_helper_barrier -> { + browseNavigator.loadLayoutPreview(LayoutGuidelineBarrierActivity::class.java, layoutResId) + } + R.layout.preview_virtual_helper_group -> { + browseNavigator.loadLayoutPreview(LayoutGuidelineGroupActivity::class.java, layoutResId) + } + else -> { + // By default it loads the preview activity with the layout requested. + browseNavigator.loadLayoutPreview(layoutResId) + } + } + + } } \ No newline at end of file diff --git a/app/src/main/java/com/hossainkhan/android/demo/dagger/ActivityBindingModule.kt b/app/src/main/java/com/hossainkhan/android/demo/dagger/ActivityBindingModule.kt index 276e543..798ca6d 100644 --- a/app/src/main/java/com/hossainkhan/android/demo/dagger/ActivityBindingModule.kt +++ b/app/src/main/java/com/hossainkhan/android/demo/dagger/ActivityBindingModule.kt @@ -16,6 +16,7 @@ package com.hossainkhan.android.demo.dagger +import com.hossainkhan.android.demo.browse.LayoutBrowseActivity import com.hossainkhan.android.demo.layoutpreview.LayoutChainStyleActivity import com.hossainkhan.android.demo.layoutpreview.LayoutGuidelineBarrierActivity import com.hossainkhan.android.demo.layoutpreview.LayoutGuidelineGroupActivity @@ -48,9 +49,10 @@ abstract class ActivityBindingModule { * into the subcomponent. If the subcomponent needs scopes, apply the scope annotations to * the method as well. */ + @ActivityScope @ContributesAndroidInjector - abstract fun layoutPositioningActivity(): LayoutPreviewBaseActivity + abstract fun layoutPreviewBaseActivity(): LayoutPreviewBaseActivity @ActivityScope @ContributesAndroidInjector diff --git a/app/src/main/java/com/hossainkhan/android/demo/dagger/DemoApplicationComponent.kt b/app/src/main/java/com/hossainkhan/android/demo/dagger/DemoApplicationComponent.kt index 7a5304f..42cb22f 100644 --- a/app/src/main/java/com/hossainkhan/android/demo/dagger/DemoApplicationComponent.kt +++ b/app/src/main/java/com/hossainkhan/android/demo/dagger/DemoApplicationComponent.kt @@ -27,7 +27,8 @@ import javax.inject.Singleton ApplicationModule::class, ActivityBindingModule::class, DataStoreModule::class, - MainActivityModule::class)) + NavigatorModule::class, + LayoutBrowseActivityModule::class)) interface DemoApplicationComponent { fun inject(app: DemoApplication) diff --git a/app/src/main/java/com/hossainkhan/android/demo/dagger/MainActivityModule.kt b/app/src/main/java/com/hossainkhan/android/demo/dagger/LayoutBrowseActivityModule.kt similarity index 86% rename from app/src/main/java/com/hossainkhan/android/demo/dagger/MainActivityModule.kt rename to app/src/main/java/com/hossainkhan/android/demo/dagger/LayoutBrowseActivityModule.kt index 4099a56..01a0ffa 100644 --- a/app/src/main/java/com/hossainkhan/android/demo/dagger/MainActivityModule.kt +++ b/app/src/main/java/com/hossainkhan/android/demo/dagger/LayoutBrowseActivityModule.kt @@ -24,13 +24,13 @@ import dagger.android.ActivityKey import dagger.android.AndroidInjector import dagger.multibindings.IntoMap -@Module(subcomponents = arrayOf(MainActivitySubcomponent::class)) -abstract class MainActivityModule { +@Module(subcomponents = [LayoutBrowseSubcomponent::class]) +abstract class LayoutBrowseActivityModule { @Binds @IntoMap @ActivityKey(LayoutBrowseActivity::class) abstract fun bindMainActivityInjectorFactory( - builder: MainActivitySubcomponent.Builder): AndroidInjector.Factory + builder: LayoutBrowseSubcomponent.Builder): AndroidInjector.Factory } \ No newline at end of file diff --git a/app/src/main/java/com/hossainkhan/android/demo/dagger/MainActivitySubcomponent.kt b/app/src/main/java/com/hossainkhan/android/demo/dagger/LayoutBrowseSubcomponent.kt similarity index 93% rename from app/src/main/java/com/hossainkhan/android/demo/dagger/MainActivitySubcomponent.kt rename to app/src/main/java/com/hossainkhan/android/demo/dagger/LayoutBrowseSubcomponent.kt index 7238e6a..9b57388 100644 --- a/app/src/main/java/com/hossainkhan/android/demo/dagger/MainActivitySubcomponent.kt +++ b/app/src/main/java/com/hossainkhan/android/demo/dagger/LayoutBrowseSubcomponent.kt @@ -21,7 +21,7 @@ import dagger.Subcomponent import dagger.android.AndroidInjector @Subcomponent -interface MainActivitySubcomponent : AndroidInjector { +interface LayoutBrowseSubcomponent : AndroidInjector { @Subcomponent.Builder abstract class Builder : AndroidInjector.Builder() } \ No newline at end of file diff --git a/app/src/main/java/com/hossainkhan/android/demo/dagger/NavigatorModule.kt b/app/src/main/java/com/hossainkhan/android/demo/dagger/NavigatorModule.kt new file mode 100644 index 0000000..84ef7ee --- /dev/null +++ b/app/src/main/java/com/hossainkhan/android/demo/dagger/NavigatorModule.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2019 Hossain Khan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hossainkhan.android.demo.dagger + +import android.content.Context +import com.hossainkhan.android.demo.browse.DefaultLayoutBrowseNavigator +import com.hossainkhan.android.demo.browse.LayoutBrowseNavigator +import dagger.Module +import dagger.Provides + +@Module +class NavigatorModule { + + @Provides + fun provideLayoutBrowseNavigator(context: Context): LayoutBrowseNavigator { + return DefaultLayoutBrowseNavigator(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/hossainkhan/android/demo/viewmodel/LayoutPreviewViewModelFactory.kt b/app/src/main/java/com/hossainkhan/android/demo/viewmodel/LayoutPreviewViewModelFactory.kt index 40376ee..7eec229 100644 --- a/app/src/main/java/com/hossainkhan/android/demo/viewmodel/LayoutPreviewViewModelFactory.kt +++ b/app/src/main/java/com/hossainkhan/android/demo/viewmodel/LayoutPreviewViewModelFactory.kt @@ -18,6 +18,7 @@ package com.hossainkhan.android.demo.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.hossainkhan.android.demo.browse.LayoutBrowseNavigator import com.hossainkhan.android.demo.browse.LayoutBrowseViewModel import com.hossainkhan.android.demo.data.AppDataStore import com.hossainkhan.android.demo.layoutpreview.LayoutInfoViewModel @@ -27,7 +28,8 @@ import javax.inject.Inject * The [ViewModelProvider.Factory] that provides all the ViewModels for the activities and fragments. */ class LayoutPreviewViewModelFactory @Inject constructor( - private val dataStore: AppDataStore + private val dataStore: AppDataStore, + private val browseNavigator: LayoutBrowseNavigator ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -37,7 +39,7 @@ class LayoutPreviewViewModelFactory @Inject constructor( LayoutInfoViewModel(dataStore) as T } modelClass.isAssignableFrom(LayoutBrowseViewModel::class.java) -> { - LayoutBrowseViewModel(dataStore) as T + LayoutBrowseViewModel(dataStore,browseNavigator) as T } else -> throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/app/src/test/java/com/hossainkhan/android/demo/browse/LayoutBrowseViewModelTest.kt b/app/src/test/java/com/hossainkhan/android/demo/browse/LayoutBrowseViewModelTest.kt new file mode 100644 index 0000000..822fc0c --- /dev/null +++ b/app/src/test/java/com/hossainkhan/android/demo/browse/LayoutBrowseViewModelTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2019 Hossain Khan + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.hossainkhan.android.demo.browse + +import android.content.SharedPreferences +import android.content.res.Resources +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.hossainkhan.android.demo.R +import com.hossainkhan.android.demo.data.AppDataStore +import com.hossainkhan.android.demo.data.LayoutDataStore +import com.hossainkhan.android.demo.layoutpreview.LayoutChainStyleActivity +import com.hossainkhan.android.demo.layoutpreview.LayoutGuidelineBarrierActivity +import com.hossainkhan.android.demo.layoutpreview.LayoutGuidelineGroupActivity +import com.hossainkhan.android.demo.layoutpreview.LayoutVisibilityGoneActivity +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.mockito.Mockito.`when` +import org.mockito.Mockito.anyBoolean +import org.mockito.Mockito.anyString +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify + +class LayoutBrowseViewModelTest { + + /** + * Uses rule to test LiveData + * + * References: + * - https://medium.com/pxhouse/unit-testing-with-mutablelivedata-22b3283a7819 + * - https://stackoverflow.com/questions/29945087/kotlin-and-new-activitytestrule-the-rule-must-be-public + */ + @get:Rule + val rule: TestRule = InstantTaskExecutorRule() + + private val resources: Resources = mock(Resources::class.java) + private val preferences: SharedPreferences = mock(SharedPreferences::class.java) + private val editor = mock(SharedPreferences.Editor::class.java) + private val layoutStore = LayoutDataStore(resources) + private val navigator = mock(LayoutBrowseNavigator::class.java) + + lateinit var sut: LayoutBrowseViewModel + + @Before + fun setup() { + `when`(editor.putBoolean(anyString(), anyBoolean())).thenReturn(editor) + `when`(preferences.edit()).thenReturn(editor) + sut = LayoutBrowseViewModel(AppDataStore(preferences, layoutStore), navigator) + } + + + @Test + fun onLayoutItemSelected_givenGenericLayout_loadsDefaultPreview() { + val layoutResId = 123 + sut.onLayoutItemSelected(layoutResId) + verify(navigator).loadLayoutPreview(layoutResId) + } + + @Test + fun onLayoutItemSelected_givenVisibilityGoneLayout_loadsVisibilityGonePreview() { + val layoutResId = R.layout.preview_visibility_gone + sut.onLayoutItemSelected(layoutResId) + verify(navigator).loadLayoutPreview(LayoutVisibilityGoneActivity::class.java, layoutResId) + } + + @Test + fun onLayoutItemSelected_givenChainStyleLayout_loadsChainStylePreview() { + val layoutResId = R.layout.preview_chain_style_main + sut.onLayoutItemSelected(layoutResId) + verify(navigator).loadLayoutPreview(LayoutChainStyleActivity::class.java, layoutResId) + } + + @Test + fun onLayoutItemSelected_givenGuidelineBarrierLayout_loadsGuidelineBarrierPreview() { + val layoutResId = R.layout.preview_virtual_helper_barrier + sut.onLayoutItemSelected(layoutResId) + verify(navigator).loadLayoutPreview(LayoutGuidelineBarrierActivity::class.java, layoutResId) + } + + @Test + fun onLayoutItemSelected_givenGuidelineGroupLayout_loadsGuidelineGroupPreview() { + val layoutResId = R.layout.preview_virtual_helper_group + sut.onLayoutItemSelected(layoutResId) + verify(navigator).loadLayoutPreview(LayoutGuidelineGroupActivity::class.java, layoutResId) + } +} \ No newline at end of file