diff --git a/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt index 31c889680cc1..265476d53237 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt @@ -66,18 +66,18 @@ open class DataViewViewModel @Inject constructor( private var page = 0 private var canLoadMore = true - lateinit var wpComApiClient: WpComApiClient + // TODO this is strictly for wp.com sites, we'll need different auth for self-hosted + val wpComApiClient: WpComApiClient by lazy { + WpComApiClient( + WpAuthenticationProvider.staticWithAuth( + WpAuthentication.Bearer(token = accountStore.accessToken!!) + ) + ) + } init { appLogWrapper.d(AppLog.T.MAIN, "$logTag init") launch { - // TODO this is strictly for wp.com sites, we'll need different auth for self-hosted - wpComApiClient = WpComApiClient( - WpAuthenticationProvider.staticWithAuth( - WpAuthentication.Bearer(token = accountStore.accessToken!!) - ) - ) - _itemSortBy.value = getDefaultSort() fetchData() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt index afbb68e30fb2..bd6024bd3722 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscriberDetailScreen.kt @@ -1,6 +1,7 @@ package org.wordpress.android.ui.subscribers import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,7 +19,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.MailOutline import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -27,28 +27,38 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.wordpress.android.R +import org.wordpress.android.models.wrappers.SimpleDateFormatWrapper import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.dataview.compose.RemoteImage import org.wordpress.android.ui.subscribers.SubscribersViewModel.Companion.displayNameOrEmail +import uniffi.wp_api.IndividualSubscriberStats import uniffi.wp_api.Subscriber +import uniffi.wp_api.SubscriberCountry import java.util.Date @Composable fun SubscriberDetailScreen( subscriber: Subscriber, - modifier: Modifier = Modifier + onUrlClick: (String) -> Unit, + onEmailClick: (String) -> Unit, + modifier: Modifier = Modifier, + subscriberStats: State? = null ) { Column( modifier = modifier @@ -61,7 +71,9 @@ fun SubscriberDetailScreen( Spacer(modifier = Modifier.height(24.dp)) - EmailStatsCard() + subscriberStats?.value?.let { stats -> + EmailStatsCard(subscriberStats = stats) + } Spacer(modifier = Modifier.height(16.dp)) @@ -69,7 +81,11 @@ fun SubscriberDetailScreen( Spacer(modifier = Modifier.height(16.dp)) - SubscriberDetailsCard(subscriber) + SubscriberDetailsCard( + subscriber = subscriber, + onUrlClick = onUrlClick, + onEmailClick = onEmailClick + ) Spacer(modifier = Modifier.height(32.dp)) @@ -112,22 +128,13 @@ fun ProfileHeader( textAlign = TextAlign.Center ) } - // TODO remove this once we have actual data - Spacer(modifier = Modifier.height(12.dp)) - Row { - Text( - text = "Note: Displaying dummy data", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - } } } @Composable -fun EmailStatsCard() { +fun EmailStatsCard( + subscriberStats: IndividualSubscriberStats +) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), @@ -142,19 +149,20 @@ fun EmailStatsCard() { StatItem( icon = Icons.Default.Email, label = stringResource(R.string.subscribers_emails_sent_label), - value = "100" + value = subscriberStats.emailsSent.toString() ) StatItem( - icon = Icons.Default.MailOutline, + icon = ImageVector.vectorResource(id = R.drawable.ic_email_open), label = stringResource(R.string.subscribers_opened_label), - value = "10" + value = subscriberStats.uniqueOpens.toString() ) StatItem( icon = Icons.Default.Check, label = stringResource(R.string.subscribers_clicked_label), - value = "10%" + value = subscriberStats.uniqueClicks.toString() ) } + Spacer(modifier = Modifier.height(12.dp)) } } @@ -212,22 +220,29 @@ fun NewsletterSubscriptionCard(subscriber: Subscriber) { DetailRow( label = stringResource(R.string.subscribers_date_label), - value = subscriber.dateSubscribed.toString() + value = SimpleDateFormatWrapper().getDateInstance().format(subscriber.dateSubscribed) ) - Spacer(modifier = Modifier.height(12.dp)) + if (subscriber.plans?.isNotEmpty() == true) { + val plan = subscriber.plans!!.first() + Spacer(modifier = Modifier.height(12.dp)) - DetailRow( - label = stringResource(R.string.subscribers_plan_label), - value = "???", - valueColor = MaterialTheme.colorScheme.primary - ) + DetailRow( + label = stringResource(R.string.subscribers_plan_label), + value = plan.title, + valueColor = MaterialTheme.colorScheme.primary + ) + } } } } @Composable -fun SubscriberDetailsCard(subscriber: Subscriber) { +private fun SubscriberDetailsCard( + subscriber: Subscriber, + onUrlClick: (String) -> Unit, + onEmailClick: (String) -> Unit, +) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), @@ -250,34 +265,46 @@ fun SubscriberDetailsCard(subscriber: Subscriber) { DetailRow( label = stringResource(R.string.subscribers_email_label), value = subscriber.emailAddress, - valueColor = MaterialTheme.colorScheme.primary + valueColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { + onEmailClick(subscriber.emailAddress) + } ) Spacer(modifier = Modifier.height(12.dp)) - DetailRow( - label = stringResource(R.string.subscribers_country_label), - value = "???" - ) + subscriber.country?.name?.let { countryName -> + DetailRow( + label = stringResource(R.string.subscribers_country_label), + value = countryName + ) + Spacer(modifier = Modifier.height(12.dp)) + } - Spacer(modifier = Modifier.height(12.dp)) - - DetailRow( - label = stringResource(R.string.subscribers_site_label), - value = "???", - valueColor = MaterialTheme.colorScheme.primary - ) + subscriber.url?.let { url -> + DetailRow( + label = stringResource(R.string.subscribers_site_label), + value = url, + valueColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { + onUrlClick(url) + } + ) + } } } } @Composable -fun DetailRow( +private fun DetailRow( label: String, value: String, - valueColor: Color = MaterialTheme.colorScheme.onSurface + modifier: Modifier = Modifier, + valueColor: Color = MaterialTheme.colorScheme.onSurface, ) { - Column { + Column( + modifier = modifier + ) { Text( text = label, style = MaterialTheme.typography.bodyMedium, @@ -294,7 +321,7 @@ fun DetailRow( } @Composable -fun DeleteSubscriberButton() { +private fun DeleteSubscriberButton() { Button( onClick = { /* Handle delete action */ }, modifier = Modifier.fillMaxWidth(), @@ -322,15 +349,31 @@ fun DeleteSubscriberButton() { fun SubscriberDetailScreenPreview() { val subscriber = Subscriber( userId = 0L, + subscriptionId = 0u, displayName = "User Name", emailAddress = "email@example.com", - emailSubscriptionId = 0u, + isEmailSubscriber = true, + url = "https://example.com", dateSubscribed = Date(), subscriptionStatus = "Subscribed", avatar = "", + country = SubscriberCountry("US", "United States"), + plans = emptyList(), + ) + + val subscriberStats = IndividualSubscriberStats( + emailsSent = 10u, + uniqueOpens = 5u, + uniqueClicks = 3u, + blogRegistrationDate = Date().toString(), ) AppThemeM3 { - SubscriberDetailScreen(subscriber) + SubscriberDetailScreen( + subscriber = subscriber, + subscriberStats = remember { mutableStateOf(subscriberStats) }, + onUrlClick = {}, + onEmailClick = {} + ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersActivity.kt index 619581ed3d17..049464de0648 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersActivity.kt @@ -25,6 +25,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.R +import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.dataview.DataViewScreen import org.wordpress.android.ui.main.BaseAppCompatActivity @@ -131,7 +132,14 @@ class SubscribersActivity : BaseAppCompatActivity() { titleState.value = subscriber.displayNameOrEmail() SubscriberDetailScreen( subscriber = subscriber, - modifier = Modifier.padding(contentPadding) + onEmailClick = { email -> + onEmailClick(email) + }, + onUrlClick = { url -> + onUrlClick(url) + }, + modifier = Modifier.padding(contentPadding), + subscriberStats = viewModel.subscriberStats.collectAsState() ) } } @@ -141,6 +149,14 @@ class SubscribersActivity : BaseAppCompatActivity() { } } + private fun onEmailClick(email: String) { + ActivityLauncher.openUrlExternal(this, "mailto:$email") + } + + private fun onUrlClick(url: String) { + ActivityLauncher.openUrlExternal(this, url) + } + companion object { private const val KEY_ID = "id" } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt index b6a6e6b0e9dd..214d29c7f63b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt @@ -2,19 +2,24 @@ package org.wordpress.android.ui.subscribers import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import org.wordpress.android.R import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.models.wrappers.SimpleDateFormatWrapper 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.DataViewDropdownItem import org.wordpress.android.ui.dataview.DataViewItemImage import org.wordpress.android.ui.dataview.DataViewViewModel import org.wordpress.android.util.AppLog import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.IndividualSubscriberStats +import uniffi.wp_api.IndividualSubscriberStatsParams import uniffi.wp_api.ListSubscribersSortField import uniffi.wp_api.Subscriber import uniffi.wp_api.SubscriberType @@ -31,6 +36,11 @@ class SubscribersViewModel @Inject constructor( mainDispatcher = mainDispatcher, appLogWrapper = appLogWrapper ) { + private val _subscriberStats = MutableStateFlow(null) + val subscriberStats = _subscriberStats.asStateFlow() + + private var statsJob: Job? = null + @Inject lateinit var dateFormatWrapper: SimpleDateFormatWrapper @@ -97,7 +107,7 @@ class SubscribersViewModel @Inject constructor( sortOrder: WpApiParamOrder, sortBy: DataViewDropdownItem?, searchQuery: String - ): List { + ): List = withContext(ioDispatcher) { val filterType = filter?.let { when (it.id) { ID_FILTER_EMAIL -> SubscriberType.EmailSubscriber @@ -135,13 +145,13 @@ class SubscribersViewModel @Inject constructor( is WpRequestResult.Success -> { val subscribers = response.response.data.subscribers appLogWrapper.d(AppLog.T.MAIN, "Fetched ${subscribers.size} subscribers") - return subscribers.map { subscriberToDataViewItem(it) } + return@withContext subscribers.map { subscriberToDataViewItem(it) } } else -> { appLogWrapper.e(AppLog.T.MAIN, "Fetch subscribers failed: $response") onError((response as? WpRequestResult.WpError)?.errorMessage) - return emptyList() + return@withContext emptyList() } } } @@ -156,7 +166,7 @@ class SubscribersViewModel @Inject constructor( title = subscriber.displayNameOrEmail(), fields = listOf( DataViewItemField( - value = subscriber.subscriptionStatus, + value = subscriber.subscriptionStatus ?: "", valueType = DataViewFieldType.TEXT, weight = .6f, ), @@ -179,12 +189,44 @@ class SubscribersViewModel @Inject constructor( return item?.data as? Subscriber } + private suspend fun fetchSubscriberStats(subscriptionId: ULong): IndividualSubscriberStats? = + withContext(ioDispatcher) { + val params = IndividualSubscriberStatsParams( + subscriptionId = subscriptionId + ) + + val response = wpComApiClient.request { requestBuilder -> + requestBuilder.subscribers().individualSubscriberStats( + wpComSiteId = siteId().toULong(), + params = params + ) + } + when (response) { + is WpRequestResult.Success -> { + val stats = response.response.data + appLogWrapper.d(AppLog.T.MAIN, "Fetched subscriber stats: $stats") + return@withContext stats + } + + else -> { + appLogWrapper.e(AppLog.T.MAIN, "Fetch subscribers failed: $response") + return@withContext null + } + } + } + /** - * Called when an item in the list is clicked. + * Called when an item in the list is clicked. We use this to request stats for the clicked subscriber. */ override fun onItemClick(item: DataViewItem) { (item.data as? Subscriber)?.let { subscriber -> appLogWrapper.d(AppLog.T.MAIN, "Clicked on subscriber ${subscriber.displayNameOrEmail()}") + _subscriberStats.value = null + statsJob?.cancel() + statsJob = launch { + val stats = fetchSubscriberStats(subscriber.subscriptionId) + _subscriberStats.value = stats + } } } diff --git a/WordPress/src/main/res/drawable/ic_email_open.xml b/WordPress/src/main/res/drawable/ic_email_open.xml new file mode 100644 index 000000000000..16c9a2e682d2 --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_email_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ad6e61e518d..a4c8ddb660f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -103,7 +103,7 @@ wiremock = '2.26.3' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.1.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-ba6df4e8adfec6c14d6fa58af3717d2297d431fa' +wordpress-rs = 'trunk-14fe1179d108dc91066776e28ee9690864cab1f1' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.0'