diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dc09a9229..46b6f13dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -162,6 +162,7 @@ dependencies { // Jetpack Compose api(libs.androidx.activity.compose) + api(platform(libs.androidx.compose.bom)) api(libs.androidx.compose.material3) api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation.layout) diff --git a/app/src/main/java/org/mifos/mobile/ui/activities/HomeActivity.kt b/app/src/main/java/org/mifos/mobile/ui/activities/HomeActivity.kt index d40e24881..ee8e79d8c 100644 --- a/app/src/main/java/org/mifos/mobile/ui/activities/HomeActivity.kt +++ b/app/src/main/java/org/mifos/mobile/ui/activities/HomeActivity.kt @@ -40,6 +40,7 @@ import org.mifos.mobile.ui.help.HelpActivity import org.mifos.mobile.ui.home.HomeOldFragment import org.mifos.mobile.ui.login.LoginActivity import org.mifos.mobile.ui.notification.NotificationFragment +import org.mifos.mobile.ui.recent_transactions.RecentTransactionsComposeFragment import org.mifos.mobile.utils.Constants import org.mifos.mobile.utils.TextDrawable import org.mifos.mobile.utils.Toaster @@ -182,7 +183,7 @@ class HomeActivity : } R.id.item_recent_transactions -> replaceFragment( - RecentTransactionsFragment.newInstance(), + RecentTransactionsComposeFragment.newInstance(), true, R.id.container, ) @@ -407,7 +408,7 @@ class HomeActivity : setNavigationViewSelectedItem(R.id.item_accounts) } - is RecentTransactionsFragment -> { + is RecentTransactionsComposeFragment -> { setNavigationViewSelectedItem(R.id.item_recent_transactions) } diff --git a/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionScreen.kt b/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionScreen.kt new file mode 100644 index 000000000..b93fac303 --- /dev/null +++ b/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionScreen.kt @@ -0,0 +1,251 @@ +package org.mifos.mobile.ui.recent_transactions + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat.getString +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.mifos.mobile.MifosSelfServiceApp +import org.mifos.mobile.R +import org.mifos.mobile.core.ui.component.EmptyDataView +import org.mifos.mobile.core.ui.component.MFScaffold +import org.mifos.mobile.core.ui.component.MifosErrorComponent +import org.mifos.mobile.core.ui.component.MifosProgressIndicator +import org.mifos.mobile.core.ui.component.MifosProgressIndicatorOverlay +import org.mifos.mobile.core.ui.theme.MifosMobileTheme +import org.mifos.mobile.models.Transaction +import org.mifos.mobile.models.client.Type +import org.mifos.mobile.utils.CurrencyUtil +import org.mifos.mobile.utils.DateHelper +import org.mifos.mobile.utils.Network +import org.mifos.mobile.utils.Utils + +@Composable +fun RecentTransactionScreen( + viewModel: RecentTransactionViewModel = hiltViewModel(), + navigateBack: () -> Unit +) { + val uiState by viewModel.recentTransactionUiState.collectAsStateWithLifecycle() + val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle() + val isPaginating by viewModel.isPaginating.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { + viewModel.loadInitialTransactions() + } + + RecentTransactionScreen( + uiState = uiState, + navigateBack = navigateBack, + onRetry = { viewModel.loadInitialTransactions() }, + isRefreshing = isRefreshing, + onRefresh = { viewModel.refresh() }, + isPaginating = isPaginating, + loadMore = { offset -> viewModel.loadPaginatedTransactions(offset) } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecentTransactionScreen( + uiState: RecentTransactionUiState, + navigateBack: () -> Unit, + onRetry: () -> Unit, + isRefreshing: Boolean, + onRefresh: () -> Unit, + isPaginating: Boolean, + loadMore: (offset: Int) -> Unit +) { + val context = LocalContext.current + val pullRefreshState = rememberPullToRefreshState() + + MFScaffold( + topBarTitleResId = R.string.recent_transactions, + navigateBack = navigateBack, + scaffoldContent = { paddingValues -> + Box(modifier = Modifier.padding(paddingValues = paddingValues)) { + when (uiState) { + is RecentTransactionUiState.Error -> { + MifosErrorComponent( + isNetworkConnected = Network.isConnected(context), + isRetryEnabled = true, + onRetry = onRetry + ) + } + + is RecentTransactionUiState.Loading -> { + MifosProgressIndicatorOverlay() + } + + is RecentTransactionUiState.Success -> { + if (uiState.transactions.isEmpty()) { + EmptyDataView( + icon = R.drawable.ic_error_black_24dp, + error = R.string.no_transaction, + modifier = Modifier.fillMaxSize() + ) + } else { + RecentTransactionsContent( + transactions = uiState.transactions, + isPaginating = isPaginating, + loadMore = loadMore, + canPaginate = uiState.canPaginate + ) + } + } + } + } + } + ) + + if (pullRefreshState.isRefreshing) { + LaunchedEffect(key1 = true) { + onRefresh() + } + } + LaunchedEffect(key1 = isRefreshing) { + if (isRefreshing) + pullRefreshState.startRefresh() + else + pullRefreshState.endRefresh() + } + + PullToRefreshContainer( + state = pullRefreshState, + ) +} + +@Composable +fun RecentTransactionsContent( + transactions: List, + isPaginating: Boolean, + canPaginate: Boolean, + loadMore: (offset: Int) -> Unit +) { + val lazyColumnState = rememberLazyListState() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyColumnState + ) { + val visibleItems = lazyColumnState.layoutInfo.visibleItemsInfo + val lastVisibleItemIndex = visibleItems.lastOrNull()?.index ?: 0 + val isNearBottom = lastVisibleItemIndex >= transactions.size - 5 + + if (!isPaginating && canPaginate && isNearBottom) { + loadMore(transactions.size - 1) + } + + items(items = transactions) { transaction -> + RecentTransactionListItem(transaction) + } + + if(isPaginating) { + item { + MifosProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + } +} + +@Composable +fun RecentTransactionListItem(transaction: Transaction?) { + Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = R.drawable.ic_local_atm_black_24dp), + contentDescription = stringResource(id = R.string.atm_icon), + modifier = Modifier.size(40.dp) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = Utils.formatTransactionType(transaction?.type?.value), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Row { + Text( + text = stringResource( + id = R.string.string_and_string, + transaction?.currency?.displaySymbol ?: transaction?.currency?.code ?: "", + CurrencyUtil.formatCurrency(MifosSelfServiceApp.context, transaction?.amount ?: 0.0,) + ), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.weight(1f).alpha(0.7f), + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = DateHelper.getDateAsString(transaction?.submittedOnDate), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.alpha(0.7f), + color = MaterialTheme.colorScheme.onSurface + ) + } + + } + } +} + + +class RecentTransactionScreenPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + RecentTransactionUiState.Loading, + RecentTransactionUiState.Error(""), + RecentTransactionUiState.Success(listOf(), canPaginate = true) + ) +} + +@Preview(showSystemUi = true) +@Composable +private fun RecentTransactionScreenPreview( + @PreviewParameter(RecentTransactionScreenPreviewProvider::class) recentTransactionUiState: RecentTransactionUiState +) { + MifosMobileTheme { + RecentTransactionScreen( + uiState = recentTransactionUiState, + navigateBack = {}, + onRetry = {}, + isRefreshing = false, + onRefresh = {}, + isPaginating = false, + loadMore = {} + ) + } +} + diff --git a/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionViewModel.kt b/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionViewModel.kt new file mode 100644 index 000000000..7e4eb646b --- /dev/null +++ b/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionViewModel.kt @@ -0,0 +1,71 @@ +package org.mifos.mobile.ui.recent_transactions + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch +import org.mifos.mobile.R +import org.mifos.mobile.models.Transaction +import org.mifos.mobile.models.client.Type +import org.mifos.mobile.repositories.RecentTransactionRepository +import javax.inject.Inject + +@HiltViewModel +class RecentTransactionViewModel @Inject constructor(private val recentTransactionRepositoryImp: RecentTransactionRepository) : + ViewModel() { + + private val limit = 50 + private var transactions: MutableList = mutableListOf() + + private val _recentTransactionUiState = MutableStateFlow(RecentTransactionUiState.Loading) + val recentTransactionUiState: StateFlow = _recentTransactionUiState + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow get() = _isRefreshing.asStateFlow() + + private val _isPaginating = MutableStateFlow(false) + val isPaginating: StateFlow get() = _isPaginating.asStateFlow() + + fun refresh() { + _isRefreshing.value = true + loadInitialTransactions() + } + + fun loadPaginatedTransactions(offset: Int) { + _isPaginating.value = true + transactions.clear() + loadRecentTransactions(offset) + } + + fun loadInitialTransactions() { + _recentTransactionUiState.value = RecentTransactionUiState.Loading + loadRecentTransactions(0) + } + + private fun loadRecentTransactions(offset: Int) { + viewModelScope.launch { + recentTransactionRepositoryImp.recentTransactions(offset, limit) + .catch { + _recentTransactionUiState.value = RecentTransactionUiState.Error(it.message) + } + .collect { + transactions.plus(it.pageItems) + _recentTransactionUiState.value = RecentTransactionUiState.Success(transactions = transactions, canPaginate = it.pageItems.isNotEmpty()) + _isPaginating.emit(false) + _isRefreshing.emit(false) + } + } + } + +} + +sealed class RecentTransactionUiState { + data object Loading : RecentTransactionUiState() + data class Error(val message: String?) : RecentTransactionUiState() + data class Success(val transactions: List, val canPaginate: Boolean) : RecentTransactionUiState() +} diff --git a/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionsComposeFragment.kt b/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionsComposeFragment.kt new file mode 100644 index 000000000..531c1d1c6 --- /dev/null +++ b/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionsComposeFragment.kt @@ -0,0 +1,42 @@ +package org.mifos.mobile.ui.recent_transactions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import org.mifos.mobile.R +import org.mifos.mobile.core.ui.component.mifosComposeView +import org.mifos.mobile.ui.activities.base.BaseActivity +import org.mifos.mobile.ui.fragments.base.BaseFragment + +/** + * @author Vishwwajeet + * @since 09/08/16 + */ +@AndroidEntryPoint +class RecentTransactionsComposeFragment : BaseFragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + (activity as? BaseActivity)?.hideToolbar() + return mifosComposeView(requireContext()) { + RecentTransactionScreen( + navigateBack = { activity?.onBackPressedDispatcher?.onBackPressed() } + ) + } + } + + companion object { + fun newInstance(): RecentTransactionsComposeFragment { + val fragment = RecentTransactionsComposeFragment() + val args = Bundle() + fragment.arguments = args + return fragment + } + } +} diff --git a/app/src/main/java/org/mifos/mobile/ui/fragments/RecentTransactionsFragment.kt b/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionsFragment.kt similarity index 98% rename from app/src/main/java/org/mifos/mobile/ui/fragments/RecentTransactionsFragment.kt rename to app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionsFragment.kt index dae06d6cb..30ac957cf 100644 --- a/app/src/main/java/org/mifos/mobile/ui/fragments/RecentTransactionsFragment.kt +++ b/app/src/main/java/org/mifos/mobile/ui/recent_transactions/RecentTransactionsFragment.kt @@ -1,4 +1,4 @@ -package org.mifos.mobile.ui.fragments +package org.mifos.mobile.ui.recent_transactions import android.os.Bundle import android.os.Parcelable @@ -8,7 +8,6 @@ import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager @@ -26,9 +25,9 @@ import org.mifos.mobile.ui.fragments.base.BaseFragment import org.mifos.mobile.utils.* import org.mifos.mobile.utils.Network.isConnected import org.mifos.mobile.utils.ParcelableAndSerializableUtils.getCheckedArrayListFromParcelable -import org.mifos.mobile.viewModels.RecentTransactionViewModel import javax.inject.Inject +/* /** * @author Vishwwajeet * @since 09/08/16 @@ -285,3 +284,5 @@ class RecentTransactionsFragment : BaseFragment(), OnRefreshListener { } } } + + */ \ No newline at end of file diff --git a/app/src/main/java/org/mifos/mobile/utils/RecentTransactionUiState.kt b/app/src/main/java/org/mifos/mobile/utils/RecentTransactionUiState.kt index 5fa15e671..ef256f710 100644 --- a/app/src/main/java/org/mifos/mobile/utils/RecentTransactionUiState.kt +++ b/app/src/main/java/org/mifos/mobile/utils/RecentTransactionUiState.kt @@ -1,13 +1,3 @@ package org.mifos.mobile.utils import org.mifos.mobile.models.Transaction - -sealed class RecentTransactionUiState { - object Initial : RecentTransactionUiState() - object Loading : RecentTransactionUiState() - object EmptyTransaction : RecentTransactionUiState() - data class Error(val message: Int) : RecentTransactionUiState() - data class RecentTransactions(val transactions: List) : RecentTransactionUiState() - data class LoadMoreRecentTransactions(val transactions: List) : - RecentTransactionUiState() -} diff --git a/app/src/main/java/org/mifos/mobile/utils/Utils.kt b/app/src/main/java/org/mifos/mobile/utils/Utils.kt index 1ff37259b..6c76e5bc3 100644 --- a/app/src/main/java/org/mifos/mobile/utils/Utils.kt +++ b/app/src/main/java/org/mifos/mobile/utils/Utils.kt @@ -76,14 +76,16 @@ object Utils { @JvmStatic fun formatTransactionType(type: String?): String { val builder = StringBuilder() - for (str in type?.lowercase(Locale.ROOT)?.split("_".toRegex())?.toTypedArray()!!) { - builder.append( - str[0].toString().uppercase(Locale.ROOT) + str.substring( - 1, - str.length, - ) + " ", - ) - } + try { + for (str in type?.lowercase(Locale.ROOT)?.split("_".toRegex())?.toTypedArray()!!) { + builder.append( + str[0].toString().uppercase(Locale.ROOT) + str.substring( + 1, + str.length, + ) + " ", + ) + } + } catch (_: Exception) {} return builder.toString() } } diff --git a/app/src/main/java/org/mifos/mobile/viewModels/RecentTransactionViewModel.kt b/app/src/main/java/org/mifos/mobile/viewModels/RecentTransactionViewModel.kt deleted file mode 100644 index e6cf3a80c..000000000 --- a/app/src/main/java/org/mifos/mobile/viewModels/RecentTransactionViewModel.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.mifos.mobile.viewModels - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.launch -import org.mifos.mobile.R -import org.mifos.mobile.repositories.RecentTransactionRepository -import org.mifos.mobile.utils.RecentTransactionUiState -import javax.inject.Inject - -@HiltViewModel -class RecentTransactionViewModel @Inject constructor(private val recentTransactionRepositoryImp: RecentTransactionRepository) : - ViewModel() { - - private val limit = 50 - private var loadmore = false - - private val _recentTransactionUiState = - MutableStateFlow(RecentTransactionUiState.Initial) - val recentTransactionUiState: StateFlow = _recentTransactionUiState - - fun loadRecentTransactions(loadmore: Boolean, offset: Int) { - this.loadmore = loadmore - loadRecentTransactions(offset, limit) - } - - private fun loadRecentTransactions(offset: Int, limit: Int) { - viewModelScope.launch { - _recentTransactionUiState.value = RecentTransactionUiState.Loading - recentTransactionRepositoryImp.recentTransactions(offset, limit).catch { - _recentTransactionUiState.value = - RecentTransactionUiState.Error(R.string.recent_transactions) - }.collect { - if (it.totalFilteredRecords == 0) { - _recentTransactionUiState.value = RecentTransactionUiState.EmptyTransaction - } else if (loadmore && it.pageItems.isNotEmpty()) { - _recentTransactionUiState.value = - RecentTransactionUiState.LoadMoreRecentTransactions(it.pageItems) - } else if (it.pageItems.isNotEmpty()) { - _recentTransactionUiState.value = - RecentTransactionUiState.RecentTransactions(it.pageItems) - } - } - } - } - -} \ No newline at end of file diff --git a/app/src/test/java/org/mifos/mobile/viewModels/RecentTransactionViewModelTest.kt b/app/src/test/java/org/mifos/mobile/viewModels/RecentTransactionViewModelTest.kt index 9353a64da..cecd058d6 100644 --- a/app/src/test/java/org/mifos/mobile/viewModels/RecentTransactionViewModelTest.kt +++ b/app/src/test/java/org/mifos/mobile/viewModels/RecentTransactionViewModelTest.kt @@ -2,13 +2,11 @@ package org.mifos.mobile.viewModels import CoroutineTestRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.Observer import app.cash.turbine.test import junit.framework.TestCase.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import okhttp3.ResponseBody import org.junit.* import org.junit.runner.RunWith import org.mifos.mobile.R @@ -17,13 +15,13 @@ import org.mifos.mobile.models.Transaction import org.mifos.mobile.models.client.Currency import org.mifos.mobile.models.client.Type import org.mifos.mobile.repositories.RecentTransactionRepository +import org.mifos.mobile.ui.recent_transactions.RecentTransactionViewModel import org.mifos.mobile.util.RxSchedulersOverrideRule import org.mifos.mobile.utils.RecentTransactionUiState import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner -import retrofit2.Response @RunWith(MockitoJUnitRunner::class) class RecentTransactionViewModelTest { @@ -79,7 +77,7 @@ class RecentTransactionViewModelTest { `when`(recentTransactionRepositoryImp.recentTransactions(offset, limit)) .thenReturn(flowOf(transactions)) viewModel.recentTransactionUiState.test { - viewModel.loadRecentTransactions(loadmore = false, offset) + viewModel.loadRecentTransactions(loadMore = false, offset) assertEquals(RecentTransactionUiState.Initial, awaitItem()) assertEquals(RecentTransactionUiState.Loading, awaitItem()) assertEquals(transactions.pageItems.let { RecentTransactionUiState.RecentTransactions(it) }, awaitItem()) @@ -107,7 +105,7 @@ class RecentTransactionViewModelTest { `when`(recentTransactionRepositoryImp.recentTransactions(offset, limit)) .thenReturn(flowOf(transactions)) viewModel.recentTransactionUiState.test { - viewModel.loadRecentTransactions(loadmore = false, offset) + viewModel.loadRecentTransactions(loadMore = false, offset) assertEquals(RecentTransactionUiState.Initial, awaitItem()) assertEquals(RecentTransactionUiState.Loading, awaitItem()) assertEquals(RecentTransactionUiState.EmptyTransaction, awaitItem()) @@ -137,7 +135,7 @@ class RecentTransactionViewModelTest { .thenReturn(flowOf(transactions)) viewModel.recentTransactionUiState.test { - viewModel.loadRecentTransactions(loadmore = false, offset) + viewModel.loadRecentTransactions(loadMore = false, offset) assertEquals(RecentTransactionUiState.Initial, awaitItem()) assertEquals(RecentTransactionUiState.Loading, awaitItem()) assertEquals(transactions.pageItems.let { RecentTransactionUiState.RecentTransactions(it) }, awaitItem())