diff --git a/app/build.gradle b/app/build.gradle index 4fde86f..de63b4a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,6 +3,7 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlinx-serialization' apply plugin: 'kotlin-kapt' +apply plugin: "androidx.navigation.safeargs.kotlin" android { compileSdkVersion 29 diff --git a/app/src/main/java/com/sfjava/tunesfeed/ViewModelFactory.kt b/app/src/main/java/com/sfjava/tunesfeed/ViewModelFactory.kt index 46adc82..15b9e9b 100644 --- a/app/src/main/java/com/sfjava/tunesfeed/ViewModelFactory.kt +++ b/app/src/main/java/com/sfjava/tunesfeed/ViewModelFactory.kt @@ -6,7 +6,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.savedstate.SavedStateRegistryOwner import com.sfjava.tunesfeed.data.source.FeedItemsRepository -import com.sfjava.tunesfeed.ui.feeds.FeedListViewModel +import com.sfjava.tunesfeed.ui.feeditem.FeedItemViewModel +import com.sfjava.tunesfeed.ui.feedlist.FeedListViewModel /** * Factory for all ViewModels. @@ -26,6 +27,8 @@ class ViewModelFactory constructor( when { isAssignableFrom(FeedListViewModel::class.java) -> FeedListViewModel(itemsRepository) //, handle) // FIXME: handle state persistence? +// isAssignableFrom(FeedItemViewModel::class.java) -> +// FeedItemViewModel(feedListViewModel) //, handle) // FIXME: handle state persistence? else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") } diff --git a/app/src/main/java/com/sfjava/tunesfeed/ui/Event.kt b/app/src/main/java/com/sfjava/tunesfeed/ui/Event.kt new file mode 100644 index 0000000..7adc135 --- /dev/null +++ b/app/src/main/java/com/sfjava/tunesfeed/ui/Event.kt @@ -0,0 +1,44 @@ +package com.sfjava.tunesfeed.ui + +import androidx.lifecycle.Observer + +/** + * Used as a wrapper for data that is exposed via a LiveData that represents an event. + */ +open class Event(private val content: T) { + + @Suppress("MemberVisibilityCanBePrivate") + var hasBeenHandled = false + private set // allow external read but not write + + /** + * Returns the content and prevents its use again. + */ + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + /** + * Returns the content, even if it's already been handled. + */ + fun peekContent(): T = content +} + +/** + * An [Observer] for [Event]s, simplifying the pattern of checking if the [Event]'s content has + * already been handled. + * + * [onEventUnhandledContent] is *only* called if the [Event]'s contents has not been handled. + */ +class EventObserver(private val onEventUnhandledContent: (T) -> Unit) : Observer> { + override fun onChanged(event: Event?) { + event?.getContentIfNotHandled()?.let { + onEventUnhandledContent(it) + } + } +} diff --git a/app/src/main/java/com/sfjava/tunesfeed/ui/feeditem/FeedItemDetailFragment.kt b/app/src/main/java/com/sfjava/tunesfeed/ui/feeditem/FeedItemDetailFragment.kt new file mode 100644 index 0000000..a207b3e --- /dev/null +++ b/app/src/main/java/com/sfjava/tunesfeed/ui/feeditem/FeedItemDetailFragment.kt @@ -0,0 +1,39 @@ +package com.sfjava.tunesfeed.ui.feeditem + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.sfjava.tunesfeed.data.model.FeedType +import com.sfjava.tunesfeed.databinding.FeedItemDetailFragmentBinding +import com.sfjava.tunesfeed.ui.feeditem.FeedItemDetailFragmentArgs.Companion.fromBundle +import com.sfjava.tunesfeed.ui.getViewModelFactory + +class FeedItemDetailFragment(): Fragment() { + + private val itemId by lazy { + arguments?.let { fromBundle(it).itemId } ?: throw IllegalArgumentException("Expected arguments") + } + + private val feedItemViewModel + by viewModels { getViewModelFactory(FeedType.ComingSoon) } // FIXME + + private lateinit var viewDataBinding: FeedItemDetailFragmentBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewDataBinding = FeedItemDetailFragmentBinding.inflate(inflater, container, false).apply { + viewmodel = feedItemViewModel + } + return viewDataBinding.root + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/sfjava/tunesfeed/ui/feeditem/FeedItemViewModel.kt b/app/src/main/java/com/sfjava/tunesfeed/ui/feeditem/FeedItemViewModel.kt new file mode 100644 index 0000000..b830074 --- /dev/null +++ b/app/src/main/java/com/sfjava/tunesfeed/ui/feeditem/FeedItemViewModel.kt @@ -0,0 +1,15 @@ +package com.sfjava.tunesfeed.ui.feeditem + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.sfjava.tunesfeed.data.model.FeedItem +import com.sfjava.tunesfeed.ui.feedlist.FeedListViewModel + +class FeedItemViewModel(val feedListViewModel: FeedListViewModel): ViewModel() { + + private val _item = MutableLiveData() + val item: LiveData = _item + + fun getItem(id: String) = feedListViewModel.getItem(id) +} \ No newline at end of file diff --git a/app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListAdapter.kt b/app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListAdapter.kt similarity index 92% rename from app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListAdapter.kt rename to app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListAdapter.kt index 36bd19c..8a928d9 100644 --- a/app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListAdapter.kt +++ b/app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListAdapter.kt @@ -1,4 +1,4 @@ -package com.sfjava.tunesfeed.ui.feeds +package com.sfjava.tunesfeed.ui.feedlist import android.view.LayoutInflater import android.view.ViewGroup @@ -24,7 +24,7 @@ class FeedListAdapter(private val viewModel: FeedListViewModel) : RecyclerView.ViewHolder(binding.root) { fun bind(viewModel: FeedListViewModel, item: FeedItem) { - // binding.viewmodel = viewModel // NOTE: plumb-through if we need to ref the list-model + binding.feedListViewModel = viewModel // NOTE: need to ref this for item on-click action binding.item = item binding.executePendingBindings() } diff --git a/app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListBindings.kt b/app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListBindings.kt similarity index 94% rename from app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListBindings.kt rename to app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListBindings.kt index 72cccc6..ca5698b 100644 --- a/app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListBindings.kt +++ b/app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListBindings.kt @@ -1,4 +1,4 @@ -package com.sfjava.tunesfeed.ui.feeds +package com.sfjava.tunesfeed.ui.feedlist import android.widget.ImageView import androidx.databinding.BindingAdapter diff --git a/app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListFragment.kt b/app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListFragment.kt similarity index 64% rename from app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListFragment.kt rename to app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListFragment.kt index f8b413a..14f9bfa 100644 --- a/app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListFragment.kt +++ b/app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListFragment.kt @@ -1,4 +1,4 @@ -package com.sfjava.tunesfeed.ui.feeds +package com.sfjava.tunesfeed.ui.feedlist import android.os.Bundle import android.util.Log @@ -7,8 +7,10 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController import com.sfjava.tunesfeed.data.model.FeedType -import com.sfjava.tunesfeed.databinding.FragmentFeedListBinding +import com.sfjava.tunesfeed.databinding.FeedListFragmentBinding +import com.sfjava.tunesfeed.ui.EventObserver import com.sfjava.tunesfeed.ui.getViewModelFactory class FeedListFragment : Fragment() { @@ -16,7 +18,7 @@ class FeedListFragment : Fragment() { private val feedListViewModel by viewModels { getViewModelFactory(arguments?.get("feedType") as FeedType) } - private lateinit var viewDataBinding: FragmentFeedListBinding + private lateinit var viewDataBinding: FeedListFragmentBinding private lateinit var listAdapter: FeedListAdapter override fun onCreateView( @@ -24,7 +26,7 @@ class FeedListFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View? { - viewDataBinding = FragmentFeedListBinding.inflate(inflater, container, false).apply { + viewDataBinding = FeedListFragmentBinding.inflate(inflater, container, false).apply { viewmodel = feedListViewModel } return viewDataBinding.root @@ -33,7 +35,7 @@ class FeedListFragment : Fragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) - viewDataBinding.lifecycleOwner = this.viewLifecycleOwner + viewDataBinding.lifecycleOwner = viewLifecycleOwner val viewModel = viewDataBinding.viewmodel if (viewModel != null) { listAdapter = FeedListAdapter(viewModel) @@ -41,5 +43,14 @@ class FeedListFragment : Fragment() { } else { Log.w("TunesFeed", "ViewModel not initialized when attempting to set up adapter.") } + + viewModel?.showItemDetailEvent?.observe(viewLifecycleOwner, EventObserver { + showItemDetail(it) + }) + } + + private fun showItemDetail(id: String) { + val action = FeedListFragmentDirections.actionFeedListFragmentToFeedItemDetailFragment(id) + findNavController().navigate(action) } } diff --git a/app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListViewModel.kt b/app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListViewModel.kt similarity index 81% rename from app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListViewModel.kt rename to app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListViewModel.kt index 7a9a06a..22a4a83 100644 --- a/app/src/main/java/com/sfjava/tunesfeed/ui/feeds/FeedListViewModel.kt +++ b/app/src/main/java/com/sfjava/tunesfeed/ui/feedlist/FeedListViewModel.kt @@ -1,9 +1,10 @@ -package com.sfjava.tunesfeed.ui.feeds +package com.sfjava.tunesfeed.ui.feedlist import androidx.lifecycle.* import com.sfjava.tunesfeed.data.model.FeedItem import com.sfjava.tunesfeed.data.source.FeedItemsRepository import com.sfjava.tunesfeed.data.source.Result +import com.sfjava.tunesfeed.ui.Event import kotlinx.coroutines.launch class FeedListViewModel(val itemsRepository: FeedItemsRepository) : ViewModel() { @@ -22,6 +23,9 @@ class FeedListViewModel(val itemsRepository: FeedItemsRepository) : ViewModel() } val items: LiveData> = _items + private val _showItemDetailEvent = MutableLiveData>() + val showItemDetailEvent: LiveData> = _showItemDetailEvent + private val _dataLoading = MutableLiveData() val dataLoading: LiveData = _dataLoading @@ -39,6 +43,12 @@ class FeedListViewModel(val itemsRepository: FeedItemsRepository) : ViewModel() _forceUpdate.value = forceUpdate } + fun getItem(id: String): FeedItem? = _items.value?.filter { it.id == id }?.firstOrNull() + + fun showItemDetail(id: String) { + _showItemDetailEvent.value = Event(id) + } + private fun filterItems(itemsResult: Result>): LiveData> { // TODO: this is a good case for liveData builder; replace when stable (per google's sample) val result = MutableLiveData>() diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index d504aa5..07cba95 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -27,6 +27,6 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" - app:navGraph="@navigation/mobile_navigation" /> + app:navGraph="@navigation/nav_graph" /> \ No newline at end of file diff --git a/app/src/main/res/layout/feed_item_detail_fragment.xml b/app/src/main/res/layout/feed_item_detail_fragment.xml new file mode 100644 index 0000000..3ebb133 --- /dev/null +++ b/app/src/main/res/layout/feed_item_detail_fragment.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_feed_list.xml b/app/src/main/res/layout/feed_list_fragment.xml similarity index 96% rename from app/src/main/res/layout/fragment_feed_list.xml rename to app/src/main/res/layout/feed_list_fragment.xml index 429f1f2..4786ec9 100644 --- a/app/src/main/res/layout/fragment_feed_list.xml +++ b/app/src/main/res/layout/feed_list_fragment.xml @@ -6,7 +6,7 @@ + type="com.sfjava.tunesfeed.ui.feedlist.FeedListViewModel" /> + + - + android:paddingBottom="4dp" + android:onClick="@{() -> feedListViewModel.showItemDetail(item.id)}"> + tools:layout="@layout/feed_list_fragment"> + + tools:layout="@layout/feed_list_fragment"> + tools:layout="@layout/feed_list_fragment"> + tools:layout="@layout/feed_list_fragment"> + + + + diff --git a/build.gradle b/build.gradle index 9d58ccf..33fd3e8 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ buildscript { ext.kotlin_version = '1.3.61' ext.serialization_version = '0.14.0' + ext.navigationVersion = '2.2.0-rc02' repositories { google() @@ -12,6 +13,8 @@ buildscript { classpath 'com.android.tools.build:gradle:3.6.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigationVersion" + } } @@ -43,7 +46,6 @@ ext { androidXLegacySupport = '1.0.0' appCompatVersion = '1.1.0' archLifecycleVersion = '2.2.0-rc02' - navigationVersion = '2.2.0-rc02' archTestingVersion = '2.1.0' cardVersion = '1.0.0' coroutinesVersion = '1.2.1'