diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 6b121bb3ab..ecd57d94a9 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -7,9 +7,11 @@ config: complexity: LongParameterList: ignoreDefaultParameters: true - ignoreAnnotated: ['Inject'] + ignoreAnnotated: ['Inject', 'Composable'] TooManyFunctions: active: false + LongMethod: + ignoreAnnotated: ['Composable'] coroutines: GlobalCoroutineUsage: @@ -22,6 +24,7 @@ style: MagicNumber: ignoreEnums: true ignorePropertyDeclaration: true + ignoreAnnotated: ['Composable'] SpacingBetweenPackageAndImports: active: true UnusedImports: @@ -31,3 +34,7 @@ style: ForbiddenSuppress: active: true rules: ['MaximumLineLength'] + +naming: + FunctionNaming: + ignoreAnnotated: [ 'Composable' ] diff --git a/example/build.gradle b/example/build.gradle index 862c762e3e..9416b1c33d 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -67,6 +67,11 @@ android { buildFeatures { buildConfig true viewBinding true + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.15" } sourceSets { @@ -137,6 +142,9 @@ dependencies { testImplementation sharedLibs.assertj.core testImplementation sharedLibs.androidx.arch.core.testing + implementation sharedLibs.androidx.compose.material + implementation sharedLibs.androidx.compose.ui.tooling + androidTestImplementation sharedLibs.assertj.core androidTestImplementation sharedLibs.androidx.arch.core.testing androidTestCompileOnly sharedLibs.glassfish.javax.annotation @@ -153,6 +161,9 @@ dependencies { debugImplementation sharedLibs.facebook.flipper.network.plugin // Coroutines + implementation platform("androidx.compose:compose-bom:2024.04.00") + implementation 'androidx.compose.material:material' + implementation sharedLibs.kotlinx.coroutines.core implementation sharedLibs.kotlinx.coroutines.android diff --git a/example/src/main/java/org/wordpress/android/fluxc/example/di/FragmentsModule.kt b/example/src/main/java/org/wordpress/android/fluxc/example/di/FragmentsModule.kt index 881147889a..94abb7d3b6 100644 --- a/example/src/main/java/org/wordpress/android/fluxc/example/di/FragmentsModule.kt +++ b/example/src/main/java/org/wordpress/android/fluxc/example/di/FragmentsModule.kt @@ -31,6 +31,7 @@ import org.wordpress.android.fluxc.example.ui.customer.search.WooCustomersSearch import org.wordpress.android.fluxc.example.ui.gateways.WooGatewaysFragment import org.wordpress.android.fluxc.example.ui.helpsupport.WooHelpSupportFragment import org.wordpress.android.fluxc.example.ui.leaderboards.WooLeaderboardsFragment +import org.wordpress.android.fluxc.example.ui.metadata.CustomFieldsFragment import org.wordpress.android.fluxc.example.ui.onboarding.WooOnboardingFragment import org.wordpress.android.fluxc.example.ui.orders.AddressEditDialogFragment import org.wordpress.android.fluxc.example.ui.orders.WooOrdersFragment @@ -200,4 +201,7 @@ internal interface FragmentsModule { @ContributesAndroidInjector fun provideWooAdminFragment(): WooAdminFragment + + @ContributesAndroidInjector + fun provideCustomFieldsFragment(): CustomFieldsFragment } diff --git a/example/src/main/java/org/wordpress/android/fluxc/example/ui/metadata/CustomFieldsFragment.kt b/example/src/main/java/org/wordpress/android/fluxc/example/ui/metadata/CustomFieldsFragment.kt new file mode 100644 index 0000000000..e1f8188a30 --- /dev/null +++ b/example/src/main/java/org/wordpress/android/fluxc/example/ui/metadata/CustomFieldsFragment.kt @@ -0,0 +1,230 @@ +package org.wordpress.android.fluxc.example.ui.metadata + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.lifecycleScope +import dagger.android.support.DaggerFragment +import org.wordpress.android.fluxc.example.ui.metadata.CustomFieldsViewModel.CustomFieldsState +import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId +import org.wordpress.android.fluxc.model.metadata.MetaDataParentItemType +import org.wordpress.android.fluxc.model.metadata.WCMetaData +import org.wordpress.android.fluxc.model.metadata.WCMetaDataValue +import org.wordpress.android.fluxc.store.MetaDataStore +import org.wordpress.android.fluxc.store.SiteStore +import javax.inject.Inject + +class CustomFieldsFragment : DaggerFragment() { + companion object { + private const val ARG_PARENT_ITEM_TYPE = "parentItemType" + private const val ARG_PARENT_ITEM_ID = "parentItemId" + private const val ARG_SITE_ID = "siteId" + + fun newInstance( + siteId: LocalId, + parentItemId: Long, + parentItemType: MetaDataParentItemType + ) = CustomFieldsFragment().apply { + arguments = Bundle().apply { + putInt(ARG_SITE_ID, siteId.value) + putLong(ARG_PARENT_ITEM_ID, parentItemId) + putString(ARG_PARENT_ITEM_TYPE, parentItemType.name) + } + } + } + + @Inject lateinit var metaDataStore: MetaDataStore + + @Inject lateinit var siteStore: SiteStore + + private val viewModel by lazy { + CustomFieldsViewModel( + coroutineScope = lifecycleScope, + site = siteStore.getSiteByLocalId(requireArguments().getInt(ARG_SITE_ID))!!, + parentItemId = requireArguments().getLong(ARG_PARENT_ITEM_ID), + parentItemType = MetaDataParentItemType.valueOf( + requireArguments().getString(ARG_PARENT_ITEM_TYPE)!! + ), + metaDataStore = metaDataStore + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + setContent { + MaterialTheme { + CustomFieldsScreen(viewModel.state) + } + } + } + } +} + +@Composable +private fun CustomFieldsScreen(state: CustomFieldsState) { + when (state) { + CustomFieldsState.Loading -> CircularProgressIndicator( + modifier = Modifier + .fillMaxSize() + .wrapContentSize() + ) + + is CustomFieldsState.Error -> ErrorView(state) + is CustomFieldsState.Loaded -> ContentView(state) + } +} + +@Composable +private fun ErrorView(state: CustomFieldsState.Error) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxSize() + ) { + Text("An error occurred!") + Text(state.message) + Spacer(modifier = Modifier.height(16.dp)) + Button(state.onRetry) { + Text("Retry") + } + } +} + +@Composable +private fun ContentView(state: CustomFieldsState.Loaded) { + var fieldBeingEdited by remember { mutableStateOf(null) } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button({ fieldBeingEdited = WCMetaData(id = 0L, "", "") }) { + Text("Add") + } + Button(onClick = state.onSave, enabled = state.hasChanges) { + Text("Save") + } + } + + state.customFields.forEach { customField -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column { + Text( + text = customField.key, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold + ) + + Text( + text = customField.valueAsString, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2 + ) + } + Spacer(modifier = Modifier.weight(1f)) + IconButton({ fieldBeingEdited = customField }) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } + IconButton({ state.onDelete(customField) }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + } + } + } + + if (fieldBeingEdited != null) { + val field = fieldBeingEdited!! + Dialog(onDismissRequest = { fieldBeingEdited = null }) { + Column( + modifier = Modifier + .background(MaterialTheme.colors.surface) + .padding(8.dp) + ) { + Text("Edit field") + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + label = { Text("Key") }, + value = field.key, + onValueChange = { + fieldBeingEdited = field.copy(key = it) + }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + label = { Text("Value") }, + value = field.valueAsString, + onValueChange = { + fieldBeingEdited = field.copy( + value = WCMetaDataValue.StringValue(it) + ) + }, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { + if (field.id == 0L) { + state.onAdd(field) + } else { + state.onEdit(field) + } + fieldBeingEdited = null + }, + modifier = Modifier.align(Alignment.End) + ) { + Text("Done") + } + } + } + } +} diff --git a/example/src/main/java/org/wordpress/android/fluxc/example/ui/metadata/CustomFieldsViewModel.kt b/example/src/main/java/org/wordpress/android/fluxc/example/ui/metadata/CustomFieldsViewModel.kt new file mode 100644 index 0000000000..2b0b3ccf25 --- /dev/null +++ b/example/src/main/java/org/wordpress/android/fluxc/example/ui/metadata/CustomFieldsViewModel.kt @@ -0,0 +1,170 @@ +package org.wordpress.android.fluxc.example.ui.metadata + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.metadata.MetaDataParentItemType +import org.wordpress.android.fluxc.model.metadata.UpdateMetadataRequest +import org.wordpress.android.fluxc.model.metadata.WCMetaData +import org.wordpress.android.fluxc.store.MetaDataStore + +class CustomFieldsViewModel( + private val coroutineScope: CoroutineScope, + private val site: SiteModel, + private val parentItemId: Long, + private val parentItemType: MetaDataParentItemType, + private val metaDataStore: MetaDataStore +) { + private val loadingState = MutableStateFlow(LoadingState.Loaded) + var state by mutableStateOf(CustomFieldsState.Loading) + private set + + private val pendingUpdateRequest = MutableStateFlow( + UpdateMetadataRequest( + parentItemId = parentItemId, + parentItemType = parentItemType, + insertedMetadata = emptyList(), + updatedMetadata = emptyList(), + deletedMetadata = emptyList() + ) + ) + + init { + observeLoadingState() + loadCustomFields() + } + + private fun observeLoadingState() { + val customFields = combine( + metaDataStore.observeDisplayableMetaData( + site, + parentItemId + ), + pendingUpdateRequest + ) { customFields, pendingUpdateRequest -> + customFields.filterNot { it in pendingUpdateRequest.deletedMetadata } + .map { field -> + pendingUpdateRequest.updatedMetadata.find { it.key == field.key } + ?: field + } + pendingUpdateRequest.insertedMetadata + } + + combine( + loadingState, + customFields, + pendingUpdateRequest.map { + it.insertedMetadata.isNotEmpty() || + it.updatedMetadata.isNotEmpty() || + it.deletedMetadata.isNotEmpty() + } + ) { loadingState, metaData, hasChanges -> + when (loadingState) { + LoadingState.Loading -> CustomFieldsState.Loading + LoadingState.Loaded -> CustomFieldsState.Loaded( + customFields = metaData, + onDelete = { field -> + pendingUpdateRequest.update { + it.copy( + deletedMetadata = it.deletedMetadata + field + ) + } + }, + onEdit = { field -> + pendingUpdateRequest.update { + it.copy( + updatedMetadata = it.updatedMetadata + field + ) + } + }, + onAdd = { field -> + pendingUpdateRequest.update { + it.copy( + insertedMetadata = it.insertedMetadata + field + ) + } + }, + onSave = { saveChanges() }, + hasChanges = hasChanges + ) + + is LoadingState.Error -> CustomFieldsState.Error(loadingState.message) { + loadCustomFields() + } + } + }.onEach { + state = it + }.launchIn(coroutineScope) + } + + private fun loadCustomFields() { + coroutineScope.launch { + launch { + loadingState.value = LoadingState.Loading + metaDataStore.refreshMetaData(site, parentItemId, parentItemType).let { + if (it.isError) { + loadingState.value = LoadingState.Error( + message = it.error?.message ?: "Unknown error" + ) + } else { + loadingState.value = LoadingState.Loaded + } + } + } + } + } + + private fun saveChanges() { + coroutineScope.launch { + loadingState.value = LoadingState.Loading + val request = pendingUpdateRequest.value + metaDataStore.updateMetaData(site, request).let { result -> + if (result.isError) { + loadingState.value = LoadingState.Error( + message = result.error?.message ?: "Unknown error" + ) + } else { + pendingUpdateRequest.update { + it.copy( + insertedMetadata = emptyList(), + updatedMetadata = emptyList(), + deletedMetadata = emptyList() + ) + } + loadingState.value = LoadingState.Loaded + } + } + } + } + + private sealed interface LoadingState { + data object Loading : LoadingState + data object Loaded : LoadingState + data class Error(val message: String) : LoadingState + } + + sealed interface CustomFieldsState { + data object Loading : CustomFieldsState + data class Loaded( + val customFields: List, + val onDelete: (WCMetaData) -> Unit, + val onEdit: (WCMetaData) -> Unit, + val onAdd: (WCMetaData) -> Unit, + val onSave: () -> Unit, + val hasChanges: Boolean = false + ) : CustomFieldsState + + data class Error( + val message: String, + val onRetry: () -> Unit + ) : CustomFieldsState + } +} diff --git a/example/src/main/java/org/wordpress/android/fluxc/example/ui/orders/WooOrdersFragment.kt b/example/src/main/java/org/wordpress/android/fluxc/example/ui/orders/WooOrdersFragment.kt index 98d1db7c2e..07405d58fc 100644 --- a/example/src/main/java/org/wordpress/android/fluxc/example/ui/orders/WooOrdersFragment.kt +++ b/example/src/main/java/org/wordpress/android/fluxc/example/ui/orders/WooOrdersFragment.kt @@ -8,7 +8,6 @@ import android.view.ViewGroup import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import org.greenrobot.eventbus.Subscribe @@ -22,6 +21,7 @@ import org.wordpress.android.fluxc.example.databinding.FragmentWooOrdersBinding import org.wordpress.android.fluxc.example.prependToLog import org.wordpress.android.fluxc.example.replaceFragment import org.wordpress.android.fluxc.example.ui.StoreSelectingFragment +import org.wordpress.android.fluxc.example.ui.metadata.CustomFieldsFragment import org.wordpress.android.fluxc.example.ui.orders.AddressEditDialogFragment.AddressType import org.wordpress.android.fluxc.example.ui.orders.AddressEditDialogFragment.AddressType.BILLING import org.wordpress.android.fluxc.example.ui.orders.AddressEditDialogFragment.AddressType.SHIPPING @@ -29,10 +29,11 @@ import org.wordpress.android.fluxc.example.utils.showSingleLineDialog import org.wordpress.android.fluxc.example.utils.showTwoButtonsDialog import org.wordpress.android.fluxc.generated.WCOrderActionBuilder import org.wordpress.android.fluxc.model.OrderAttributionInfo -import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.OrderEntity +import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.WCOrderShipmentTrackingModel import org.wordpress.android.fluxc.model.WCOrderStatusModel +import org.wordpress.android.fluxc.model.metadata.MetaDataParentItemType import org.wordpress.android.fluxc.model.order.OrderAddress import org.wordpress.android.fluxc.model.order.UpdateOrderRequest import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.CoreOrderStatus @@ -629,6 +630,26 @@ class WooOrdersFragment : StoreSelectingFragment(), WCAddOrderShipmentTrackingDi } } } + + customFields.setOnClickListener { + selectedSite?.let { site -> + lifecycleScope.launch { + val orderId = showSingleLineDialog( + activity = requireActivity(), + message = "Please enter the order id", + isNumeric = true + )?.toLongOrNull() ?: return@launch + + replaceFragment( + CustomFieldsFragment.newInstance( + siteId = site.localId(), + parentItemId = orderId, + parentItemType = MetaDataParentItemType.ORDER + ) + ) + } + } + } } } diff --git a/example/src/main/java/org/wordpress/android/fluxc/example/ui/products/WooProductsFragment.kt b/example/src/main/java/org/wordpress/android/fluxc/example/ui/products/WooProductsFragment.kt index 092a429271..86bb996e7d 100644 --- a/example/src/main/java/org/wordpress/android/fluxc/example/ui/products/WooProductsFragment.kt +++ b/example/src/main/java/org/wordpress/android/fluxc/example/ui/products/WooProductsFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -22,11 +23,13 @@ import org.wordpress.android.fluxc.example.databinding.FragmentWooProductsBindin import org.wordpress.android.fluxc.example.prependToLog import org.wordpress.android.fluxc.example.replaceFragment import org.wordpress.android.fluxc.example.ui.StoreSelectingFragment +import org.wordpress.android.fluxc.example.ui.metadata.CustomFieldsFragment import org.wordpress.android.fluxc.example.utils.showSingleLineDialog import org.wordpress.android.fluxc.generated.WCProductActionBuilder import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.WCProductCategoryModel import org.wordpress.android.fluxc.model.WCProductImageModel +import org.wordpress.android.fluxc.model.metadata.MetaDataParentItemType import org.wordpress.android.fluxc.network.rest.wpcom.wc.product.CoreProductStockStatus import org.wordpress.android.fluxc.store.MediaStore import org.wordpress.android.fluxc.store.WCAddonsStore @@ -655,6 +658,26 @@ class WooProductsFragment : StoreSelectingFragment() { } } } + + binding?.customFields?.setOnClickListener { + selectedSite?.let { site -> + lifecycleScope.launch { + val orderId = showSingleLineDialog( + activity = requireActivity(), + message = "Please enter the product id", + isNumeric = true + )?.toLongOrNull() ?: return@launch + + replaceFragment( + CustomFieldsFragment.newInstance( + siteId = site.localId(), + parentItemId = orderId, + parentItemType = MetaDataParentItemType.PRODUCT + ) + ) + } + } + } } /** diff --git a/example/src/main/res/layout/fragment_woo_orders.xml b/example/src/main/res/layout/fragment_woo_orders.xml index 4dd83394e2..d868d5a339 100644 --- a/example/src/main/res/layout/fragment_woo_orders.xml +++ b/example/src/main/res/layout/fragment_woo_orders.xml @@ -173,5 +173,12 @@ android:layout_height="wrap_content" android:enabled="false" android:text="Fetch Order Attribution" /> + +