From 0e9eaea71c6c82cb21200651a85109d2c33faf8a Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Fri, 20 Sep 2019 01:33:56 -0700 Subject: [PATCH 01/14] Introduce a generic data-binding-enabled RecyclerView adapter. No tests have yet been added. --- app/build.gradle | 5 +- .../oppia/app/recyclerview/BindableAdapter.kt | 208 ++++++++++++++++++ .../RecyclerViewBindingAdapter.kt | 20 ++ 3 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt create mode 100644 app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt diff --git a/app/build.gradle b/app/build.gradle index 64ea16800a2..5a1823e94e8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -61,10 +61,11 @@ dependencies { 'androidx.constraintlayout:constraintlayout:1.1.3', 'androidx.core:core-ktx:1.0.2', 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha03', + 'androidx.recyclerview:recyclerview:1.0.0', 'com.google.dagger:dagger:2.24', "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version", - "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1", - "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1" + 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1', + 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1', ) testImplementation( 'androidx.test:core:1.2.0', diff --git a/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt b/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt new file mode 100644 index 00000000000..d665e9eb05a --- /dev/null +++ b/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt @@ -0,0 +1,208 @@ +package org.oppia.app.recyclerview + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView +import kotlin.reflect.KClass + +/** A function that returns the type of view that should can bind the specified data object. */ +typealias ComputeViewType = (T) -> Int + +/** + * The default type of all views used for adapters that do not need to specify different view types. + */ +const val DEFAULT_VIEW_TYPE = 0 + +private typealias ViewHolderFactory = (ViewGroup) -> BindableAdapter.BindableViewHolder + +/** + * A generic [RecyclerView.Adapter] that can be initialized using Android data-binding, and bind its + * own child views using Android data-binding (or custom View bind methods). + * + * This is loosely based on https://android.jlelse.eu/1bd08b4796b4 except the concept was extended + * to include seamlessly binding to views using data-binding in a type-safe and lifecycle-safe way. + */ +class BindableAdapter internal constructor( + private val computeViewType: ComputeViewType, + private val viewHolderFactoryMap: Map>, + val dataClassType: KClass +) : RecyclerView.Adapter>() { + private val dataList: MutableList = ArrayList() + + // TODO(#170): Introduce support for stable IDs. + + /** Sets the data of this adapter. This is expected to be called by Android via data-binding. */ + fun setData(newDataList: List) { + dataList.clear() + dataList += newDataList + // TODO(#171): Introduce diffing to notify subsets of the view to properly support animations + // rather than re-binding the entire list upon any change. + notifyDataSetChanged() + } + + /** + * Sets the data of this adapter in the same way as [setData], except with a different type. + * + * This method ensures the type of data being passed in is compatible with the type of this + * adapter. This helps ensure type compatibility at runtime in cases where the generic type of the + * adapter object is lost. + */ + fun setDataUnchecked(newDataList: List) { + // NB: This check only works if the list has any data in it. Since we can't use a reified type + // here (due to Android data binding not supporting custom adapters with inline functions), this + // method will succeed if types are different for empty lists (that is, List == List + // when T1 is not assignable to T2). This likely won't have bad side effects since any time a + // non-empty list is attempted to be bound, this crash will be correctly triggered. + newDataList.firstOrNull()?.let { + check(it.javaClass.isAssignableFrom(dataClassType.java)) { + "Trying to bind incompatible data to adapter. Data class type: ${it.javaClass}, " + + "expected adapter class type: $dataClassType." + } + } + @Suppress("UNCHECKED_CAST") // This is safe. See the above check. + setData(newDataList as List) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindableViewHolder { + val viewHolderFactory = viewHolderFactoryMap[viewType] + checkNotNull(viewHolderFactory) { "Encountered missing view factory for type: $viewType" } + return viewHolderFactory(parent) + } + + override fun getItemCount(): Int { + return dataList.size + } + + override fun getItemViewType(position: Int): Int { + return computeViewType(dataList[position]) + } + + override fun onBindViewHolder(holder: BindableViewHolder, position: Int) { + holder.bind(dataList[position]) + } + + /** A generic [RecyclerView.ViewHolder] that generically binds data to the specified view. */ + abstract class BindableViewHolder internal constructor( + view: View + ) : RecyclerView.ViewHolder(view) { + internal abstract fun bind(data: T) + } + + /** + * Constructs a new [BindableAdapter]. Each type returned by the computer should have an + * associated view binder. If no computer is set, then [DEFAULT_VIEW_TYPE] is the default view + * type. + * + * Instances of [Builder] should be instantiated using [newBuilder]. + */ + class Builder(private val dataClassType: KClass) { + private var computeViewType: ComputeViewType = { DEFAULT_VIEW_TYPE } + private var viewHolderFactoryMap: MutableMap> = HashMap() + + /** + * Registers a function which returns the type of view a specific data item corresponds to. This defaults to always + * assuming all views are the same time: [DEFAULT_VIEW_TYPE]. + * + * This function must be used if multiple views need to be supported by the [RecyclerView]. + * + * @return this + */ + fun registerViewTypeComputer(computeViewType: ComputeViewType): Builder { + this.computeViewType = computeViewType + return this + } + + /** + * Registers a [View] inflater and bind function for views of the specified view type (with default value + * [DEFAULT_VIEW_TYPE] for single-view [RecyclerView]s). Note that the viewType specified here must correspond to a + * view type registered in [registerViewTypeComputer] if non-default, or if any view type computer has been + * registered. + * + * The inflateView and bindView functions passed in here must not hold any references to UI objects except those + * that own the RecyclerView. + * + * @param inflateView function that takes a parent [ViewGroup] and returns a newly inflated [View] of type [V] + * @param bindView function that takes a [RecyclerView]-owned [View] of type [V] and binds a data element typed [T] + * to it + * @return this + */ + fun registerViewBinder( + viewType: Int = DEFAULT_VIEW_TYPE, inflateView: (ViewGroup) -> V, bindView: (V, T) -> Unit + ): Builder { + checkViewTypeIsUnique(viewType) + val viewHolderFactory: ViewHolderFactory = { viewGroup -> + // This is lifecycle-safe since it will become dereferenced when the factory method returns. + // The version referenced in the anonymous BindableViewHolder object should be copied into a + // class field that binds that reference's lifetime to the view holder's lifetime. This + // approach avoids needing to perform an unsafe cast later when binding the view. + val inflatedView = inflateView(viewGroup) + object : BindableViewHolder(inflatedView) { + override fun bind(data: T) { + bindView(inflatedView, data) + } + } + } + viewHolderFactoryMap[viewType] = viewHolderFactory + return this + } + + /** + * Behaves in the same way as [registerViewBinder] except the inflate and bind methods correspond to a [View] + * data-binding typed [DB]. + * + * @param inflateDataBinding a function that inflates the root view of a data-bound layout (e.g. + * MyDataBinding::inflate). This may also be a function that initializes the data-binding with additional + * properties as necessary. + * @param setViewModel a function that initializes the view model in the data-bound view (e.g. + * MyDataBinding::setSpecialViewModel). This may also be a function that initializes the view model & other + * view-accessible properties as necessary. + * @return this + */ + fun registerViewDataBinder( + viewType: Int = DEFAULT_VIEW_TYPE, + inflateDataBinding: (LayoutInflater, ViewGroup, Boolean) -> DB, + setViewModel: (DB, T) -> Unit + ): Builder { + checkViewTypeIsUnique(viewType) + val viewHolderFactory: ViewHolderFactory = { viewGroup -> + // See registerViewBinder() comments for why this approach should be lifecycle safe and not + // introduce memory leaks. + val binding = inflateDataBinding( + LayoutInflater.from(viewGroup.context), + viewGroup, + /* attachToRoot= */ false + ) + object : BindableViewHolder(binding.root) { + override fun bind(data: T) { + setViewModel(binding, data) + } + } + } + viewHolderFactoryMap[viewType] = viewHolderFactory + return this + } + + private fun checkViewTypeIsUnique(viewType: Int) { + check(!viewHolderFactoryMap.containsKey(viewType)) { + "Cannot register a second view binder for view type: $viewType (current binder: " + + "${viewHolderFactoryMap[viewType]}." + } + } + + /** Returns a new [BindableAdapter]. */ + fun build(): BindableAdapter { + val computeViewType = this.computeViewType + check(viewHolderFactoryMap.isNotEmpty()) { "At least one view holder must be registered" } + return BindableAdapter(computeViewType, viewHolderFactoryMap, dataClassType) + } + + companion object { + /** Returns a new [Builder]. */ + inline fun newBuilder(): Builder { + return Builder(T::class) + } + } + } +} diff --git a/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt b/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt new file mode 100644 index 00000000000..51b7e935975 --- /dev/null +++ b/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt @@ -0,0 +1,20 @@ +package org.oppia.app.recyclerview + +import androidx.databinding.BindingAdapter +import androidx.lifecycle.LiveData +import androidx.recyclerview.widget.RecyclerView + +/** + * Binds the specified generic data to the adapter of the [RecyclerView]. This is called by + * Android's data binding framework and should not be used directly. For reference: + * https://android.jlelse.eu/1bd08b4796b4. + */ +@BindingAdapter("data") +fun bindToRecyclerViewAdapter(recyclerView: RecyclerView, liveData: LiveData>) { + liveData.value?.let { data -> + val adapter = recyclerView.adapter + checkNotNull(adapter) { "Cannot bind data to a RecyclerView missing its adapter." } + check(adapter is BindableAdapter<*>) { "Can only bind data to a BindableAdapter." } + adapter.setDataUnchecked(data) + } +} From 5c34d168aed0c2c62484150ef3a8064caa2c5f8a Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Sat, 21 Sep 2019 16:28:05 -0700 Subject: [PATCH 02/14] First attempt at introducing tests for BindableAdapter. They aren't fully working yet. --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 1 + .../oppia/app/activity/ActivityComponent.kt | 2 + .../oppia/app/fragment/FragmentComponent.kt | 2 + .../oppia/app/fragment/InjectableFragment.kt | 2 +- .../java/org/oppia/app/home/HomeFragment.kt | 2 +- .../oppia/app/player/audio/AudioFragment.kt | 4 +- .../player/exploration/ExplorationFragment.kt | 2 +- .../oppia/app/player/state/StateFragment.kt | 2 +- .../oppia/app/recyclerview/BindableAdapter.kt | 6 +- .../RecyclerViewBindingAdapter.kt | 2 +- .../testing/BindableAdapterTestActivity.kt | 14 +++ .../testing/BindableAdapterTestFragment.kt | 27 ++++ .../BindableAdapterTestFragmentPresenter.kt | 44 +++++++ .../testing/BindableAdapterTestViewModel.kt | 13 ++ app/src/main/res/layout/test_fragment.xml | 16 +++ .../layout/test_text_view_no_data_binding.xml | 6 + .../app/recyclerview/BindableAdapterTest.kt | 117 ++++++++++++++++++ 18 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/oppia/app/testing/BindableAdapterTestActivity.kt create mode 100644 app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragment.kt create mode 100644 app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragmentPresenter.kt create mode 100644 app/src/main/java/org/oppia/app/testing/BindableAdapterTestViewModel.kt create mode 100644 app/src/main/res/layout/test_fragment.xml create mode 100644 app/src/main/res/layout/test_text_view_no_data_binding.xml create mode 100644 app/src/sharedTest/java/org/oppia/app/recyclerview/BindableAdapterTest.kt diff --git a/app/build.gradle b/app/build.gradle index 5a1823e94e8..882abd33cc1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,6 +12,7 @@ android { targetSdkVersion 28 versionCode 1 versionName "1.0" + multiDexEnabled true testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // https://developer.android.com/training/testing/junit-runner#ato-gradle testInstrumentationRunnerArguments clearPackageData: 'true' @@ -61,8 +62,10 @@ dependencies { 'androidx.constraintlayout:constraintlayout:1.1.3', 'androidx.core:core-ktx:1.0.2', 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha03', + 'androidx.multidex:multidex:2.0.1', 'androidx.recyclerview:recyclerview:1.0.0', 'com.google.dagger:dagger:2.24', + 'com.google.guava:guava:28.1-android', "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version", 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1', 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1', diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c632728593b..248ecd6113b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,5 +22,6 @@ + diff --git a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt index 7102ac5af84..a73c9912c1d 100644 --- a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt +++ b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt @@ -6,6 +6,7 @@ import dagger.Subcomponent import org.oppia.app.fragment.FragmentComponent import org.oppia.app.home.HomeActivity import org.oppia.app.player.exploration.ExplorationActivity +import org.oppia.app.testing.BindableAdapterTestActivity import javax.inject.Provider /** Root subcomponent for all activities. */ @@ -22,4 +23,5 @@ interface ActivityComponent { fun inject(explorationActivity: ExplorationActivity) fun inject(homeActivity: HomeActivity) + fun inject(bindableAdapterTestActivity: BindableAdapterTestActivity) } diff --git a/app/src/main/java/org/oppia/app/fragment/FragmentComponent.kt b/app/src/main/java/org/oppia/app/fragment/FragmentComponent.kt index 5392b95f3cc..7062123283c 100644 --- a/app/src/main/java/org/oppia/app/fragment/FragmentComponent.kt +++ b/app/src/main/java/org/oppia/app/fragment/FragmentComponent.kt @@ -7,6 +7,7 @@ import org.oppia.app.home.HomeFragment import org.oppia.app.player.exploration.ExplorationFragment import org.oppia.app.player.state.StateFragment import org.oppia.app.player.audio.AudioFragment +import org.oppia.app.testing.BindableAdapterTestFragment /** Root subcomponent for all fragments. */ @Subcomponent @@ -22,4 +23,5 @@ interface FragmentComponent { fun inject(explorationFragment: ExplorationFragment) fun inject(homeFragment: HomeFragment) fun inject(stateFragment: StateFragment) + fun inject(bindableAdapterTestFragment: BindableAdapterTestFragment) } diff --git a/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt b/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt index 25c3628f07a..8042a88d3cc 100644 --- a/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt +++ b/app/src/main/java/org/oppia/app/fragment/InjectableFragment.kt @@ -16,7 +16,7 @@ abstract class InjectableFragment: Fragment() { */ lateinit var fragmentComponent: FragmentComponent - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent = (requireActivity() as InjectableAppCompatActivity).createFragmentComponent(this) } diff --git a/app/src/main/java/org/oppia/app/home/HomeFragment.kt b/app/src/main/java/org/oppia/app/home/HomeFragment.kt index e3bc454853f..66a537947a4 100644 --- a/app/src/main/java/org/oppia/app/home/HomeFragment.kt +++ b/app/src/main/java/org/oppia/app/home/HomeFragment.kt @@ -12,7 +12,7 @@ import javax.inject.Inject class HomeFragment : InjectableFragment() { @Inject lateinit var homeFragmentController: HomeFragmentController - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/player/audio/AudioFragment.kt b/app/src/main/java/org/oppia/app/player/audio/AudioFragment.kt index 19fbb2b25d3..3b5155d7927 100755 --- a/app/src/main/java/org/oppia/app/player/audio/AudioFragment.kt +++ b/app/src/main/java/org/oppia/app/player/audio/AudioFragment.kt @@ -17,7 +17,7 @@ class AudioFragment : InjectableFragment() { lateinit var languageInterface: LanguageInterface - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } @@ -42,7 +42,7 @@ class AudioFragment : InjectableFragment() { getDummyAudioLanguageList(), "en" ) - dialogFragment.showNow(fragmentManager, TAG_DIALOG) + dialogFragment.showNow(fragmentManager!!, TAG_DIALOG) } private fun getDummyAudioLanguageList(): List { diff --git a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt index 337112eee6c..a60850d21cb 100755 --- a/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt +++ b/app/src/main/java/org/oppia/app/player/exploration/ExplorationFragment.kt @@ -12,7 +12,7 @@ import javax.inject.Inject class ExplorationFragment : InjectableFragment() { @Inject lateinit var explorationFragmentPresenter: ExplorationFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt index 20908b50513..b5b118f0238 100755 --- a/app/src/main/java/org/oppia/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/app/player/state/StateFragment.kt @@ -12,7 +12,7 @@ import javax.inject.Inject class StateFragment : InjectableFragment() { @Inject lateinit var stateFragmentPresenter: StateFragmentPresenter - override fun onAttach(context: Context?) { + override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent.inject(this) } diff --git a/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt b/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt index d665e9eb05a..d6ef7563e4a 100644 --- a/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt +++ b/app/src/main/java/org/oppia/app/recyclerview/BindableAdapter.kt @@ -27,7 +27,7 @@ private typealias ViewHolderFactory = (ViewGroup) -> BindableAdapter.Bindable class BindableAdapter internal constructor( private val computeViewType: ComputeViewType, private val viewHolderFactoryMap: Map>, - val dataClassType: KClass + private val dataClassType: KClass ) : RecyclerView.Adapter>() { private val dataList: MutableList = ArrayList() @@ -49,7 +49,7 @@ class BindableAdapter internal constructor( * adapter. This helps ensure type compatibility at runtime in cases where the generic type of the * adapter object is lost. */ - fun setDataUnchecked(newDataList: List) { + fun setDataUnchecked(newDataList: List) { // NB: This check only works if the list has any data in it. Since we can't use a reified type // here (due to Android data binding not supporting custom adapters with inline functions), this // method will succeed if types are different for empty lists (that is, List == List @@ -194,7 +194,7 @@ class BindableAdapter internal constructor( /** Returns a new [BindableAdapter]. */ fun build(): BindableAdapter { val computeViewType = this.computeViewType - check(viewHolderFactoryMap.isNotEmpty()) { "At least one view holder must be registered" } + check(viewHolderFactoryMap.isNotEmpty()) { "At least one view binder must be registered" } return BindableAdapter(computeViewType, viewHolderFactoryMap, dataClassType) } diff --git a/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt b/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt index 51b7e935975..8eb836fd255 100644 --- a/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt +++ b/app/src/main/java/org/oppia/app/recyclerview/RecyclerViewBindingAdapter.kt @@ -10,7 +10,7 @@ import androidx.recyclerview.widget.RecyclerView * https://android.jlelse.eu/1bd08b4796b4. */ @BindingAdapter("data") -fun bindToRecyclerViewAdapter(recyclerView: RecyclerView, liveData: LiveData>) { +fun bindToRecyclerViewAdapter(recyclerView: RecyclerView, liveData: LiveData>) { liveData.value?.let { data -> val adapter = recyclerView.adapter checkNotNull(adapter) { "Cannot bind data to a RecyclerView missing its adapter." } diff --git a/app/src/main/java/org/oppia/app/testing/BindableAdapterTestActivity.kt b/app/src/main/java/org/oppia/app/testing/BindableAdapterTestActivity.kt new file mode 100644 index 00000000000..bec31a709e4 --- /dev/null +++ b/app/src/main/java/org/oppia/app/testing/BindableAdapterTestActivity.kt @@ -0,0 +1,14 @@ +package org.oppia.app.testing + +import android.os.Bundle +import org.oppia.app.activity.InjectableAppCompatActivity + +// TODO(#59): Make this activity only included in relevant tests instead of all prod builds. +/** A test activity for the bindable RecyclerView adapter. */ +class BindableAdapterTestActivity: InjectableAppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + activityComponent.inject(this) + supportFragmentManager.beginTransaction().add(BindableAdapterTestFragment(), BINDABLE_TEST_FRAGMENT_TAG).commitNow() + } +} diff --git a/app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragment.kt b/app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragment.kt new file mode 100644 index 00000000000..0a9c477dc1a --- /dev/null +++ b/app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragment.kt @@ -0,0 +1,27 @@ +package org.oppia.app.testing + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.oppia.app.fragment.InjectableFragment +import javax.inject.Inject + +const val BINDABLE_TEST_FRAGMENT_TAG = "bindable_adapter_test_fragment" + +// TODO(#59): Make this fragment only included in relevant tests instead of all prod builds. +/** A test fragment for the bindable RecyclerView adapter. */ +class BindableAdapterTestFragment: InjectableFragment() { + @Inject + lateinit var bindableAdapterTestFragmentPresenter: BindableAdapterTestFragmentPresenter + + override fun onAttach(context: Context) { + super.onAttach(context) + fragmentComponent.inject(this) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return bindableAdapterTestFragmentPresenter.handleCreateView(inflater, container) + } +} diff --git a/app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragmentPresenter.kt b/app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragmentPresenter.kt new file mode 100644 index 00000000000..c066abadcb5 --- /dev/null +++ b/app/src/main/java/org/oppia/app/testing/BindableAdapterTestFragmentPresenter.kt @@ -0,0 +1,44 @@ +package org.oppia.app.testing + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.fragment.app.Fragment +import org.oppia.app.databinding.TestFragmentBinding +import org.oppia.app.recyclerview.BindableAdapter +import org.oppia.app.viewmodel.ViewModelProvider +import javax.inject.Inject + +/** The test-only fragment presenter corresponding to [BindableAdapterTestFragment]. */ +class BindableAdapterTestFragmentPresenter @Inject constructor( + private val fragment: Fragment, + private val viewModelProvider: ViewModelProvider +) { + @VisibleForTesting val viewModel: BindableAdapterTestViewModel by lazy { + getBindableAdapterTestViewModel() + } + + companion object { + // TODO(#59): Move away from this fragile static testing state by leveraging a test-only DI graph that can be + // configured within tests to provide the bindable adapter to be used by this presenter. + var testBindableAdapter: BindableAdapter? = null + } + + fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? { + val binding = TestFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) + checkNotNull(testBindableAdapter) { "Expected a bindable adapter to be provided in a test module" } + binding.testRecyclerView.apply { + adapter = testBindableAdapter + } + binding.let { + it.viewModel = viewModel + it.lifecycleOwner = fragment + } + return binding.root + } + + private fun getBindableAdapterTestViewModel(): BindableAdapterTestViewModel { + return viewModelProvider.getForFragment(fragment, BindableAdapterTestViewModel::class.java) + } +} diff --git a/app/src/main/java/org/oppia/app/testing/BindableAdapterTestViewModel.kt b/app/src/main/java/org/oppia/app/testing/BindableAdapterTestViewModel.kt new file mode 100644 index 00000000000..3f695c4b2c6 --- /dev/null +++ b/app/src/main/java/org/oppia/app/testing/BindableAdapterTestViewModel.kt @@ -0,0 +1,13 @@ +package org.oppia.app.testing + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import org.oppia.app.fragment.FragmentScope +import javax.inject.Inject + +// TODO(#59): Make this view model only included in relevant tests instead of all prod builds. +/** A [ViewModel] for testing the bindable RecyclerView adapter. */ +@FragmentScope +class BindableAdapterTestViewModel @Inject constructor(): ViewModel() { + val dataListLiveData = MutableLiveData>() +} diff --git a/app/src/main/res/layout/test_fragment.xml b/app/src/main/res/layout/test_fragment.xml new file mode 100644 index 00000000000..b2b1e67b68d --- /dev/null +++ b/app/src/main/res/layout/test_fragment.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/test_text_view_no_data_binding.xml b/app/src/main/res/layout/test_text_view_no_data_binding.xml new file mode 100644 index 00000000000..c5de925f746 --- /dev/null +++ b/app/src/main/res/layout/test_text_view_no_data_binding.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/sharedTest/java/org/oppia/app/recyclerview/BindableAdapterTest.kt b/app/src/sharedTest/java/org/oppia/app/recyclerview/BindableAdapterTest.kt new file mode 100644 index 00000000000..948022aa381 --- /dev/null +++ b/app/src/sharedTest/java/org/oppia/app/recyclerview/BindableAdapterTest.kt @@ -0,0 +1,117 @@ +package org.oppia.app.recyclerview + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.lifecycle.MutableLiveData +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onIdle +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.app.R +import org.oppia.app.testing.BINDABLE_TEST_FRAGMENT_TAG +import org.oppia.app.testing.BindableAdapterTestActivity +import org.oppia.app.testing.BindableAdapterTestFragment +import org.oppia.app.testing.BindableAdapterTestFragmentPresenter +import org.oppia.app.testing.BindableAdapterTestViewModel + +const val ITEM_0 = "Item 0" +const val ITEM_1 = "Item 1" + +/** Tests for [BindableAdapter]. */ +@RunWith(AndroidJUnit4::class) +class BindableAdapterTest { + @Before + fun setUp() { + // Ensure that the bindable fragment's test state is properly reset each time. + BindableAdapterTestFragmentPresenter.testBindableAdapter = null + } + @After + fun tearDown() { + // Ensure that the bindable fragment's test state is properly cleaned up. + BindableAdapterTestFragmentPresenter.testBindableAdapter = null + } + + @Test + fun testBindableAdapter_withOneViewType_noData_bindsNoViews() { + // Set up the adapter to be used for this test. + BindableAdapterTestFragmentPresenter.testBindableAdapter = + createSingleViewTypeNoDataBindingBindableAdapter() + + ActivityScenario.launch(BindableAdapterTestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val recyclerView: RecyclerView = getTestFragment(activity).view!!.findViewById(R.id.test_recycler_view) + + assertThat(recyclerView.childCount).isEqualTo(0) + } + } + } + + @Test + fun testBindableAdapter_withOneViewType_setItem_automaticallyBindsView() { + // Set up the adapter to be used for this test. + BindableAdapterTestFragmentPresenter.testBindableAdapter = + createSingleViewTypeNoDataBindingBindableAdapter() + + ActivityScenario.launch(BindableAdapterTestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val liveData = getRecyclerViewListLiveData(activity) + liveData.value = listOf(ITEM_0) + onIdle() + + val recyclerView: RecyclerView = getTestFragment(activity).view!!.findViewById(R.id.test_recycler_view) + assertThat(recyclerView.childCount).isEqualTo(1) +// onView(withId(R.id.text_view_no_data_binding)).check(matches(withText(ITEM_0))) + } + } + } + + // testBindableAdapter_withOneViewType_setMultipleItems_automaticallyBinds + // testBindableAdapter_withTwoViewTypes_setItems_autoBindsCorrectItemsPerTypes + // testBindableAdapter_withTwoViewTypes_setItems_autoBindsCorrectItemsPerTypes + + private fun createSingleViewTypeNoDataBindingBindableAdapter(): BindableAdapter { + return BindableAdapter.Builder + .newBuilder() + .registerViewBinder( + inflateView = this::inflateTextViewWithoutDataBinding, + bindView = this::bindTextViewWithoutDataBinding + ) + .build() + } + + private fun inflateTextViewWithoutDataBinding(viewGroup: ViewGroup): TextView { + val inflater = LayoutInflater.from(ApplicationProvider.getApplicationContext()) + return inflater.inflate(R.layout.test_text_view_no_data_binding, viewGroup, /* attachToRoot= */ false) as TextView + } + + private fun bindTextViewWithoutDataBinding(textView: TextView, data: String) { + textView.text = data + } + + private fun getRecyclerViewListLiveData(activity: BindableAdapterTestActivity): MutableLiveData> { + return getTestViewModel(activity).dataListLiveData + } + + private fun getTestViewModel(activity: BindableAdapterTestActivity): BindableAdapterTestViewModel { + return getTestFragmentPresenter(activity).viewModel + } + + private fun getTestFragmentPresenter(activity: BindableAdapterTestActivity): BindableAdapterTestFragmentPresenter { + return getTestFragment(activity).bindableAdapterTestFragmentPresenter + } + + private fun getTestFragment(activity: BindableAdapterTestActivity): BindableAdapterTestFragment { + return activity.supportFragmentManager.findFragmentByTag(BINDABLE_TEST_FRAGMENT_TAG) as BindableAdapterTestFragment + } +} From 9a89dee9bb68594bf0cd961712c0ced68be2ce66 Mon Sep 17 00:00:00 2001 From: James Xu Date: Fri, 27 Sep 2019 17:39:58 -0700 Subject: [PATCH 03/14] Updated view model --- .../topic/conceptcard/ConceptCardFragment.kt | 4 +- .../topic/conceptcard/ConceptCardPresenter.kt | 8 ++- .../topic/conceptcard/ConceptCardViewModel.kt | 58 ++++++++++++++++++- .../res/layout/concept_card_example_view.xml | 20 +++++++ .../main/res/layout/conceptcard_fragment.xml | 6 +- 5 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 app/src/main/res/layout/concept_card_example_view.xml diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt index f48309853fd..2d1ca49ac08 100644 --- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardFragment.kt @@ -10,7 +10,7 @@ import org.oppia.app.fragment.InjectableDialogFragment import javax.inject.Inject /* Fragment that displays a fullscreen dialog for concept cards */ -class ConceptCardFragment : InjectableDialogFragment() { +class ConceptCardFragment(private val skillId: String) : InjectableDialogFragment() { @Inject lateinit var conceptCardPresenter: ConceptCardPresenter override fun onAttach(context: Context?) { @@ -25,7 +25,7 @@ class ConceptCardFragment : InjectableDialogFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { super.onCreateView(inflater, container, savedInstanceState) - return conceptCardPresenter.handleCreateView(inflater, container) + return conceptCardPresenter.handleCreateView(inflater, container, skillId) } override fun onStart() { diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt index a3487a857a0..7851b8eb7d4 100644 --- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt @@ -9,6 +9,7 @@ import org.oppia.app.R import org.oppia.app.databinding.ConceptcardFragmentBinding import org.oppia.app.fragment.FragmentScope import org.oppia.app.viewmodel.ViewModelProvider +import org.oppia.domain.topic.TEST_SKILL_ID_0 import javax.inject.Inject /** Presenter for [ConceptCardFragment], sets up bindings from ViewModel */ @@ -17,17 +18,18 @@ class ConceptCardPresenter @Inject constructor( private val fragment: Fragment, private val viewModelProvider: ViewModelProvider ){ - fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? { + fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?, skillId: String): View? { + val viewModel = getConceptCardViewModel() + viewModel.setSkillId(skillId) val binding = ConceptcardFragmentBinding.inflate(inflater, container, /* attachToRoot= */ false) binding.conceptCardToolbar.setNavigationIcon(R.drawable.ic_close_white_24dp) binding.conceptCardToolbar.setNavigationOnClickListener { (fragment as? DialogFragment)?.dismiss() } binding.let { - it.viewModel = getConceptCardViewModel() + it.viewModel = viewModel it.lifecycleOwner = fragment } - return binding.root } diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardViewModel.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardViewModel.kt index 8362ea37b6e..d424d7c535e 100644 --- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardViewModel.kt +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardViewModel.kt @@ -1,11 +1,63 @@ package org.oppia.app.topic.conceptcard +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel +import kotlinx.coroutines.processNextEventInCurrentThread import org.oppia.app.fragment.FragmentScope +import org.oppia.app.model.ConceptCard +import org.oppia.app.model.SubtitledHtml +import org.oppia.domain.topic.TEST_SKILL_ID_0 +import org.oppia.domain.topic.TopicController +import org.oppia.util.data.AsyncResult +import org.oppia.util.logging.Logger import javax.inject.Inject /** [ViewModel] for concept card, providing rich text and worked examples */ @FragmentScope -class ConceptCardViewModel @Inject constructor() : ViewModel() { - fun getDummyText() = "hello world" -} \ No newline at end of file +class ConceptCardViewModel @Inject constructor( + private val topicController: TopicController, + private val logger: Logger +) : ViewModel() { + + private lateinit var skillId: String + + val conceptCardLiveData: LiveData by lazy { + processConceptCardLiveData() + } + + val workedExamplesLiveData: LiveData> by lazy { + processWorkedExamplesLiveData() + } + + /** Sets the value of skillId. Must be called before setting ViewModel to binding. */ + fun setSkillId(id: String) { + skillId = id + } + + private val conceptCardResultLiveData: LiveData> by lazy { + topicController.getConceptCard(skillId) + } + + private fun processConceptCardLiveData(): LiveData { + return Transformations.map(conceptCardResultLiveData, ::processConceptCardResult) + } + + private fun processWorkedExamplesLiveData(): LiveData> { + return Transformations.map(conceptCardResultLiveData, ::processConceptCardWorkExamples) + } + + private fun processConceptCardResult(conceptCardResult: AsyncResult): ConceptCard { + if (conceptCardResult.isFailure()) { + logger.e("ConceptCardFragment", "Failed to retrieve Concept Card: " + conceptCardResult.getErrorOrNull()) + } + return conceptCardResult.getOrDefault(ConceptCard.getDefaultInstance()) + } + + private fun processConceptCardWorkExamples(conceptCardResult: AsyncResult): List { + if (conceptCardResult.isFailure()) { + logger.e("ConceptCardFragment", "Failed to retrieve Concept Card: " + conceptCardResult.getErrorOrNull()) + } + return conceptCardResult.getOrDefault(ConceptCard.getDefaultInstance()).workedExampleList + } +} diff --git a/app/src/main/res/layout/concept_card_example_view.xml b/app/src/main/res/layout/concept_card_example_view.xml new file mode 100644 index 00000000000..0a9c797e5fd --- /dev/null +++ b/app/src/main/res/layout/concept_card_example_view.xml @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conceptcard_fragment.xml b/app/src/main/res/layout/conceptcard_fragment.xml index aefaca6f1c2..f30db1cb96a 100644 --- a/app/src/main/res/layout/conceptcard_fragment.xml +++ b/app/src/main/res/layout/conceptcard_fragment.xml @@ -3,6 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> + @@ -29,11 +30,12 @@ app:layout_constraintRight_toRightOf="parent" app:layout_constraintBottom_toBottomOf="parent"> + android:visibility="@{viewModel.conceptCardLiveData.hasExplanation() ? View.VISIBLE : View.GONE}" + android:text="@{viewModel.conceptCardLiveData.explanation.getHtml()}" /> From 601f4b694e6dd4a4dd30ad869ca78f278c94ecc9 Mon Sep 17 00:00:00 2001 From: James Xu Date: Sat, 28 Sep 2019 12:25:01 -0700 Subject: [PATCH 04/14] Added bindable recycler view and test activity --- app/src/main/AndroidManifest.xml | 1 + .../oppia/app/activity/ActivityComponent.kt | 2 ++ .../topic/conceptcard/ConceptCardPresenter.kt | 21 +++++++++++++++ .../ConceptCardFragmentTestActivity.kt | 16 +++++++++++ ...nceptCardFragmentTestActivityController.kt | 20 ++++++++++++++ .../res/layout/concept_card_example_view.xml | 12 +++++---- .../concept_card_fragment_test_activity.xml | 15 +++++++++++ .../main/res/layout/conceptcard_fragment.xml | 27 ++++++++++++------- .../conceptcard/ConceptCardFragmentTest.kt | 7 ++--- 9 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivity.kt create mode 100644 app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivityController.kt create mode 100644 app/src/main/res/layout/concept_card_fragment_test_activity.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0958158687b..f99e5ddc885 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ android:supportsRtl="true" android:theme="@style/OppiaTheme"> + diff --git a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt index 7102ac5af84..882f9655678 100644 --- a/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt +++ b/app/src/main/java/org/oppia/app/activity/ActivityComponent.kt @@ -6,6 +6,7 @@ import dagger.Subcomponent import org.oppia.app.fragment.FragmentComponent import org.oppia.app.home.HomeActivity import org.oppia.app.player.exploration.ExplorationActivity +import org.oppia.app.topic.conceptcard.testing.ConceptCardFragmentTestActivity import javax.inject.Provider /** Root subcomponent for all activities. */ @@ -22,4 +23,5 @@ interface ActivityComponent { fun inject(explorationActivity: ExplorationActivity) fun inject(homeActivity: HomeActivity) + fun inject(conceptCardFragmentTestActivity: ConceptCardFragmentTestActivity) } diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt index 7851b8eb7d4..70749841511 100644 --- a/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/ConceptCardPresenter.kt @@ -1,13 +1,19 @@ package org.oppia.app.topic.conceptcard +import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager import org.oppia.app.R +import org.oppia.app.application.ApplicationContext +import org.oppia.app.databinding.ConceptCardExampleViewBinding import org.oppia.app.databinding.ConceptcardFragmentBinding import org.oppia.app.fragment.FragmentScope +import org.oppia.app.model.SubtitledHtml +import org.oppia.app.recyclerview.BindableAdapter import org.oppia.app.viewmodel.ViewModelProvider import org.oppia.domain.topic.TEST_SKILL_ID_0 import javax.inject.Inject @@ -15,6 +21,7 @@ import javax.inject.Inject /** Presenter for [ConceptCardFragment], sets up bindings from ViewModel */ @FragmentScope class ConceptCardPresenter @Inject constructor( + @ApplicationContext private val context: Context, private val fragment: Fragment, private val viewModelProvider: ViewModelProvider ){ @@ -26,6 +33,11 @@ class ConceptCardPresenter @Inject constructor( binding.conceptCardToolbar.setNavigationOnClickListener { (fragment as? DialogFragment)?.dismiss() } + binding.workedExamples.apply { + adapter = createRecyclerViewAdapter() + layoutManager = LinearLayoutManager(context) + } + binding.let { it.viewModel = viewModel it.lifecycleOwner = fragment @@ -36,4 +48,13 @@ class ConceptCardPresenter @Inject constructor( private fun getConceptCardViewModel(): ConceptCardViewModel { return viewModelProvider.getForFragment(fragment, ConceptCardViewModel::class.java) } + + private fun createRecyclerViewAdapter(): BindableAdapter { + return BindableAdapter.Builder + .newBuilder() + .registerViewDataBinder( + inflateDataBinding = ConceptCardExampleViewBinding::inflate, + setViewModel = ConceptCardExampleViewBinding::setSubtitledHtml) + .build() + } } diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivity.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivity.kt new file mode 100644 index 00000000000..1fadbcde619 --- /dev/null +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivity.kt @@ -0,0 +1,16 @@ +package org.oppia.app.topic.conceptcard.testing + +import android.os.Bundle +import org.oppia.app.activity.InjectableAppCompatActivity +import javax.inject.Inject + +class ConceptCardFragmentTestActivity : InjectableAppCompatActivity() { + + @Inject lateinit var conceptCardFragmentTestActivityController: ConceptCardFragmentTestActivityController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + activityComponent.inject(this) + conceptCardFragmentTestActivityController.handleOnCreate() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivityController.kt b/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivityController.kt new file mode 100644 index 00000000000..e27dc61f65b --- /dev/null +++ b/app/src/main/java/org/oppia/app/topic/conceptcard/testing/ConceptCardFragmentTestActivityController.kt @@ -0,0 +1,20 @@ +package org.oppia.app.topic.conceptcard.testing + +import androidx.appcompat.app.AppCompatActivity +import kotlinx.android.synthetic.main.concept_card_fragment_test_activity.* +import org.oppia.app.R +import org.oppia.app.topic.conceptcard.ConceptCardFragment +import org.oppia.domain.topic.TEST_SKILL_ID_2 +import javax.inject.Inject + +private const val TAG_CONCEPT_CARD_DIALOG = "CONCEPT_CARD_DIALOG" + +class ConceptCardFragmentTestActivityController @Inject constructor(private val activity: AppCompatActivity) { + fun handleOnCreate() { + activity.setContentView(R.layout.concept_card_fragment_test_activity) + activity.open_dialog.setOnClickListener { + val frag = ConceptCardFragment(TEST_SKILL_ID_2) + frag.showNow(activity.supportFragmentManager, TAG_CONCEPT_CARD_DIALOG) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/concept_card_example_view.xml b/app/src/main/res/layout/concept_card_example_view.xml index 0a9c797e5fd..0fae774ed55 100644 --- a/app/src/main/res/layout/concept_card_example_view.xml +++ b/app/src/main/res/layout/concept_card_example_view.xml @@ -4,17 +4,19 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> + name="subtitledHtml" + type="org.oppia.app.model.SubtitledHtml" /> + android:layout_width="match_parent" + android:layout_height="wrap_content"> + android:layout_height="wrap_content" + android:textColor="@android:color/black" + android:text="@{subtitledHtml.getHtml()}"/> \ No newline at end of file diff --git a/app/src/main/res/layout/concept_card_fragment_test_activity.xml b/app/src/main/res/layout/concept_card_fragment_test_activity.xml new file mode 100644 index 00000000000..63a60b277ce --- /dev/null +++ b/app/src/main/res/layout/concept_card_fragment_test_activity.xml @@ -0,0 +1,15 @@ + + +