Using data bindings in Fragments - android

I have a problem using viewmodel in fragment with data binding.
In my programm i'm using only one activity and fragments. I organized transitions between fragments using navigation. And i want to use data binding in my fragment, but when running the application only with binding, everything works well when I pass to the binding viewmodel when switching to this fragment of the application, it crashes.
I would be very grateful for your help!
My fragment
class TimeSettings : Fragment() {
private lateinit var viewModel: TimeSettingsViewModel
private lateinit var binding: FragmentTimeSettingsBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentTimeSettingsBinding.inflate(inflater, container, false)
binding.lifecycleOwner = this
binding.viewmodel = viewModel;
return binding.root
}
}
Object for viewmodel
object TimeData {
var inhale: Int = 4
private val _currentTimeInhale = MutableLiveData<Int>()
val currentTimeInhale: LiveData<Int>
get() = _currentTimeInhale
init{
_currentTimeInhale.value = inhale
}
fun changeCurrentTimeInhale(){
_currentTimeInhale.value = 5
}
}
Viewmodel
class TimeSettingsViewModel : ViewModel() {
val currentInhale: LiveData<Int>
get() = TimeData.currentTimeInhale
fun onChange() = TimeData.changeCurrentTimeInhale()
}
xml file for fragment
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewmodel"
type="ivakin.first.my_first_app.viewmodels.TimeSettingsViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="#+id/textView"
android:layout_width="95dp"
android:layout_height="59dp"
android:text="#{viewmodel.currentInhale}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.452" />
<Button
android:id="#+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Set"
android:onClick="#{()->viewmodel.onChange()"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/textView" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

U Forgot Initialize ViewModel :
viewmodel = TimeSettingsViewModel()
Also dont use return binding.root

Related

Persist states after viewModelLifecyle till the app get closed

I'm a beginner android developer and I got stuck with an issue.
viewModel keeps the states change data related to a fragment and when the user navigate to some fragment then the viewModel no longer keeps the states.
What I want is that when the user change something in the fragment and after that navigate to other fragment and then come back to the fragment where he changes something, he should see the changes he made.
Solutions are there for storing ex. shared_preference & Room, but my requirement is that I only want to keep the state data alive until the use closed the App.
I've googling so far but still didn't understand the approach developers follow to do these things. So can you also provide some guidance on approaches I should follow that consider the best practices. My main motive is to learn.
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="homeViewModel"
type="com.android.example.presentation.home.HomeViewModel" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".presentation.home.HomeFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:keepScreenOn="true">
<TextView
android:id="#+id/sampleText"
android:text="#{String.valueOf(homeViewModel.sampleText)}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.5" />
<Button
android:id="#+id/sampleButton"
android:onClick="#{() -> homeViewModel.samplePressed()}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="Button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/sampleText" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</layout>
class HomeViewModel : ViewModel() {
private val _sampleText = MutableLiveData(0)
val sampleText : LiveData<Int>
get() = _sampleText
fun samplePressed() {
_sampleText.postValue(1)
}
}
Thanks to the comment from Vivek Gupta
This can be achieved by scoping the ViewModel to the activity's lifecycle
previous
class HomeFragment : Fragment() {
private val viewModel: HomeViewModel by viewModels()
}
Now change it this
class HomeFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels()
}
Edit: another way to do this if we have viewModelFactory
class HomeFragment : Fragment() {
private lateinit var binding: FragmentHomeBinding
private lateinit var mainRepository: MainRepository
private lateinit var viewModel: HomeViewModel
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) : View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_home, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mainRepository = MainRepository(RetrofitInstance.api)
val viewModelFactory = HomeViewModelFactory(mainRepository)
viewModel = ViewModelProvider(requireActivity(), viewModelFactory).get(HomeViewModel::class.java)
binding.homeViewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner
}
}

Room livedata , databinding issue : UI doesn't update when data changes

I am now developing an application where I use MVVM pattern for UI and repository interaction. In other words, I receive live data object with a list of models from my ROOM data base query via repository, then assign it to my live data variable in viewmodel. After that, this data should be populated to my xml layout recycler view via data binding, but It happens only once fragment is initialised. In other cases recycler view is void
DAO code :
#Query("SELECT * FROM note WHERE content LIKE :query ORDER BY isStarred")
fun searchAllNotes(query : String?) : Flow<List<Note>>
ViewModel code:
#HiltViewModel
class HomeViewModel #Inject constructor (
private val noteRepository : NoteRepository
) : ViewModel() {
private var _notesLiveData : LiveData<List<Note>> = noteRepository.getAllNotes().asLiveData()
val notesLiveData get()= _notesLiveData
fun searchNotes(query : String){
viewModelScope.launch(Dispatchers.IO){
_notesLiveData = noteRepository.searchAllNotes(query).asLiveData()
}
}
fun deleteNote(note : Note){
viewModelScope.launch(Dispatchers.IO){
noteRepository.deleteNote(note)
}
}
fun updateNoteChecked(note : Note){
viewModelScope.launch(Dispatchers.IO){
noteRepository.updateNoteChecked(note.id, note.isStarred)
}
}
Fragment code
#AndroidEntryPoint
class HomeFragment : Fragment(),
NoteCardAdapter.NoteTouchListener,
SearchView.OnQueryTextListener{
private var _binding : FragmentHomeBinding? = null
val binding get() = _binding!!
private val adapter by lazy {
NoteCardAdapter(this as NoteCardAdapter.NoteTouchListener)
}
private val vm : HomeViewModel by viewModels()
private val noteSharedViewModel : NoteSharedViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentHomeBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.adapter = adapter
binding.vm = vm
binding.apply {
lifecycleOwner = viewLifecycleOwner
homeRecyclerView.isNestedScrollingEnabled = false
homeRecyclerView.layoutManager = LinearLayoutManager(view.context)
homeRecyclerView.itemAnimator= NotesItemAnimator()
}
binding.homeSearchView.isSubmitButtonEnabled = true
binding.homeSearchView.setOnQueryTextListener(this as SearchView.OnQueryTextListener)
binding.addNoteButton.setOnClickListener{
val note = Note(
"","","",false, activity?.getDate(), folderId = -1
)
noteSharedViewModel.selectNote(note)
val action = HomeFragmentDirections.actionHomeFragmentToSingleNoteFragment(
isNew = true
)
findNavController().navigate(action)
}
binding.foldersButton.setOnClickListener{
findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToFoldersFragment())
}
}
xml layout code :
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
>
<data>
<variable
name="vm"
type="com.example.leonidsnotesapplication.presentation.notes_feature.viewmodels.HomeViewModel" />
<variable
name="adapter"
type="com.example.leonidsnotesapplication.presentation.notes_feature.adapters.NoteCardAdapter" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:background="#color/note_background_color_3"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".presentation.notes_feature.fragments.HomeFragment">
<androidx.appcompat.widget.SearchView
android:id="#+id/homeSearchView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:theme="#style/AppSearchView"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<TextView
android:id="#+id/tvRecentTitle"
android:textStyle="bold"
android:textSize="25sp"
android:textColor="#color/button_color_2"
android:text="#string/recent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginStart="20dp"
app:layout_constraintTop_toBottomOf="#id/homeSearchView"
app:layout_constraintStart_toStartOf="parent"/>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="16dp"
android:layout_marginTop="28dp"
android:background="#drawable/ic_circle_arrow"
app:layout_constraintStart_toEndOf="#id/tvRecentTitle"
app:layout_constraintTop_toBottomOf="#id/homeSearchView" />
<androidx.recyclerview.widget.RecyclerView
tools:listitem="#layout/note_card_view"
android:scrollbars="vertical"
android:scrollbarStyle="outsideInset"
android:id="#+id/homeRecyclerView"
android:layout_width="match_parent"
android:requiresFadingEdge="vertical"
android:fadingEdge="vertical"
android:fadingEdgeLength="15dp"
android:layout_height="500dp"
android:layout_marginTop="30dp"
app:setNoteAdapter="#{adapter}"
app:submitNoteList="#{vm.notesLiveData}"
app:layout_constraintTop_toBottomOf="#id/tvRecentTitle"
app:layout_constraintStart_toStartOf="parent"
/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:backgroundTint="#color/button_color_1"
android:id="#+id/add_note_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="#drawable/ic_create"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:backgroundTint="#color/button_color_1"
android:id="#+id/folders_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="#drawable/ic_folders_stack"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Data binding adapter :
#BindingAdapter("submitNoteList")
fun submitNoteList(recyclerView: RecyclerView, data : List<Note>?){
val adapter = recyclerView.adapter as NoteCardAdapter
adapter.setData((data as ArrayList<Note>? ?: arrayListOf()))
}
#BindingAdapter("setNoteAdapter")
fun setNoteAdapter(recyclerView: RecyclerView, adapter: NoteCardAdapter){
adapter.let {
recyclerView.adapter = it
}
}
You should observe your live data. Example code is:
vm.notesLiveData.observe(viewLifecycleOwner) { adapter.submitList(it) }

move Constraint Layout programmatically

I want to move a ConstraintLayout programmatically. I looked through some solutions on stack overflow but they didn't work for me.
There are two ConstraintLayouts placed vertically. When the code goes into else if("android.permission.CAMERA" in permissionList.toString(), the above constraint layout (layoutLocation) will be invisible and the below constraint layout (layoutCamera) will come up and take its position. I mark where I should use declare it with 'want to use here'.
Code (apology to not ba able to post the whole code due to patent reason)
class SplashFragment : Fragment() {
private lateinit var mContext: MainActivity
private lateinit var binding : FragmentSplashBinding
private val viewModel : SplashViewModel by inject()
private val getPermissionUseCase : GetPermissionUseCase by inject()
override fun onAttach(context: Context) {
super.onAttach(context)
mContext = context as MainActivity
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_splash, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.viewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner
}
private fun actionApplicationSetting(permissionList : List<String>) {
val layoutInflater = LayoutInflater.from(context)
val view = layoutInflater.inflate(R.layout.dialog_splash, null)
val alertDialog = AlertDialog.Builder(requireContext(), R.style.CustomAlertDialog).setView(view).create()
val layoutLocation : ConstraintLayout = view.findViewById(R.id.layout_box_location)
val layoutCamera : ConstraintLayout = view.findViewById(R.id.layout_box_camera)
for(permission in permissionList) {
if("android.permission.CAMERA" in permissionList.toString() && "android.permission.ACCESS_COARSE_LOCATION" in permissionList.toString()) {
layoutLocation.background = AppCompatResources.getDrawable(mContext, R.drawable.auth_level_blue)
layoutCamera.background = AppCompatResources.getDrawable(mContext, R.drawable.auth_level_blue)
}else if("android.permission.ACCESS_COARSE_LOCATION" in permissionList.toString()) {
layoutLocation.background = AppCompatResources.getDrawable(mContext, R.drawable.auth_level_blue)
}else if("android.permission.CAMERA" in permissionList.toString()) {
layoutLocation.visibility = View.INVISIBLE
// want to use here
}
}
alertDialog.show()
}
}
XML
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="#+id/layout_main"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="60dp"
android:layout_marginStart="70dp"
android:layout_marginBottom="10dp"
app:layout_constraintBottom_toBottomOf="#+id/layout_wrapper"
app:layout_constraintEnd_toEndOf="#+id/layout_wrapper"
app:layout_constraintStart_toStartOf="#+id/layout_wrapper">
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="#+id/layout_wrapper"
android:layout_width="match_parent"
android:layout_height="180dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="#+id/layout_box_location"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="#+id/layout_box_camera"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#+id/layout_box_location" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Instead of layoutLocation.visibility = View.INVISIBLE try layoutLocation.visibility = View.GONE

StateFlow value displayed using Data Binding not updating

I've switched from using LiveData to StateFlow in my app and also applied it in Data Binding. But after changing the value of StateFlow, the UI is not updating. From what I understand from the StateFlow docs, I only have to assign a new value to the value property of a StateFlow to update its consumers.
Below, I have a time field which I update the value by using an MaterialTimePicker dialog. But after I change the time and assign a new value to the selectedAlarm property (StateFlow) of my viewmodel, the UI is not updating for the new value. I'm not sure where is my mistake or what is lacking.
Below is my AlarmFormFragment:
#AndroidEntryPoint
class AlarmFormFragment : Fragment() {
private val alarmsViewModel: AlarmsViewModel by activityViewModels()
private var _binding: FragmentAlarmFormBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentAlarmFormBinding.inflate(inflater, container, false)
binding.alarmsViewModel = alarmsViewModel
binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setUpTimeField()
}
private fun setUpTimeField() {
alarmsViewModel.selectedAlarm.value.let { alarm ->
binding.llTimeField.setOnClickListener {
// Create and then show time picker
val picker =
MaterialTimePicker.Builder()
.setTimeFormat(TimeFormat.CLOCK_12H)
.setTitleText("Set Time")
.setHour(alarm.time.get(Calendar.HOUR_OF_DAY))
.setMinute(alarm.time.get(Calendar.MINUTE))
.build()
picker.addOnPositiveButtonClickListener {
alarm.time.set(Calendar.HOUR_OF_DAY, picker.hour)
alarm.time.set(Calendar.MINUTE, picker.minute)
alarmsViewModel.selectedAlarm.value = alarm
}
picker.show(childFragmentManager, "TIME_INPUT_DIALOG")
}
}
}
}
Below is the xml file for AlarmFormFragment:
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
<import type="com.example.myclock.utilities.Utility" />
<import type="com.example.myclock.utilities.SnoozeUtils"/>
<variable
name="alarmsViewModel"
type="com.example.myclock.viewmodels.AlarmsViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".fragments.AlarmFormFragment">
<!-- Form Fields -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
<!-- Time Field -->
<LinearLayout
android:id="#+id/llTimeField"
style="#style/FormFieldLayoutStyle">
<TextView
style="#style/FormFieldTextStyle.Label"
android:text="#string/label_time" />
<TextView
android:id="#+id/tvTime"
style="#style/FormFieldTextStyle.Value"
android:text="#{Utility.INSTANCE.timeToString(alarmsViewModel.selectedAlarm.value.time)}" />
</LinearLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
And below is my AlarmsViewModel:
#HiltViewModel
class AlarmsViewModel #Inject constructor(
private val alarmsRepository: AlarmsRepository
) : ViewModel() {
private val _alarms = MutableStateFlow<List<Alarm>>(emptyList())
val alarms: StateFlow<List<Alarm>> get() = _alarms
var selectedAlarm = MutableStateFlow(Alarm())
...
}
Below is class for the data Alarm:
#Parcelize
#Entity
class Alarm(
#PrimaryKey(autoGenerate = true) val id: Int = 0,
var time: Calendar = Calendar.getInstance(),
...
) : Parcelable {
...
}

DataBinding doesn't update value when changing LiveData

I want the value to increase when I press the button and write it in the textview.
However, the code below shows the increasing value as Log
But, on screen, the value in textview does not change.
I'm not sure what's wrong. Please let me know if you know.
Fragment.kt
class SettingFragment : Fragment(), View.OnClickListener {
companion object {
fun newInstance() = SettingFragment()
private val TAG = "SettingFragment"
}
private lateinit var viewModel: SettingViewModel
private lateinit var binding: FragmentSettingBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_setting, container, false)
return binding.root
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProvider(this).get(SettingViewModel::class.java)
binding.lifecycleOwner = this
binding.button.setOnClickListener(this)
}
override fun onClick(v: View?) {
viewModel.increase()
Log.d(TAG, "Log Data" + viewModel.testInt.value)
}
}
FragmentViewModel
class SettingViewModel : ViewModel() {
val testInt: MutableLiveData<Int> = MutableLiveData()
init {
testInt.value = 0
}
fun increase() {
testInt.value = testInt.value?.plus(1)
}
}
fragment.xml
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
<variable
name="viewModel"
type="com.example.ui.setting.SettingViewModel" />
</data>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.setting.SettingFragment">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="#{String.valueOf(viewModel.testInt)}" />
<Button
android:id="#+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
/>
</LinearLayout>
</layout>
<variable
name="viewModel"
type="com.example.ui.setting.SettingViewModel" />
This viewModel variable doesn't bind to a certain variable programmatically. So, when the value incremented, it won't reflect to the layout.
To fix this set the fragment's viewModel to this value:
viewModel = ViewModelProvider(this).get(SettingViewModel::class.java)
binding.viewModel = viewModel // <<<<< Here is the change
binding.lifecycleOwner = this
binding.button.setOnClickListener(this)
Side note:
You shouldn't use onActivityCreated() as it's is deprecated as of API level 28; you can normally its part of code to onCreateView(). And you can check here for other alternatives.

Categories

Resources