diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index 24fdf6e308cb..01638ac32ba7 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -702,6 +702,11 @@ android:label="@string/people" android:launchMode="singleTop" android:theme="@style/WordPress.NoActionBar"/> + ActivityLauncher.viewBlogAdmin(activity, action.site) - is SiteNavigationAction.OpenPeople -> ActivityLauncher.viewCurrentBlogPeople(activity, action.site) + is SiteNavigationAction.OpenPeople -> { + ActivityLauncher.viewCurrentBlogPeople(activity, action.site) + } + is SiteNavigationAction.OpenSelfHostedUsers -> { + ActivityLauncher.viewSelfHostedUsers(activity, action.site) + } is SiteNavigationAction.OpenSharing -> ActivityLauncher.viewBlogSharing(activity, action.site) is SiteNavigationAction.OpenSiteSettings -> ActivityLauncher.viewBlogSettingsForResult(activity, action.site) is SiteNavigationAction.OpenThemes -> ActivityLauncher.viewCurrentBlogThemes(activity, action.site) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/SiteNavigationAction.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/SiteNavigationAction.kt index 18fcc1edcd09..3b53ed131044 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/SiteNavigationAction.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/SiteNavigationAction.kt @@ -32,6 +32,7 @@ sealed class SiteNavigationAction { data class OpenAdmin(val site: SiteModel) : SiteNavigationAction() data class OpenPeople(val site: SiteModel) : SiteNavigationAction() + data class OpenSelfHostedUsers(val site: SiteModel) : SiteNavigationAction() data class OpenSharing(val site: SiteModel) : SiteNavigationAction() data class OpenDomains(val site: SiteModel) : SiteNavigationAction() data class OpenSiteSettings(val site: SiteModel) : SiteNavigationAction() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandler.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandler.kt index cc01fc6a663e..c25d918c8abe 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandler.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/ListItemActionHandler.kt @@ -30,6 +30,7 @@ class ListItemActionHandler @Inject constructor( ListItemAction.PAGES -> SiteNavigationAction.OpenPages(selectedSite) ListItemAction.ADMIN -> SiteNavigationAction.OpenAdmin(selectedSite) ListItemAction.PEOPLE -> SiteNavigationAction.OpenPeople(selectedSite) + ListItemAction.SELF_HOSTED_USERS -> SiteNavigationAction.OpenSelfHostedUsers(selectedSite) ListItemAction.SHARING -> SiteNavigationAction.OpenSharing(selectedSite) ListItemAction.DOMAINS -> SiteNavigationAction.OpenDomains(selectedSite) ListItemAction.ME -> SiteNavigationAction.OpenMeScreen diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/ListItemAction.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/ListItemAction.kt index 4fd019acca11..65f6f54ba678 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/ListItemAction.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/ListItemAction.kt @@ -9,6 +9,7 @@ enum class ListItemAction (val trackingLabel: String) { PAGES("pages"), ADMIN("admin"), PEOPLE("people"), + SELF_HOSTED_USERS("self_hosted_users"), SHARING("sharing"), DOMAINS("domains"), SITE_SETTINGS("site_settings"), diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteItemsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteItemsBuilder.kt index 2727c43d1e2a..0e899edccb4b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteItemsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteItemsBuilder.kt @@ -151,6 +151,7 @@ class SiteItemsBuilder @Inject constructor( return if (!jetpackFeatureRemovalOverlayUtil.shouldHideJetpackFeatures()) { listOfNotNull( siteListItemBuilder.buildPeopleItemIfAvailable(params.site, params.onClick), + siteListItemBuilder.buildSelfHostedUserListItemIfAvailable(params.site, params.onClick), siteListItemBuilder.buildPluginItemIfAvailable(params.site, params.onClick), siteListItemBuilder.buildShareItemIfAvailable( params.site, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteListItemBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteListItemBuilder.kt index 7bc0263630ad..f5aceac0fcc4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteListItemBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteListItemBuilder.kt @@ -18,6 +18,7 @@ import org.wordpress.android.ui.mysite.items.listitem.ListItemAction.PEOPLE import org.wordpress.android.ui.mysite.items.listitem.ListItemAction.PLAN import org.wordpress.android.ui.mysite.items.listitem.ListItemAction.PLUGINS import org.wordpress.android.ui.mysite.items.listitem.ListItemAction.SCAN +import org.wordpress.android.ui.mysite.items.listitem.ListItemAction.SELF_HOSTED_USERS import org.wordpress.android.ui.mysite.items.listitem.ListItemAction.SHARING import org.wordpress.android.ui.mysite.items.listitem.ListItemAction.SITE_SETTINGS import org.wordpress.android.ui.mysite.items.listitem.ListItemAction.THEMES @@ -29,6 +30,7 @@ import org.wordpress.android.ui.utils.UiString.UiStringText import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.DateTimeUtils import org.wordpress.android.util.SiteUtilsWrapper +import org.wordpress.android.util.config.SelfHostedUsersFeatureConfig import org.wordpress.android.util.config.SiteMonitoringFeatureConfig import java.util.GregorianCalendar import java.util.TimeZone @@ -41,7 +43,8 @@ class SiteListItemBuilder @Inject constructor( private val buildConfigWrapper: BuildConfigWrapper, private val themeBrowserUtils: ThemeBrowserUtils, private val jetpackFeatureRemovalPhaseHelper: JetpackFeatureRemovalPhaseHelper, - private val siteMonitoringFeatureConfig: SiteMonitoringFeatureConfig + private val siteMonitoringFeatureConfig: SiteMonitoringFeatureConfig, + private val selfHostedUsersFeatureConfig: SelfHostedUsersFeatureConfig, ) { fun buildActivityLogItemIfAvailable(site: SiteModel, onClick: (ListItemAction) -> Unit): ListItem? { val isWpComOrJetpack = siteUtilsWrapper.isAccessedViaWPComRest( @@ -138,6 +141,20 @@ class SiteListItemBuilder @Inject constructor( onClick = ListItemInteraction.create(PEOPLE, onClick), listItemAction = PEOPLE ) + } else { + null + } + } + + fun buildSelfHostedUserListItemIfAvailable(site: SiteModel, onClick: (ListItemAction) -> Unit): ListItem? { + // TODO Should this excluded JetPack users? + return if (selfHostedUsersFeatureConfig.isEnabled() && site.selfHostedSiteId > 0) { + ListItem( + R.drawable.ic_user_white_24dp, + UiStringRes(R.string.users), + onClick = ListItemInteraction.create(SELF_HOSTED_USERS, onClick), + listItemAction = SELF_HOSTED_USERS + ) } else null } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SampleUsers.kt b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SampleUsers.kt new file mode 100644 index 000000000000..17faba3d3933 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SampleUsers.kt @@ -0,0 +1,89 @@ +package org.wordpress.android.ui.selfhostedusers + +import uniffi.wp_api.UserWithEditContext + +/** + * This is a temporary object to supply a list of users for the self-hosted user list. + * It will be removed once the network request to retrieve users is implemented. + */ +@Suppress("MaxLineLength") +object SampleUsers { + private val sampleUserList = ArrayList() + + private val sampleUser1 = UserWithEditContext( + id = 1, + username = "@sampleUser", + avatarUrls = emptyMap(), + capabilities = emptyMap(), + email = "email@exmaple.com", + extraCapabilities = emptyMap(), + firstName = "Sample", + lastName = "User", + link = "example.com", + locale = "en-US", + name = "Sample User", + nickname = "User nickname", + registeredDate = "2023-01-01", + roles = listOf("admin"), + slug = "sample-user", + url = "example.com", + description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam non quam viverra, viverra est vel, interdum felis. Pellentesque interdum libero quis metus pharetra ullamcorper. Morbi nec libero ligula. Quisque consectetur, purus sit amet lobortis porttitor, ligula ex imperdiet massa, in ullamcorper augue odio sit amet metus. In sollicitudin mauris et risus mollis commodo. Aliquam vel vehicula ante, nec blandit erat. Aenean non turpis porttitor orci fringilla fringilla nec ac nunc. Nulla ultrices urna ut ipsum posuere blandit. Phasellus mauris nulla, tincidunt at leo at, auctor interdum felis. Sed pharetra risus a ullamcorper dictum. Suspendisse pharetra justo molestie risus lobortis facilisis.", + ) + + private val sampleUser2 = UserWithEditContext( + id = 2, + username = "@sampleUserWithALongUserName", + avatarUrls = emptyMap(), + capabilities = emptyMap(), + description = "User description", + email = "email@exmaple.com", + extraCapabilities = emptyMap(), + firstName = "Sample", + lastName = "User", + link = "example.com", + locale = "en-US", + name = "Sample User", + nickname = "User nickname", + registeredDate = "2023-01-01", + roles = listOf("contributor"), + slug = "sample-user", + url = "example.com", + ) + + private val sampleUser3 = UserWithEditContext( + id = 3, + username = "@sampleUser", + avatarUrls = emptyMap(), + capabilities = emptyMap(), + description = "User description", + email = "email@exmaple.com", + extraCapabilities = emptyMap(), + firstName = "Sample", + lastName = "User", + link = "example.com", + locale = "en-US", + name = "Sample User", + nickname = "User nickname", + registeredDate = "2023-01-01", + roles = listOf("contributor", "editor", "subscriber"), + slug = "sample-user", + url = "example.com", + ) + + @Suppress("MagicNumber") + fun getSampleUsers(): ArrayList { + fun addWithId(user: UserWithEditContext) { + sampleUserList.add( + user.copy(id = sampleUserList.size) + ) + } + if (sampleUserList.isEmpty()) { + repeat(25) { + addWithId(sampleUser1) + addWithId(sampleUser2) + addWithId(sampleUser3) + } + } + return sampleUserList + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUserComposables.kt b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUserComposables.kt new file mode 100644 index 000000000000..7867ac4059c0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUserComposables.kt @@ -0,0 +1,225 @@ +package org.wordpress.android.ui.selfhostedusers + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +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.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.M3Theme + +/** + * These composables were created for the self-hosted users feature but were written to be reusable + * in other projects. + */ + +/** + * A composable that displays an avatar image optionally with a click listener. This + * is suitable for use in a list, such as in the self-hosted user list feature. + */ +@Composable +fun SmallAvatar( + avatarUrl: String?, + onAvatarClick: ((String?) -> Unit)? = null, +) { + val extraModifier = if (onAvatarClick != null) { + Modifier.clickable { + onAvatarClick(avatarUrl) + } + } else { + Modifier + } + + if (avatarUrl.isNullOrEmpty()) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_user_placeholder_primary_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .size(48.dp) + .then( + extraModifier + ) + ) + } else { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(avatarUrl) + .error(R.drawable.ic_user_placeholder_primary_24) + .crossfade(true) + .build(), + contentScale = ContentScale.Fit, + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .size(48.dp) + .then( + extraModifier + ) + ) + } +} + +/** + * A composable that displays an avatar image at the maximum screen size + */ +@Composable +fun LargeAvatar(avatarUrl: String) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(avatarUrl) + .error(R.drawable.ic_user_placeholder_primary_24) + .crossfade(true) + .build(), + contentScale = ContentScale.Fit, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) +} + +/** + * A composable that displays a message when there is no network connection + */ +@Composable +fun OfflineView() { + MessageView( + R.drawable.ic_wifi_off_24px, + R.string.no_network_title, + R.string.no_network_message, + ) +} + +/** + * A composable that displays a title with an icon above it and an optional subtitle below it + */ +@Composable +fun MessageView( + @DrawableRes iconRes: Int, + @StringRes titleRes: Int, + @StringRes subtitleRes: Int? = null, +) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = ImageVector.vectorResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .size(85.dp) + ) + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + if (subtitleRes != null) { + Text( + text = stringResource(subtitleRes), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +/** + * A composable that displays a screen with a top bar and a content area + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScreenWithTopBar( + title: String, + onCloseClick: () -> Unit, + isScrollable: Boolean, + closeIcon: ImageVector = Icons.Default.Close, + content: @Composable () -> Unit, +) { + M3Theme { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onCloseClick) { + Icon(closeIcon, stringResource(R.string.back)) + } + }, + ) + }, + ) { contentPadding -> + val extraModifier = if (isScrollable) { + Modifier + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + } else { + Modifier + } + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .then(extraModifier) + ) { + content() + } + } + } +} + +@Composable +@Preview( + name = "Offline Screen Light Mode", + showBackground = true +) +@Preview( + name = "Offline Screen Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun OfflineScreenPreview() { + val content: @Composable () -> Unit = @Composable { + OfflineView() + } + ScreenWithTopBar( + title = "Title", + content = content, + onCloseClick = {}, + isScrollable = false + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUsersActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUsersActivity.kt new file mode 100644 index 000000000000..29425a069574 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUsersActivity.kt @@ -0,0 +1,64 @@ +package org.wordpress.android.ui.selfhostedusers + +import android.os.Build +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.R +import org.wordpress.android.WordPress +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.LocaleAwareActivity +import org.wordpress.android.util.ToastUtils +import org.wordpress.android.util.extensions.getSerializableCompat +import org.wordpress.android.util.extensions.getSerializableExtraCompat + +@AndroidEntryPoint +class SelfHostedUsersActivity : LocaleAwareActivity() { + private val viewModel by viewModels() + private var site: SiteModel? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + site = if (savedInstanceState == null) { + intent.getSerializableExtraCompat(WordPress.SITE) + } else { + savedInstanceState.getSerializableCompat(WordPress.SITE) + } + if (site == null) { + ToastUtils.showToast(this, R.string.blog_not_found, ToastUtils.Duration.SHORT) + finish() + return + } + + setContentView( + ComposeView(this).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + this.isForceDarkAllowed = false + } + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + SelfHostedUsersScreen( + uiState = viewModel.uiState, + onCloseClick = { + viewModel.onCloseClick(this@SelfHostedUsersActivity) + }, + onUserClick = { user -> + viewModel.onUserClick(user) + }, + onUserAvatarClick = { avatarUrl -> + viewModel.onUserAvatarClick(avatarUrl) + } + ) + } + } + ) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putSerializable(WordPress.SITE, site) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUsersScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUsersScreen.kt new file mode 100644 index 000000000000..9b252e56865a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUsersScreen.kt @@ -0,0 +1,357 @@ +package org.wordpress.android.ui.selfhostedusers + +import android.content.res.Configuration +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.integerResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.wordpress.android.R +import org.wordpress.android.ui.compose.components.ProgressDialog +import org.wordpress.android.ui.compose.components.ProgressDialogState +import org.wordpress.android.ui.selfhostedusers.SelfHostedUsersViewModel.SelfHostedUserState +import uniffi.wp_api.UserWithEditContext + +@Composable +fun SelfHostedUsersScreen( + uiState: StateFlow, + onCloseClick: () -> Unit = {}, + onUserClick: (UserWithEditContext) -> Unit = {}, + onUserAvatarClick: (avatarUrl: String?) -> Unit = {}, +) { + val state = uiState.collectAsState().value + + val title = when (state) { + is SelfHostedUserState.UserDetail -> state.user.name + is SelfHostedUserState.UserAvatar -> "" + else -> stringResource(R.string.users) + } + + val closeIcon = when (state) { + is SelfHostedUserState.UserAvatar -> Icons.Default.Close + else -> Icons.AutoMirrored.Filled.ArrowBack + } + + val isScrollable = when (state) { + is SelfHostedUserState.UserList -> true + is SelfHostedUserState.UserDetail -> true + else -> false + } + + Crossfade( + targetState = state, + animationSpec = tween( + durationMillis = integerResource(android.R.integer.config_mediumAnimTime) + ), + label = "Crossfade" + ) { targetState -> + ScreenWithTopBar( + title = title, + onCloseClick = { onCloseClick() }, + isScrollable = isScrollable, + closeIcon = closeIcon, + ) { + when (targetState) { + is SelfHostedUserState.Loading -> { + ProgressDialog( + ProgressDialogState( + message = R.string.loading, + showCancel = false, + dismissible = false + ) + ) + } + + is SelfHostedUserState.UserList -> { + UserList(targetState.users, onUserClick) + } + + is SelfHostedUserState.EmptyUserList -> { + MessageView( + R.drawable.ic_people_white_24dp, + R.string.no_users, + ) + } + + is SelfHostedUserState.UserAvatar -> { + LargeAvatar(targetState.avatarUrl) + } + + is SelfHostedUserState.UserDetail -> { + UserDetail( + targetState.user, + onUserAvatarClick + ) + } + + is SelfHostedUserState.Offline -> { + OfflineView() + } + } + } + } +} + +@Composable +private fun UserList( + users: List, + onUserClick: (UserWithEditContext) -> Unit +) { + for (user in users) { + UserLazyRow(user, onUserClick) + HorizontalDivider(thickness = 1.dp, modifier = Modifier.padding(start = 80.dp)) + } +} + +@Composable +private fun UserLazyRow( + user: UserWithEditContext, + onUserClick: (UserWithEditContext) -> Unit +) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .clickable { onUserClick(user) } + ) { + item { + Column(modifier = Modifier.padding(all = userScreenPaddingDp)) { + SmallAvatar(user.avatarUrls?.values?.firstOrNull()) + } + } + + item { + Column( + modifier = Modifier + .padding( + top = userScreenPaddingDp, + bottom = userScreenPaddingDp, + end = userScreenPaddingDp + ) + ) { + Text( + text = user.name, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = user.username, + style = MaterialTheme.typography.bodyMedium + ) + if (user.roles.isNotEmpty()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = user.roles.joinToString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } + } + } +} + +@Composable +private fun UserDetail( + user: UserWithEditContext, + onAvatarClick: (String?) -> Unit = {}, +) { + Row( + modifier = Modifier + .padding(all = userScreenPaddingDp) + .fillMaxWidth() + ) { + Column { + val avatarUrl = user.avatarUrls?.values?.firstOrNull() + SmallAvatar( + avatarUrl = avatarUrl, + onAvatarClick = if (avatarUrl.isNullOrEmpty()) { + null + } else { + onAvatarClick + } + ) + } + + Column( + modifier = Modifier + .padding(start = userScreenPaddingDp) + ) { + UserDetailSection(title = stringResource(R.string.name)) { + UserDetailRow( + label = stringResource(R.string.username), + text = user.username, + ) + UserDetailRow( + label = stringResource(R.string.role), + text = user.roles.joinToString(), + ) + UserDetailRow( + label = stringResource(R.string.first_name), + text = user.firstName, + ) + UserDetailRow( + label = stringResource(R.string.last_name), + text = user.lastName, + ) + UserDetailRow( + label = stringResource(R.string.nickname), + text = user.nickname, + ) + // TODO display name is missing from the model + } + + UserDetailSection(title = stringResource(R.string.contact_info)) { + UserDetailRow( + label = stringResource(R.string.email), + text = user.email, + ) + UserDetailRow( + label = stringResource(R.string.website), + text = user.url, + ) + } + + UserDetailSection(title = stringResource(R.string.about_the_user)) { + UserDetailRow( + label = stringResource(R.string.biographical_info), + text = user.description.ifEmpty { + stringResource(R.string.biographical_info_empty) + }, + isMultiline = true + ) + } + } + } +} + +@Composable +private fun UserDetailSection( + title: String, + content: @Composable () -> Unit, +) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + ) + Spacer(modifier = Modifier.height(userScreenPaddingDp)) + content() + HorizontalDivider(thickness = 1.dp) + Spacer(modifier = Modifier.height(userScreenPaddingDp)) +} + +@Composable +private fun UserDetailRow( + label: String, + text: String, + isMultiline: Boolean = false, +) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + maxLines = if (isMultiline) 10 else 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(userScreenPaddingDp)) +} + +@Composable +@Preview( + name = "Light Mode", + showBackground = true +) +@Preview( + name = "Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun UserListScreenPreview() { + val uiState = SelfHostedUserState.UserList(SampleUsers.getSampleUsers()) + SelfHostedUsersScreen(MutableStateFlow(uiState)) +} + +@Composable +@Preview( + name = "Detail Light Mode", + showBackground = true +) +@Preview( + name = "Detail Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun UserDetailScreenPreview() { + val uiState = SelfHostedUserState.UserDetail(SampleUsers.getSampleUsers().first()) + SelfHostedUsersScreen(MutableStateFlow(uiState)) +} + +@Composable +@Preview( + name = "Empty View Light Mode", + showBackground = true +) +@Preview( + name = "Empty View Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun EmptyUserListScreenPreview() { + val uiState = SelfHostedUserState.EmptyUserList + SelfHostedUsersScreen(MutableStateFlow(uiState)) +} + +@Composable +@Preview( + name = "Offline View Light Mode", + showBackground = true +) +@Preview( + name = "Offline View Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun OfflineScreenPreview() { + val uiState = SelfHostedUserState.Offline + SelfHostedUsersScreen(MutableStateFlow(uiState)) +} + +@Composable +@Preview( + name = "Progress Light Mode", + showBackground = true +) +@Preview( + name = "Progress Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun ProgressPreview() { + val uiState = SelfHostedUserState.Loading + SelfHostedUsersScreen(MutableStateFlow(uiState)) +} + +private val userScreenPaddingDp = 16.dp diff --git a/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUsersViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUsersViewModel.kt new file mode 100644 index 000000000000..b2417f2bd1b6 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/selfhostedusers/SelfHostedUsersViewModel.kt @@ -0,0 +1,98 @@ +package org.wordpress.android.ui.selfhostedusers + +import android.app.Activity +import android.content.Context +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.wordpress.android.WordPress +import org.wordpress.android.modules.UI_THREAD +import org.wordpress.android.util.NetworkUtils +import org.wordpress.android.viewmodel.ScopedViewModel +import uniffi.wp_api.UserWithEditContext +import javax.inject.Inject +import javax.inject.Named + +@HiltViewModel +class SelfHostedUsersViewModel @Inject constructor( + @Named(UI_THREAD) mainDispatcher: CoroutineDispatcher, +) : ScopedViewModel(mainDispatcher) { + private val userList = ArrayList() + private var selectedUser: UserWithEditContext? = null + + private val _uiState = MutableStateFlow(SelfHostedUserState.Loading) + val uiState = _uiState.asStateFlow() + + init { + fetchUsers() + } + + // TODO this uses dummy data for now - no network request is involved yet + @Suppress("MagicNumber") + private fun fetchUsers() { + if (NetworkUtils.isNetworkAvailable(WordPress.getContext()).not()) { + _uiState.value = SelfHostedUserState.Offline + return + } + + _uiState.value = SelfHostedUserState.Loading + launch { + delay(1000L) + userList.clear() + val users = SampleUsers.getSampleUsers() + if (users.isEmpty()) { + _uiState.value = SelfHostedUserState.EmptyUserList + } else { + userList.addAll(users) + _uiState.value = SelfHostedUserState.UserList(userList) + } + } + } + + /** + * Called when the back/close button is clicked + */ + fun onCloseClick(context: Context) { + when (_uiState.value) { + is SelfHostedUserState.UserDetail -> { + _uiState.value = SelfHostedUserState.UserList(userList) + } + + is SelfHostedUserState.UserAvatar -> { + _uiState.value = SelfHostedUserState.UserDetail(selectedUser!!) + } + + else -> { + (context as? Activity)?.finish() + } + } + } + + /** + * Called when a user is clicked in the list screen + */ + fun onUserClick(user: UserWithEditContext) { + selectedUser = user + _uiState.value = SelfHostedUserState.UserDetail(user) + } + + /** + * Called when a user's avatar is clicked in the detail screen + */ + fun onUserAvatarClick(avatarUrl: String?) { + avatarUrl?.let { + _uiState.value = SelfHostedUserState.UserAvatar(it) + } + } + + sealed class SelfHostedUserState { + data object Loading : SelfHostedUserState() + data object Offline : SelfHostedUserState() + data object EmptyUserList : SelfHostedUserState() + data class UserList(val users: List) : SelfHostedUserState() + data class UserDetail(val user: UserWithEditContext) : SelfHostedUserState() + data class UserAvatar(val avatarUrl: String) : SelfHostedUserState() + } +} diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index a8b3ccb844af..77acaf3371ea 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -620,6 +620,7 @@ https:\/\/ Close Submit + Back General @@ -2686,6 +2687,9 @@ People + Users + User + No users Role Since %1$s Remove %1$s @@ -2976,6 +2980,16 @@ Updates might take some time to sync with your Gravatar profile. Done + + + Name + Nickname + Contact info + Website + About the user + Biographical Info + None given + Account Settings Click the verification link in the email sent to %1$s to confirm your new address diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/items/listitem/SiteListItemBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/items/listitem/SiteListItemBuilderTest.kt index d1bfd2e7eac7..dc0b33f28056 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/items/listitem/SiteListItemBuilderTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/items/listitem/SiteListItemBuilderTest.kt @@ -29,6 +29,7 @@ import org.wordpress.android.ui.plugins.PluginUtilsWrapper import org.wordpress.android.ui.themes.ThemeBrowserUtils import org.wordpress.android.util.BuildConfigWrapper import org.wordpress.android.util.SiteUtilsWrapper +import org.wordpress.android.util.config.SelfHostedUsersFeatureConfig import org.wordpress.android.util.config.SiteMonitoringFeatureConfig @RunWith(MockitoJUnitRunner::class) @@ -57,6 +58,9 @@ class SiteListItemBuilderTest { @Mock lateinit var siteMonitoringFeatureConfig: SiteMonitoringFeatureConfig + @Mock + lateinit var selfHostedUsersFeatureConfig: SelfHostedUsersFeatureConfig + private lateinit var siteListItemBuilder: SiteListItemBuilder @Before @@ -68,7 +72,8 @@ class SiteListItemBuilderTest { buildConfigWrapper, themeBrowserUtils, jetpackFeatureRemovalPhaseHelper, - siteMonitoringFeatureConfig + siteMonitoringFeatureConfig, + selfHostedUsersFeatureConfig ) }