How can I call same API multiple time using MVVM in android? - android

I have implemented MVVM architecture in one of my projects. In which I have created a ViewModel, Repository, and ViewModelFactory to make API call. I'm showing a list of products on the wish list. There is a facility where you can remove a particular product from the wish list. When my activity opens its API calls and renders the data but when I remove the product from the recycler view list need to make an API call which gets the remaining list of products available in the wishlist. When I change the configuration of the device it also makes API calls. How can I survive the configuration changes?
Below is the code from Activity
val repository = WishlistRepository(activity)
wishlistViewModel = ViewModelProvider(
this,
WishlistViewModelFactory(repository)
)[WishlistViewModel::class.java]
wishlistObservables()
if (preferenceUtils.getPrefBoolean(Constants.IS_LOGGED_IN)) {
if (wishlistViewModel.wishListLiveData.value == null) {
prepareParams()
}
} else {
val intent = Intent(activity, LoginActivity::class.java)
intent.putExtra("fromScreen", 2)
startActivity(intent)
}
prepareParams method
private fun prepareParams() {
if (Utils.isNetworkAvailable(activity)) {
val params = mutableMapOf<String, String>()
params["customer_id"] = preferenceUtils.getPreString(Constants.USER_ID)
params["store"] = preferenceUtils.getPreInt(Constants.STORE).toString()
wishlistViewModel.getWishList(params)
} else {
Utils.snackBar(binding.coordinatorWishlist, getString(R.string.network))
}
}
ViewModel Code
val wishListLiveData: LiveData<Resource<WishlistModel>> get() = repository.wishLiveData
fun getWishList(params: Map<String, String>) {
repository.getWishlistApiCall(params)
}
Repository Code
private val wishListLiveData = MutableLiveData<Resource<WishlistModel>>()
val wishLiveData:LiveData<Resource<WishlistModel>> get() = wishListLiveData
fun getWishlistApiCall(params:Map<String,String>){
if (Utils.isNetworkAvailable(activity)) {
wishListLiveData.postValue(Resource.Loading())
retroService.getWishListApiCall(params).enqueue(object : Callback<WishlistModel> {
override fun onResponse(call: Call<WishlistModel>, response: Response<WishlistModel>) {
try {
if (response.body() != null) {
if (response.isSuccessful) {
response.body()?.let {
if (it.code == 200) {
if (it.status == "1") {
wishListLiveData.postValue(Resource.Success(it))
} else {
wishListLiveData.postValue(Resource.Error(it.message))
}
} else if (it.code == 404) {
wishListLiveData.postValue(
Resource.Error(
activity.getString(
R.string.four_hundred_four_not_found
)
)
)
} else if (it.code == 500) {
wishListLiveData.postValue(
Resource.Error(
activity.getString(
R.string.five_hundred_server_error
)
)
)
} else {
wishListLiveData.postValue(Resource.Error(it.message))
}
}
} else {
wishListLiveData.postValue(Resource.Error(response.message()))
}
} else {
wishListLiveData.postValue(Resource.Error(response.message()))
}
} catch (e: Exception) {
wishListLiveData.postValue(Resource.Error(e.message.toString()))
}
}
override fun onFailure(call: Call<WishlistModel>, t: Throwable) {
try {
wishListLiveData.postValue(Resource.Error(t.message.toString()))
} catch (e: Exception) {
wishListLiveData.postValue(Resource.Error(e.message.toString()))
}
}
})
} else {
wishListLiveData.postValue(Resource.Error(activity.getString(R.string.network)))
}
}
ViewModelFactory Code
class WishlistViewModelFactory(private val repository: WishlistRepository) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return WishlistViewModel(repository) as T
}
}
Remove wishlist observables
private fun removeObservables() {
removeWishlistViewModel.removeWishListLiveData.observe(this) {
when (it.status) {
Status.LOADING -> {
Utils.showDialog(activity)
}
Status.SUCCESS -> {
Utils.hideDialog()
if (it.data != null) {
it.data.let { removeWishModel ->
Utils.snackBar(
binding.coordinatorWishlist,
removeWishModel.message, Constants.SNACK_SUCCESS_COLOR
)
val activityFragmentMessageEvent =
Events.ActivityFragmentMessage("Wishlist")
EventBus.getDefault().post(activityFragmentMessageEvent)
prepareParams()
}
}
}
Status.ERROR -> {
Utils.hideDialog()
Utils.snackBar(binding.coordinatorWishlist, it.errorMessage.toString())
}
}
}
}
When I call prepareParams method again it's will make an API call of getting the wish-listed products. After that when I change the configuration from portrait to landscape it's starting again to make API calls again and again.
How can I resolve this issue? Please help me to resolve this issue with MVVM. I'm begginner in MVVM architecture.
Thank you

Related

android kotlin recyclerview stackFromEnd not working

the app have a chatting function so I used 'stackFromEnd' method of recyclerview to show a last item of list firstly like other chatting app however, it not worked. it stopped in the middle of placing message items.
MessageActivity OnCreate
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_message)
binding.lifecycleOwner = this
val intent = intent
room = intent.getSerializableExtra("room") as RoomEntity
roomUid = room!!.uid
other = intent.getSerializableExtra("other") as RoomMemberEntity
ownUid = AppContext.uid
layoutManager = LinearLayoutManager(this)
layoutManager.stackFromEnd = true
layoutManager.isSmoothScrollbarEnabled = true
binding.recyclerMessages.recycledViewPool.setMaxRecycledViews(0,0)
binding.recyclerMessages.layoutManager = layoutManager
adapter = MessageAdapter(messageList, ownUid, other)
binding.recyclerMessages.adapter = adapter
binding.buttonSend.setOnClickListener {
val text = binding.editMessage.text.toString()
if (text.length > 0) {
binding.editMessage.text!!.clear()
lifecycleScope.launch(Dispatchers.IO) {
val sendResponse = viewModel.sendMessage(text, roomUid)
when(sendResponse) {
is Response.Error -> {
withContext(Dispatchers.Main) {
Toast.makeText(this#MessageActivity,"message not sent due to the internet connection error.",Toast.LENGTH_SHORT)
}
}
else -> {
}
}
}
}
}
binding.editMessage.doAfterTextChanged { text ->
if (text!!.length > 0)
binding.buttonSend.visibility = View.VISIBLE
else
binding.buttonSend.visibility = View.GONE
}
}
MessageActivity OnStart
lifecycleScope.launch(Dispatchers.IO) {
viewModel.fetchMessage(roomUid).collect { fetchResponse->
when (fetchResponse) {
is Response.Success -> {
val map = fetchResponse.data
val type = map.keys.first()
val message = map.get(type)
if (message != null) {
if (messageList.contains(message)) {
val index = messageList.indexOf(message)
messageList.set(index, message)
} else {
messageList.add(message)
}
if (type == ADDED) {
if (message.read == false && !message.sender.equals(ownUid)) {
val readResponse = viewModel.readMessage(roomUid, message.uid)
when(readResponse) {
is Response.Error -> {
}
else -> {
}
}
}
}
withContext(Dispatchers.Main) {
adapter.changeMessages(messageList)
adapter.notifyDataSetChanged()
}
} else {
}
}
is Response.No -> {
}
is Response.Error -> {
}
else -> {
}
}
}
}
ViewModel
private fun _fetchMessage (roomUid : String) : Flow<Response<Map<Int, MessageEntity>>> {
val flow = repository.fetchMessage(roomUid).shareIn(viewModelScope, SharingStarted.WhileSubscribed(), 0)
return flow
}
fun fetchMessage (roomUid: String) : Flow<Response<Map<Int, MessageEntity>>> {
return _fetchMessage(roomUid)
}
DataSourceImpl
val reference =
databaseReference.child("messages").child(roomUid)
val subscription =
reference.addChildEventListener(object : ChildEventListener {
override fun onChildAdded(
snapshot: DataSnapshot,
previousChildName: String?
) {
val message = snapshot.getValue(MessageEntity::class.java)
if (message != null)
trySend(Response.Success(mapOf(ADDED to message )))
else
trySend(Response.Success(mapOf(RESPONSE_NULL to MessageEntity())))
}
override fun onChildChanged(
snapshot: DataSnapshot,
previousChildName: String?
) {
val message = snapshot.getValue(MessageEntity::class.java)
if (message != null)
trySend(Response.Success(mapOf(CHANGED to message)))
else
trySend(Response.Success(mapOf(RESPONSE_NULL to MessageEntity())))
}
override fun onChildRemoved(snapshot: DataSnapshot) {
}
override fun onChildMoved(
snapshot: DataSnapshot,
previousChildName: String?
) {
}
override fun onCancelled(error: DatabaseError) {
}
} )
awaitClose {
reference.removeEventListener(subscription)
channel.close()
}
why this error occurred? is it due to callbackFlow??
Issue (as you can see, the window does not show the end of messages. even usage of setStackFromEnd)
What I expected and wanted
Try this binding.rvChatMessage.layoutManager = LinearLayoutManager(this ,RecyclerView.VERTICAL,true) and remove stackfromend

Firebase Auth with Kotlin Flow

I am learning clean architecture and Kotlin Flow. I want to check is user mail exists in the Firebase Auth base. However, when I threw an error to the flow function, app is crash.
CheckUserUseCase.kt
class CheckUserUseCase #Inject constructor(private val repository: SignInRepository) {
operator fun invoke(mail: String): Flow<Status<Boolean, String>> = flow {
emit(Status.Loading(data = null))
try {
repository.isUserExists(mail = mail)
emit(Status.Success(data = true))
} catch (e: Exception) {
emit(Status.Error(message = e.message, data = false))
}
}
}
SignInRepository.kt
interface SignInRepository {
suspend fun isUserExists(mail: String)
}
SignInRepositoryImpl.kt
class SignInRepositoryImpl #Inject constructor(private val firebaseUserActions: FirebaseUserActions) : SignInRepository {
override suspend fun isUserExists(mail: String) {
firebaseUserActions.isUserExists(mail = mail)
}
}
FirebaseAuthentication.kt
class FirebaseAuthentication #Inject constructor(private val auth: FirebaseAuth) : FirebaseUserActions {
override suspend fun isUserExists(mail: String){
auth.fetchSignInMethodsForEmail(mail).addOnCompleteListener { task ->
task.result.signInMethods?.let {
if (it.size != 0) Log.i("App.tag", "True.")
else throw IOException() <-- Crash point.
}
}.addOnFailureListener { e -> e.printStackTrace() }
.await()
}
}
How can I return a state to Kotlin Flow method? Thank you!
Please try the following approach:
override suspend fun isUserExists(mail: String): Status {
return try {
val result = auth.fetchSignInMethodsForEmail(mail).await()
result.signInMethods?.let {
if (it.isNotEmpty()) {
Status.Success(data = true)
} else {
Status.Error(message = "No data", data = false)
}
} ?: Status.Error(message = "No Data", data = false)
} catch (e: Exception) {
Status.Error(message = e.message, data = false)
}
}
In CheckUserUseCase class just emit the result of calling isUserExists():
emit(Status.Loading(data = null))
emit(repository.isUserExists(mail = mail))
Try
it.size != 0 && it.size != null
and
if (task.isSuccessful()) {
[...]
task.result.signInMethods?.let {
[...]
}

How to synchonize executing http requests (kotlin, android)?

I am debugging an application that communicates with an IoT device via http.
In response to commands, the device sends information in xml format.
An application can also receive binary data on a GET request.
In the functionality of the application, filling the RecyclerView from the list and loading images to fill the RecyclerView and executing individual commands to change modes.
The problem is that the device does not have the most powerful processor, and when a large number of http commands are received, the service cannot cope and hangs for a long time until the WiFi channel fails.
I can’t figure out how to organize interaction so that each next command waits for the previous one to complete. The solution is complicated by the fact that populating the RecyclerView, loading images, and executing commands are in different parts of the code, and each is executed asynchronously.
Populating RecyclerView:
private fun initViewModel(filter: String) {
val st = Storage(requireContext())
val cache = "${st.externalCacheDir}/$filter/"
val viewModel = ViewModelProvider(this).get(DeviceListViewModel::class.java)
viewModel.getRecycerListObserver().observe(requireActivity(), Observer<ResponseData> {
if (it != null) {
val media = it.mediaData?.filter { it.mediaData?.fPath!!.contains(filter, false) }
mediaList = arrayListOf()
if (media != null) {
for (i in media.sortedByDescending { it.mediaData?.fTimeCode }) {
i.mediaData?.let { it1 -> mediaList.add(it1) }
}
}
viewModel.recyclerListLiveData = MutableLiveData()
ThumbDownloader(dataAdapter, mediaList, cache, swipeLayout).execute()
} else {
Toast.makeText(activity, "Error in getting data", Toast.LENGTH_SHORT).show()
}
})
viewLifecycleOwner.lifecycleScope.launch {
viewModel.makeApiCall()
}
}
ViewModel:
class DeviceListViewModel : ViewModel() {
var recyclerListLiveData: MutableLiveData<ResponseData>
init {
recyclerListLiveData = MutableLiveData()
}
fun getRecycerListObserver(): MutableLiveData<ResponseData> {
return recyclerListLiveData
}
fun makeApiCall() {
viewModelScope.launch(Dispatchers.IO) {
try {
val retroInstance =
RetroInstance.getRetroInstance(MainActivity.BaseUrl).create(RetroService::class.java)
val response = retroInstance.getDataFromApi(1, Cmd.WIFIAPP_CMD_FILELIST)
recyclerListLiveData.postValue(response)
} catch (e: Exception) {
var response: ResponseData? = null
when (e) {
is ConnectException -> {
recyclerListLiveData.postValue(response)
}
is SocketTimeoutException -> {
recyclerListLiveData.postValue(response)
}
}
}
}
}
}
Service to make a command (processing results in the Handler):
class DeviceService {
private val handler: Handler
private var mJob: Job? = null
constructor(handler: Handler) {
this.handler = handler
}
fun sendCommand(cmd: Int) {
val service = RetroInstance.buildService(MainActivity.BaseUrl, RetroService::class.java)
mJob = CoroutineScope(Dispatchers.IO).launch {
val response = when (cmd) {
Cmd.WIFIAPP_CMD_MOVIE_GET_LIVEVIEW_FMT -> {
try {
service.getLinkFromApi(1, cmd)
} catch (e: Exception) {
handler.obtainMessage(Msg.MESSAGE_TOAST, "Error in getting data").sendToTarget()
mJob?.cancel()
}
}
else -> {
try {
service.makeCommand(1, cmd)
} catch (e: Exception) {
handler.obtainMessage(Msg.MESSAGE_TOAST, "Error in getting data").sendToTarget()
mJob?.cancel()
}
}
}
withContext(Dispatchers.Main) {
try {
when (cmd) {
Cmd.WIFIAPP_CMD_MOVIE_GET_LIVEVIEW_FMT -> {
handler.obtainMessage(Msg.MESSAGE_LINK_FORMAT, response).sendToTarget()
}
else -> {
handler.obtainMessage(Msg.MESSAGE_PAR_FUNCTION, response).sendToTarget()
}
}
} catch (e: Exception) {
when (e) {
is ConnectException -> {
handler.obtainMessage(Msg.MESSAGE_TOAST, "Connection lost").sendToTarget()
}
is SocketTimeoutException -> {
handler.obtainMessage(Msg.MESSAGE_TOAST, "Connection lost").sendToTarget()
}
}
}
mJob?.cancelAndJoin()
}
}
}
}
Downloading a images:
class ThumbDownloader(dataAdapter: DeviceAdapter, data: ArrayList<MediaData>, file_path: String, swipe: SwipeRefreshLayout) : CoroutineScope {
private var job: Job = Job()
private var file_path: String
private var dataAdapter: DeviceAdapter
private var data: ArrayList<MediaData>
private var swipe: SwipeRefreshLayout
init {
this.data = data
this.file_path = file_path
this.dataAdapter = dataAdapter
this.swipe = swipe
}
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
fun cancel() {
job.cancel()
}
fun execute() = async {
var item: File? = null
for (i in data) {
val task = async(Dispatchers.IO) {
val url = i.fPath!!
val real_url = "${MainActivity.BaseUrl}$url"
item = NetworkUtil.downloadFile(real_url, file_path, false)
}
task.await()
if (item != null) {
dataAdapter.insertItem(i)
}
}
cancel()
swipe.isRefreshing = false
}
}
Any ideas how to come up with their synchronization while waiting for the previous commands to complete?

Firestore Snapshot with Coroutines?

Hello I tried to make Coroutines Flow (using callbackFlow) and tried to convert it to live data, but it seems it's not updating.
You can see my code below:
#ExperimentalCoroutinesApi
suspend fun checkInDanger(): Flow<NetworkStatus<List<UserFire>>> = callbackFlow {
val check = fs.collection(UserFire.COLLECTION).whereEqualTo(UserFire.DANGER, true)
.addSnapshotListener { value, e ->
if (e != null) {
trySend(NetworkStatus.Failed("Error occurred\n${e.code}"))
return#addSnapshotListener
}
if (value == null || value.isEmpty) trySend(NetworkStatus.Empty)
else {
val users = value.map { it.toObject(UserFire::class.java) }
trySend(NetworkStatus.Success(users))
}
}
awaitClose { }
}.flowOn(Dispatchers.IO)
On my repositories:
#ExperimentalCoroutinesApi
override suspend fun checkInDanger(): Flow<Status<List<User>>> = flow {
when (val result = network.checkInDanger().first()) {
is NetworkStatus.Success -> emit(Status.Success(result.data.map {
MapVal.userFireToDom(it)
}))
is NetworkStatus.Empty -> emit(Status.Success(listOf<User>()))
is NetworkStatus.Failed -> emit(Status.Error(null, result.error))
}
}
In my ViewModel:
val checkInDanger = liveData(Dispatchers.IO) {
try {
useCase.checkInDanger().collectLatest {
emit(it)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
But when I changed the value in my Firebase, it's not fetching new data...
Anyone know why it's not fixed
I kind of find the way, but it's with callback, if we used callback, it can fetch data update even without live-data, but for my case I tried to push the callback to live data again,
so the code will be like this:
fun checkInDanger(networkStatus: (NetworkStatus<List<UserFire>>) -> Unit) {
fs.collection(UserFire.COLLECTION).whereEqualTo(UserFire.DANGER, true)
.addSnapshotListener { value, e ->
if (e != null) {
networkStatus(NetworkStatus.Failed("Error occurred\n${e.code}"))
return#addSnapshotListener
}
if (value == null || value.isEmpty) networkStatus(NetworkStatus.Empty)
else {
val users = value.map { it.toObject(UserFire::class.java) }
networkStatus(NetworkStatus.Success(users))
}
}
}
In my repositories:
override fun checkInDanger(callback: (Status<List<User>>) -> Unit) {
network.checkInDanger { result ->
when (result) {
is NetworkStatus.Success -> callback(Status.Success(result.data.map {
MapVal.userFireToDom(it)
}))
is NetworkStatus.Empty -> callback(Status.Success(listOf<User>()))
is NetworkStatus.Failed -> callback(Status.Error(null, result.error))
}
}
}
In my ViewModel:
fun checkInDanger(callback: (Status<List<User>>) -> Unit) = useCase.checkInDanger { callback(it) }
val setUsers = MutableLiveData<List<User>>().apply { this.value = listOf() }
val users: LiveData<List<User>> = setUsers
In my UI Class (Fragment Main):
val inDangerCallback: (Status<List<User>>) -> Unit = {
if (relative != null) {
when (it) {
is Status.Success ->
viewModel.setUsers.value =
it.data?.filter { user -> user.username in relative!!.pure }
else -> requireView().createSnackBar(it.error!!, 1000)
}
}
}
viewModel.checkInDanger(inDangerCallback)
viewModel.users.observe(viewLifecycleOwner) { users ->
println(users.size})
users?.forEach { user -> println(user.username) }
}
And the code can run perfectly and update automatically...

Android ViewState using RxJava or kotlin coroutines

I'm trying to learn how to use RxJava in Android, but have run into a dead end. I have the following DataSource:
object DataSource {
enum class FetchStyle {
FETCH_SUCCESS,
FETCH_EMPTY,
FETCH_ERROR
}
var relay: BehaviorRelay<FetchStyle> = BehaviorRelay.createDefault(FetchStyle.FETCH_ERROR)
fun fetchData(): Observable<DataModel> {
return relay
.map { f -> loadData(f) }
}
private fun loadData(f: FetchStyle): DataModel {
Thread.sleep(5000)
return when (f) {
FetchStyle.FETCH_SUCCESS -> DataModel("Data Loaded")
FetchStyle.FETCH_EMPTY -> DataModel(null)
FetchStyle.FETCH_ERROR -> throw IllegalStateException("Error Fetching")
}
}
}
I want to trigger an update downstream, whenever I change the value of relay, but this doesn't happen. It works when the Activity is initialized, but not when I'm updating the value. Here's my ViewModel, from where I update the value:
class MainViewModel : ViewModel() {
val fetcher: Observable<UiStateModel> = DataSource.fetchData().replay(1).autoConnect()
.map { result -> UiStateModel.from(result) }
.onErrorReturn { exception -> UiStateModel.Error(exception) }
.startWith(UiStateModel.Loading())
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
fun loadSuccess() {
DataSource.relay.accept(DataSource.FetchStyle.FETCH_SUCCESS)
}
fun loadEmpty() {
DataSource.relay.accept(DataSource.FetchStyle.FETCH_EMPTY)
}
fun loadError() {
DataSource.relay.accept(DataSource.FetchStyle.FETCH_ERROR)
}
}
This is the code from the Activity that does the subsciption:
model.fetcher
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
uiState -> mainPresenter.loadView(uiState)
})
Ended up using kotlin coroutines instead, as I was unable to re-subscribe to ConnectableObservable and start a new fetch.
Here's the code for anyone interested.
The presenter:
class MainPresenter(val view: MainView) {
private lateinit var subscription: SubscriptionReceiveChannel<UiStateModel>
fun loadSuccess(model: MainViewModel) {
model.loadStyle(DataSource.FetchStyle.FETCH_SUCCESS)
}
fun loadError(model: MainViewModel) {
model.loadStyle(DataSource.FetchStyle.FETCH_ERROR)
}
fun loadEmpty(model: MainViewModel) {
model.loadStyle(DataSource.FetchStyle.FETCH_EMPTY)
}
suspend fun subscribe(model: MainViewModel) {
subscription = model.connect()
subscription.subscribe { loadView(it) }
}
private fun loadView(uiState: UiStateModel) {
when(uiState) {
is Loading -> view.isLoading()
is Error -> view.isError(uiState.exception.localizedMessage)
is Success -> when {
uiState.result != null -> view.isSuccess(uiState.result)
else -> view.isEmpty()
}
}
}
fun unSubscribe() {
subscription.close()
}
}
inline suspend fun <E> SubscriptionReceiveChannel<E>.subscribe(action: (E) -> Unit) = consumeEach { action(it) }
The view:
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
launch(UI) {
mainPresenter.subscribe(model)
}
btn_load_success.setOnClickListener {
mainPresenter.loadSuccess(model)
}
btn_load_error.setOnClickListener {
mainPresenter.loadError(model)
}
btn_load_empty.setOnClickListener {
mainPresenter.loadEmpty(model)
}
}
override fun onDestroy() {
super.onDestroy()
Log.d("View", "onDestroy()")
mainPresenter.unSubscribe()
}
...
The model:
class MainViewModel : ViewModel() {
val TAG = this.javaClass.simpleName
private val stateChangeChannel = ConflatedBroadcastChannel<UiStateModel>()
init {
/** When the model is initialized we immediately start fetching data */
fetchData()
}
override fun onCleared() {
super.onCleared()
Log.d(TAG, "onCleared() called")
stateChangeChannel.close()
}
fun connect(): SubscriptionReceiveChannel<UiStateModel> {
return stateChangeChannel.openSubscription()
}
fun fetchData() = async {
stateChangeChannel.send(UiStateModel.Loading())
try {
val state = DataSource.loadData().await()
stateChangeChannel.send(UiStateModel.from(state))
} catch (e: Exception) {
Log.e("MainModel", "Exception happened when sending new state to channel: ${e.cause}")
}
}
internal fun loadStyle(style: DataSource.FetchStyle) {
DataSource.style = style
fetchData()
}
}
And here's a link to the project on github.

Categories

Resources