diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 9f5767a0da1e..624aac556cb6 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -252,6 +252,10 @@ android:name=".ui.accounts.applicationpassword.ApplicationPasswordsListActivity" android:label="@string/application_password_info_title" android:theme="@style/WordPress.NoActionBar" /> + , // Avoid adding the last field to the end of the card and follow regular alignment instead val skipEndPositioning: Boolean = false, - val data: Any? = null + val data: Any? = null, + val indentation: Dp = 0.dp // Used to indent items ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewItemCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewItemCard.kt index b09012025ce8..c2c72a63cac2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewItemCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewItemCard.kt @@ -49,7 +49,7 @@ fun DataViewItemCard( ) { Row( modifier = Modifier - .padding(16.dp) + .padding(start = 16.dp + item.indentation, end = 16.dp, top = 16.dp , bottom = 16.dp) .fillMaxWidth(), ) { item.image?.let { image -> diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java index 4993b9669db5..9f54e9516cfc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/SiteSettingsFragment.java @@ -1143,14 +1143,8 @@ private void showTaxonomies(List taxonomies) pref.setTitle(taxonomy.getName()); pref.setKey(taxonomy.getSlug()); pref.setOnPreferenceClickListener(preference -> { - // TODO: Create generic taxonomies DataView and call it from here - // We are not accepting the taxonomy name as a parameter yet - // So Categories and Tags are still hardcoded - if ("category".equals(taxonomy.getSlug())) { - ActivityLauncher.showCategoriesList(getActivity(), mSite); - } else if ("post_tag".equals(taxonomy.getSlug())) { - SiteSettingsTagListActivity.showTagList(getActivity(), mSite); - } + ActivityLauncher.showTermsList(getActivity(), taxonomy.getSlug(), taxonomy.getName(), + taxonomy.getHierarchical()); return false; } ); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsDataViewActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsDataViewActivity.kt new file mode 100644 index 000000000000..399d55270269 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsDataViewActivity.kt @@ -0,0 +1,299 @@ +package org.wordpress.android.ui.taxonomies + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.ui.dataview.DataViewScreen +import org.wordpress.android.ui.main.BaseAppCompatActivity +import org.wordpress.android.util.AppLog +import uniffi.wp_api.AnyTermWithEditContext +import javax.inject.Inject + +@AndroidEntryPoint +class TermsDataViewActivity : BaseAppCompatActivity() { + @Inject + lateinit var appLogWrapper: AppLogWrapper + + private val viewModel by viewModels() + + private lateinit var composeView: ComposeView + private lateinit var navController: NavHostController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val taxonomySlug = intent.getStringExtra(TAXONOMY_SLUG) + val isHierarchical = intent.getBooleanExtra(IS_HIERARCHICAL, false) + val taxonomyName = intent.getStringExtra(TAXONOMY_NAME) ?: "" + if (taxonomySlug == null) { + appLogWrapper.e(AppLog.T.API, "Error: No taxonomy selected") + finish() + return + } + + viewModel.initialize(taxonomySlug, isHierarchical) + + composeView = ComposeView(this) + setContentView( + composeView.apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + this.isForceDarkAllowed = false + } + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + NavigableContent(taxonomyName) + } + } + ) + } + + private enum class TermScreen { + List, + Detail + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun NavigableContent(taxonomyName: String) { + navController = rememberNavController() + val listTitle = taxonomyName + val titleState = remember { mutableStateOf(listTitle) } + + AppThemeM3 { + Scaffold( + topBar = { + TopAppBar( + title = { Text(titleState.value) }, + navigationIcon = { + IconButton(onClick = { + if (navController.previousBackStackEntry != null) { + navController.navigateUp() + } else { + finish() + } + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + } + ) + }, + ) { contentPadding -> + NavHost( + navController = navController, + startDestination = TermScreen.List.name + ) { + composable(route = TermScreen.List.name) { + titleState.value = listTitle + ShowListScreen( + navController, + modifier = Modifier.padding(contentPadding) + ) + } + + composable(route = TermScreen.Detail.name) { + navController.previousBackStackEntry?.savedStateHandle?.let { handle -> + val termId = handle.get(KEY_TERM_ID) + if (termId != null) { + viewModel.getTerm(termId)?.let { term -> + titleState.value = term.name + ShowTermDetailScreen( + allTerms = viewModel.getAllTerms(), + term = term, + modifier = Modifier.padding(contentPadding) + ) + } + } + } + } + } + } + } + } + + @Composable + private fun ShowListScreen( + navController: NavHostController, + modifier: Modifier + ) { + DataViewScreen( + uiState = viewModel.uiState.collectAsState(), + supportedFilters = viewModel.getSupportedFilters(), + supportedSorts = viewModel.getSupportedSorts(), + onRefresh = { + viewModel.onRefreshData() + }, + onFetchMore = { + viewModel.onFetchMoreData() + }, + onSearchQueryChange = { query -> + viewModel.onSearchQueryChange(query) + }, + onItemClick = { item -> + viewModel.onItemClick(item) + (item.data as? AnyTermWithEditContext)?.let { term -> + navController.currentBackStackEntry?.savedStateHandle?.set( + key = KEY_TERM_ID, + value = term.id + ) + navController.navigate(route = TermScreen.Detail.name) + } + }, + onFilterClick = { filter -> + viewModel.onFilterClick(filter) + }, + onSortClick = { sort -> + viewModel.onSortClick(sort) + }, + onSortOrderClick = { order -> + viewModel.onSortOrderClick(order) + }, + emptyView = viewModel.emptyView, + modifier = modifier + ) + } + + @Composable + private fun ShowTermDetailScreen( + allTerms: List, + term: AnyTermWithEditContext, + modifier: Modifier + ) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + TermDetailsCard(allTerms, term) + } + } + + @Composable + private fun TermDetailsCard(allTerms: List, term: AnyTermWithEditContext) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + DetailRow( + label = stringResource(R.string.term_name_label), + value = term.name + ) + + DetailRow( + label = stringResource(R.string.term_slug_label), + value = term.slug + ) + + DetailRow( + label = stringResource(R.string.term_description_label), + value = term.description + ) + + DetailRow( + label = stringResource(R.string.term_count_label), + value = term.count.toString() + ) + + term.parent?.let { parentId -> + val parentName = allTerms.firstOrNull { it.id == parentId }?.name + parentName?.let { + DetailRow( + label = stringResource(R.string.term_parent_label), + value = parentName + ) + } + } + } + } + } + + @Composable + private fun DetailRow( + label: String, + value: String + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.3f) + ) + + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(0.7f) + ) + } + } + + companion object { + private const val TAXONOMY_SLUG = "taxonomy_slug" + private const val IS_HIERARCHICAL = "is_hierarchical" + private const val TAXONOMY_NAME = "taxonomy_name" + private const val KEY_TERM_ID = "termId" + + fun getIntent(context: Context, taxonomySlug: String, taxonomyName: String, isHierarchical: Boolean): Intent = + Intent(context, TermsDataViewActivity::class.java).apply { + putExtra(TAXONOMY_SLUG, taxonomySlug) + putExtra(TAXONOMY_NAME, taxonomyName) + putExtra(IS_HIERARCHICAL, isHierarchical) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt new file mode 100644 index 000000000000..05476bf5d764 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt @@ -0,0 +1,254 @@ +package org.wordpress.android.ui.taxonomies + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.ui.unit.dp +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.store.TaxonomyStore.DEFAULT_TAXONOMY_CATEGORY +import org.wordpress.android.fluxc.store.TaxonomyStore.DEFAULT_TAXONOMY_TAG +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.modules.IO_THREAD +import org.wordpress.android.modules.UI_THREAD +import org.wordpress.android.ui.dataview.DataViewDropdownItem +import org.wordpress.android.ui.dataview.DataViewFieldType +import org.wordpress.android.ui.dataview.DataViewItem +import org.wordpress.android.ui.dataview.DataViewItemField +import org.wordpress.android.ui.dataview.DataViewViewModel +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.NetworkUtilsWrapper +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.TermEndpointType +import uniffi.wp_api.TermListParams +import uniffi.wp_api.AnyTermWithEditContext +import uniffi.wp_api.WpApiParamOrder +import uniffi.wp_api.WpApiParamTermsOrderBy +import javax.inject.Inject +import javax.inject.Named + +private const val INDENTATION_IN_DP = 10 + +@HiltViewModel +class TermsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val wpApiClientProvider: WpApiClientProvider, + private val appLogWrapper: AppLogWrapper, + private val selectedSiteRepository: SelectedSiteRepository, + accountStore: AccountStore, + @Named(UI_THREAD) mainDispatcher: CoroutineDispatcher, + sharedPrefs: SharedPreferences, + networkUtilsWrapper: NetworkUtilsWrapper, + @Named(IO_THREAD) ioDispatcher: CoroutineDispatcher, +) : DataViewViewModel( + mainDispatcher = mainDispatcher, + appLogWrapper = appLogWrapper, + sharedPrefs = sharedPrefs, + networkUtilsWrapper = networkUtilsWrapper, + selectedSiteRepository = selectedSiteRepository, + accountStore = accountStore, + ioDispatcher = ioDispatcher +) { + private var taxonomySlug: String = "" + private var isHierarchical: Boolean = false + private var currentTerms = listOf() + + fun initialize(taxonomySlug: String, isHierarchical: Boolean) { + this.taxonomySlug = taxonomySlug + this.isHierarchical = isHierarchical + initialize() + } + + override fun getSupportedSorts(): List = if (isHierarchical) { + listOf() + } else { + listOf( + DataViewDropdownItem(id = SORT_BY_NAME_ID, titleRes = R.string.term_sort_by_name), + DataViewDropdownItem(id = SORT_BY_COUNT_ID, titleRes = R.string.term_sort_by_count), + ) + } + + override suspend fun performNetworkRequest( + page: Int, + searchQuery: String, + filter: DataViewDropdownItem?, + sortOrder: WpApiParamOrder, + sortBy: DataViewDropdownItem?, + ): List = withContext(ioDispatcher) { + val selectedSite = selectedSiteRepository.getSelectedSite() + + if (selectedSite == null) { + val error = "No selected site to get Terms" + appLogWrapper.e(AppLog.T.API, error) + onError(error) + return@withContext emptyList() + } + + val allTerms = getTermsList(selectedSite, page, searchQuery, sortOrder, sortBy) + currentTerms = allTerms + + // Sort the results hierarchically if necessary + val sortedTerms = if (isHierarchical) { + sortByHierarchy(terms = allTerms) + } else { + allTerms + } + + // Convert to DataViewItems and return + sortedTerms.map { term -> + // Do not use hierarchical indentation when the user is searching terms + convertToDataViewItem(allTerms, term, isHierarchical && searchQuery.isEmpty()) + } + } + + private fun sortByHierarchy(terms: List): List { + val result = mutableListOf() + val termsById = terms.associateBy { it.id } + val visited = mutableSetOf() + + fun addTermWithChildren(term: AnyTermWithEditContext) { + if (term.id in visited) return + visited.add(term.id) + result.add(term) + + // Find and add all direct children + terms.filter { it.parent == term.id } + .sortedBy { it.name } + .forEach { child -> + addTermWithChildren(child) + } + } + + // First, add all root terms (those with parent == 0 or no parent in the list) + terms.filter { it.parent == 0L || termsById[it.parent] == null } + .sortedBy { it.name } + .forEach { rootTerm -> + addTermWithChildren(rootTerm) + } + + return result + } + + fun getTerm(termId: Long): AnyTermWithEditContext? { + val item = uiState.value.items.firstOrNull { + (it.data as? AnyTermWithEditContext)?.id == termId + } + return item?.data as? AnyTermWithEditContext + } + + fun getAllTerms(): List = currentTerms + + private fun convertToDataViewItem( + allTerms: List, + term: AnyTermWithEditContext, + useHierarchicalIndentation: Boolean + ): DataViewItem { + val indentation = if (useHierarchicalIndentation) { + getHierarchicalIndentation(allTerms, term) + } else { + 0 + } + return DataViewItem( + id = term.id, + image = null, + title = term.name, + fields = listOf( + DataViewItemField( + value = context.resources.getString(R.string.term_count, term.count), + valueType = DataViewFieldType.TEXT, + ) + ), + skipEndPositioning = true, + data = term, + indentation = (indentation * INDENTATION_IN_DP).dp + ) + } + + + /** + * Returns an integer representation of the hierarchical indentation for the given term. + */ + private fun getHierarchicalIndentation( + allTerms: List, + term: AnyTermWithEditContext? + ): Int { + if (term == null) return 0 + + val termsById = allTerms.associateBy { it.id } + var indentation = 0 + var currentParentId = term.parent + + while (currentParentId != null && currentParentId > 0) { + val parent = termsById[currentParentId] + if (parent == null) break + indentation++ + currentParentId = parent.parent + } + + return indentation + } + + private suspend fun getTermsList( + site: SiteModel, + page: Int, + searchQuery: String, + sortOrder: WpApiParamOrder, + sortBy: DataViewDropdownItem? + ): List { + val wpApiClient = wpApiClientProvider.getWpApiClient(site) + + val termEndpointType = when (taxonomySlug) { + DEFAULT_TAXONOMY_CATEGORY -> TermEndpointType.Categories + DEFAULT_TAXONOMY_TAG -> TermEndpointType.Tags + else -> TermEndpointType.Custom(taxonomySlug) + } + + val termsResponse = wpApiClient.request { requestBuilder -> + requestBuilder.terms().listWithEditContext( + termEndpointType = termEndpointType, + params = TermListParams( + page = page.toUInt(), + search = searchQuery, + order = when (sortOrder) { + WpApiParamOrder.ASC -> WpApiParamOrder.ASC + WpApiParamOrder.DESC -> WpApiParamOrder.DESC + }, + orderby = if (sortBy == null) { + null + } else { + if (sortBy.id == SORT_BY_COUNT_ID) { + WpApiParamTermsOrderBy.COUNT + } else { + WpApiParamTermsOrderBy.NAME // default + } + } + ) + ) + } + + return when (termsResponse) { + is WpRequestResult.Success -> { + appLogWrapper.d(AppLog.T.API, "Fetched ${termsResponse.response.data.size} terms") + termsResponse.response.data + } + + else -> { + val error = "Error getting Terms list for taxonomy: $taxonomySlug" + appLogWrapper.e(AppLog.T.API, error) + onError(error) + emptyList() + } + } + } + + companion object { + private const val SORT_BY_NAME_ID = 1L + private const val SORT_BY_COUNT_ID = 2L + } +} diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 2814e693a892..18a22ddc45ee 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5086,4 +5086,14 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> There\'s nothing here + + + Name: + Slug: + Description: + Count: + Count: %1$d + Parent: + Name + Count diff --git a/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt new file mode 100644 index 000000000000..3fcebaa07a85 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt @@ -0,0 +1,96 @@ +package org.wordpress.android.ui.taxonomies + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.R +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.store.TaxonomyStore.DEFAULT_TAXONOMY_CATEGORY +import org.wordpress.android.fluxc.store.TaxonomyStore.DEFAULT_TAXONOMY_TAG +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.util.NetworkUtilsWrapper + +@ExperimentalCoroutinesApi +class TermsViewModelTest : BaseUnitTest() { + @Mock + private lateinit var context: Context + + @Mock + private lateinit var wpApiClientProvider: WpApiClientProvider + + @Mock + private lateinit var appLogWrapper: AppLogWrapper + + @Mock + private lateinit var selectedSiteRepository: SelectedSiteRepository + + @Mock + private lateinit var accountStore: AccountStore + + @Mock + private lateinit var sharedPrefs: SharedPreferences + + @Mock + private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + + @Before + fun setUp() { + // Minimal setup - add more mocks in individual tests as needed + } + + private fun createViewModel(): TermsViewModel { + return TermsViewModel( + context = context, + wpApiClientProvider = wpApiClientProvider, + appLogWrapper = appLogWrapper, + selectedSiteRepository = selectedSiteRepository, + accountStore = accountStore, + mainDispatcher = testDispatcher(), + sharedPrefs = sharedPrefs, + networkUtilsWrapper = networkUtilsWrapper, + ioDispatcher = testDispatcher() + ) + } + + @Test + fun `getSupportedSorts returns empty list for hierarchical taxonomies`() { + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + + val supportedSorts = viewModel.getSupportedSorts() + + assertThat(supportedSorts).isEmpty() + } + + @Test + fun `getSupportedSorts returns sort options for non-hierarchical taxonomies`() { + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_TAG, isHierarchical = false) + + val supportedSorts = viewModel.getSupportedSorts() + + assertThat(supportedSorts).hasSize(2) + assertThat(supportedSorts[0].titleRes).isEqualTo(R.string.term_sort_by_name) + assertThat(supportedSorts[1].titleRes).isEqualTo(R.string.term_sort_by_count) + } + + @Test + fun `network unavailable sets offline state`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.loadingState) + .isEqualTo(org.wordpress.android.ui.dataview.LoadingState.OFFLINE) + } +}