From 055784251b51e6ff739877be4f9881ab22dd8da5 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Fri, 13 Oct 2023 12:53:14 +0300 Subject: [PATCH] Discussion's user profile (#69) * Added screen another users profile with open from discussion screen * Added AnothersProfileViewModel unit tests * Accessibility support --- .../main/java/org/openedx/app/AppRouter.kt | 11 + .../java/org/openedx/app/di/ScreenModule.kt | 2 + .../openedx/auth/presentation/AuthRouter.kt | 1 - core/src/main/res/values-uk/strings.xml | 3 +- core/src/main/res/values/strings.xml | 4 +- .../presentation/DiscussionRouter.kt | 4 + .../comments/DiscussionCommentsFragment.kt | 24 +- .../responses/DiscussionResponsesFragment.kt | 28 +- .../presentation/ui/DiscussionUI.kt | 55 +++- .../org/openedx/profile/data/model/Account.kt | 4 +- .../data/repository/ProfileRepository.kt | 5 + .../domain/interactor/ProfileInteractor.kt | 2 + .../AnothersProfileFragment.kt | 257 ++++++++++++++++++ .../AnothersProfileUIState.kt | 8 + .../AnothersProfileViewModel.kt | 49 ++++ .../presentation/profile/ProfileFragment.kt | 147 ++-------- .../profile/presentation/ui/ProfileUI.kt | 148 ++++++++++ .../profile/AnothersProfileViewModelTest.kt | 121 +++++++++ 18 files changed, 717 insertions(+), 156 deletions(-) create mode 100644 profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileFragment.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileUIState.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileViewModel.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt create mode 100644 profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index ea24e9d7b..d9938022c 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -33,6 +33,7 @@ import org.openedx.discussion.presentation.search.DiscussionSearchThreadFragment import org.openedx.discussion.presentation.threads.DiscussionAddThreadFragment import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment import org.openedx.profile.presentation.ProfileRouter +import org.openedx.profile.presentation.anothers_account.AnothersProfileFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.settings.video.VideoQualityFragment @@ -223,6 +224,16 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di DiscussionSearchThreadFragment.newInstance(courseId) ) } + + override fun navigateToAnothersProfile( + fm: FragmentManager, + username: String + ) { + replaceFragmentWithBackStack( + fm, + AnothersProfileFragment.newInstance(username) + ) + } //endregion //region ProfileRouter diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index d1dfaba76..b074d3165 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -46,6 +46,7 @@ import org.openedx.profile.presentation.settings.video.VideoSettingsViewModel import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module +import org.openedx.profile.presentation.anothers_account.AnothersProfileViewModel val screenModule = module { @@ -73,6 +74,7 @@ val screenModule = module { viewModel { VideoSettingsViewModel(get(), get()) } viewModel { VideoQualityViewModel(get(), get()) } viewModel { DeleteProfileViewModel(get(), get(), get(), get()) } + viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } single { CourseRepository(get(), get(), get(),get()) } factory { CourseInteractor(get()) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt index 1db5b98c1..a8b9e8b66 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -9,5 +9,4 @@ interface AuthRouter { fun navigateToSignUp(fm: FragmentManager) fun navigateToRestorePassword(fm: FragmentManager) - } \ No newline at end of file diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index 8de28cc5b..68027aa79 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -36,8 +36,9 @@ Модель пристрою: Відгук: Відгук клієнта - dd MMMM dd MMM yyyy HH:mm + + %1$s зображення профілю \ No newline at end of file diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 7eeeb7375..45e5092f4 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -38,7 +38,9 @@ Device Model: Feedback: Customer Feedback - MMMM dd dd MMM yyyy hh:mm aaa + + + %1$s profile image \ No newline at end of file diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt b/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt index 71386a645..54f519004 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/DiscussionRouter.kt @@ -37,4 +37,8 @@ interface DiscussionRouter { courseId: String ) + fun navigateToAnothersProfile( + fm: FragmentManager, + username: String + ) } \ No newline at end of file diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt index 1a5b1315a..04f3dcffa 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt @@ -121,6 +121,11 @@ class DiscussionCommentsFragment : Fragment() { requireActivity().supportFragmentManager, it, viewModel.thread.closed ) }, + onUserPhotoClick = { username -> + router.navigateToAnothersProfile( + requireActivity().supportFragmentManager, username + ) + }, onAddResponseClick = { viewModel.createComment(it) }, @@ -167,7 +172,8 @@ private fun DiscussionCommentsScreen( onItemClick: (String, String, Boolean) -> Unit, onCommentClick: (DiscussionComment) -> Unit, onAddResponseClick: (String) -> Unit, - onBackClick: () -> Unit + onBackClick: () -> Unit, + onUserPhotoClick: (String) -> Unit ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberLazyListState() @@ -287,7 +293,11 @@ private fun DiscussionCommentsScreen( thread = uiState.thread, onClick = { action, bool -> onItemClick(action, uiState.thread.id, bool) - }) + }, + onUserPhotoClick = { username -> + onUserPhotoClick(username) + } + ) } if (uiState.commentsData.isNotEmpty()) { item { @@ -320,6 +330,9 @@ private fun DiscussionCommentsScreen( }, onAddCommentClick = { onCommentClick(comment) + }, + onUserPhotoClick = { + onUserPhotoClick(comment.author) }) } item { @@ -403,6 +416,7 @@ private fun DiscussionCommentsScreen( } } } + is DiscussionCommentsUIState.Loading -> { Box( Modifier @@ -450,7 +464,8 @@ private fun DiscussionCommentsScreenPreview() { onBackClick = {}, scrollToBottom = false, refreshing = false, - onSwipeRefresh = {} + onSwipeRefresh = {}, + onUserPhotoClick = {} ) } } @@ -480,7 +495,8 @@ private fun DiscussionCommentsScreenTabletPreview() { onBackClick = {}, scrollToBottom = false, refreshing = false, - onSwipeRefresh = {} + onSwipeRefresh = {}, + onUserPhotoClick = {} ) } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt index 169b65b97..f668ed391 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject import org.openedx.core.UIMessage import org.openedx.core.domain.model.ProfileImage import org.openedx.core.extension.TextConverter @@ -53,6 +54,7 @@ import org.openedx.discussion.presentation.comments.DiscussionCommentsFragment import org.openedx.discussion.presentation.ui.CommentMainItem import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.R as discussionR class DiscussionResponsesFragment : Fragment() { @@ -61,6 +63,8 @@ class DiscussionResponsesFragment : Fragment() { parametersOf(requireArguments().parcelable(ARG_COMMENT)) } + private val router by inject() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) @@ -118,6 +122,11 @@ class DiscussionResponsesFragment : Fragment() { }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() + }, + onUserPhotoClick = { username -> + router.navigateToAnothersProfile( + requireActivity().supportFragmentManager, username + ) } ) } @@ -156,6 +165,7 @@ private fun DiscussionResponsesScreen( onItemClick: (String, String, Boolean) -> Unit, addCommentClick: (String) -> Unit, onBackClick: () -> Unit, + onUserPhotoClick: (String) -> Unit ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberLazyListState() @@ -290,7 +300,11 @@ private fun DiscussionResponsesScreen( uiState.mainComment.id, bool ) - }) + }, + onUserPhotoClick = {username -> + onUserPhotoClick(username) + } + ) } if (uiState.mainComment.childCount > 0) { item { @@ -332,7 +346,11 @@ private fun DiscussionResponsesScreen( comment = comment, onClick = { action, commentId, bool -> onItemClick(action, commentId, bool) - }) + }, + onUserPhotoClick = {username -> + onUserPhotoClick(username) + } + ) } } item { @@ -464,7 +482,8 @@ private fun DiscussionResponsesScreenPreview() { }, onBackClick = {}, - isClosed = false + isClosed = false, + onUserPhotoClick = {} ) } } @@ -494,7 +513,8 @@ private fun DiscussionResponsesScreenTabletPreview() { }, onBackClick = {}, - isClosed = false + isClosed = false, + onUserPhotoClick = {} ) } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt index 0d146692a..dd25a1284 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt @@ -53,6 +53,7 @@ fun ThreadMainItem( modifier: Modifier, thread: org.openedx.discussion.domain.model.Thread, onClick: (String, Boolean) -> Unit, + onUserPhotoClick: (String) -> Unit ) { val profileImageUrl = if (thread.users?.get(thread.author)?.image?.hasImage == true) { thread.users[thread.author]?.image?.imageUrlFull @@ -93,14 +94,25 @@ fun ThreadMainItem( .error(org.openedx.core.R.drawable.core_ic_default_profile_picture) .placeholder(org.openedx.core.R.drawable.core_ic_default_profile_picture) .build(), - contentDescription = null, + contentDescription = stringResource(id = org.openedx.core.R.string.core_accessibility_user_profile_image, thread.author), modifier = Modifier .size(48.dp) .clip(MaterialTheme.appShapes.material.medium) + .clickable { + if (thread.author.isNotEmpty()) { + onUserPhotoClick(thread.author) + } + } ) Spacer(Modifier.width(16.dp)) Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .clickable { + if (thread.author.isNotEmpty()) { + onUserPhotoClick(thread.author) + } + }, verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( @@ -172,6 +184,7 @@ fun CommentItem( shape: Shape = MaterialTheme.appShapes.cardShape, onClick: (String, String, Boolean) -> Unit, onAddCommentClick: () -> Unit = {}, + onUserPhotoClick: (String) -> Unit ) { val profileImageUrl = if (comment.profileImage?.hasImage == true) { comment.profileImage.imageUrlFull @@ -232,15 +245,21 @@ fun CommentItem( .error(org.openedx.core.R.drawable.core_ic_default_profile_picture) .placeholder(org.openedx.core.R.drawable.core_ic_default_profile_picture) .build(), - contentDescription = null, + contentDescription = stringResource(id = org.openedx.core.R.string.core_accessibility_user_profile_image, comment.author), modifier = Modifier .size(32.dp) .clip(CircleShape) - + .clickable { + onUserPhotoClick(comment.author) + } ) Spacer(Modifier.width(12.dp)) Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .clickable { + onUserPhotoClick(comment.author) + }, verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( @@ -323,6 +342,7 @@ fun CommentMainItem( internalPadding: Dp = 16.dp, comment: DiscussionComment, onClick: (String, String, Boolean) -> Unit, + onUserPhotoClick: (String) -> Unit ) { val profileImageUrl = if (comment.profileImage?.hasImage == true) { comment.profileImage.imageUrlFull @@ -375,15 +395,21 @@ fun CommentMainItem( .error(org.openedx.core.R.drawable.core_ic_default_profile_picture) .placeholder(org.openedx.core.R.drawable.core_ic_default_profile_picture) .build(), - contentDescription = null, + contentDescription = stringResource(id = org.openedx.core.R.string.core_accessibility_user_profile_image, comment.author), modifier = Modifier .size(32.dp) .clip(CircleShape) - + .clickable { + onUserPhotoClick(comment.author) + } ) Spacer(Modifier.width(12.dp)) Column( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .clickable { + onUserPhotoClick(comment.author) + }, verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( @@ -642,9 +668,9 @@ private fun CommentItemPreview() { CommentItem( modifier = Modifier.fillMaxWidth(), comment = mockComment, - onClick = { _, _, _ -> - - }) + onClick = { _, _, _ -> }, + onUserPhotoClick = {} + ) } } @@ -653,9 +679,10 @@ private fun CommentItemPreview() { private fun ThreadMainItemPreview() { ThreadMainItem( modifier = Modifier.fillMaxWidth(), - thread = mockThread, onClick = { _, _ -> - - }) + thread = mockThread, + onClick = { _, _ -> }, + onUserPhotoClick = {} + ) } private val mockComment = DiscussionComment( diff --git a/profile/src/main/java/org/openedx/profile/data/model/Account.kt b/profile/src/main/java/org/openedx/profile/data/model/Account.kt index c3fe9304e..ff069376a 100644 --- a/profile/src/main/java/org/openedx/profile/data/model/Account.kt +++ b/profile/src/main/java/org/openedx/profile/data/model/Account.kt @@ -60,7 +60,9 @@ data class Account( yearOfBirth = yearOfBirth, levelOfEducation = levelOfEducation ?: "", goals = goals ?: "", - languageProficiencies = languageProficiencies!!.map { it.mapToDomain() }, + languageProficiencies = languageProficiencies?.let { languageProficiencyList -> + languageProficiencyList.map { it.mapToDomain() } + } ?: emptyList(), gender = gender ?: "", mailingAddress = mailingAddress ?: "", email = email, diff --git a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt index f9f7d0a7a..b8887a984 100644 --- a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt +++ b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt @@ -23,6 +23,11 @@ class ProfileRepository( return account.mapToDomain() } + suspend fun getAccount(username: String): Account { + val account = api.getAccount(username) + return account.mapToDomain() + } + fun getCachedAccount() : Account? { return profilePreferences.profile?.mapToDomain() } diff --git a/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt b/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt index 8f5b3cbe2..cbad3b4fe 100644 --- a/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt +++ b/profile/src/main/java/org/openedx/profile/domain/interactor/ProfileInteractor.kt @@ -7,6 +7,8 @@ class ProfileInteractor(private val repository: ProfileRepository) { suspend fun getAccount() = repository.getAccount() + suspend fun getAccount(username: String) = repository.getAccount(username) + fun getCachedAccount() = repository.getCachedAccount() suspend fun updateAccount(fields: Map) = repository.updateAccount(fields) diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileFragment.kt new file mode 100644 index 000000000..3007a416b --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileFragment.kt @@ -0,0 +1,257 @@ +package org.openedx.profile.presentation.anothers_account + +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.ProfileImage +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.profile.domain.model.Account +import org.openedx.profile.presentation.ui.ProfileInfoSection +import org.openedx.profile.presentation.ui.ProfileTopic + +class AnothersProfileFragment : Fragment() { + + private val viewModel: AnothersProfileViewModel by viewModel { + parametersOf(requireArguments().getString(ARG_USERNAME, "")) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + + val uiState by viewModel.uiState + val uiMessage by viewModel.uiMessage + + AnothersProfileScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }, + ) + } + } + } + + companion object { + private const val ARG_USERNAME = "username" + fun newInstance( + username: String, + ): AnothersProfileFragment { + val fragment = AnothersProfileFragment() + fragment.arguments = bundleOf( + ARG_USERNAME to username + ) + return fragment + } + } +} + +@Composable +private fun AnothersProfileScreen( + windowSize: WindowSize, + uiState: AnothersProfileUIState, + uiMessage: UIMessage?, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier + .fillMaxWidth() + ) + ) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + topBar = { + Column( + Modifier + .fillMaxWidth() + .statusBarsInset(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .then(topBarWidth), + contentAlignment = Alignment.CenterStart + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.core_profile), + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.titleMedium + ) + BackBtn( + modifier = Modifier.padding(end = 16.dp) + ) { + onBackClick() + } + } + } + } + ) { paddingValues -> + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .background(MaterialTheme.appColors.background), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (uiState) { + is AnothersProfileUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is AnothersProfileUIState.Data -> { + Column( + Modifier + .fillMaxHeight() + .then(contentWidth) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProfileTopic(uiState.account) + + Spacer(modifier = Modifier.height(36.dp)) + + ProfileInfoSection(uiState.account) + } + } + } + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ProfileScreenPreview() { + OpenEdXTheme { + AnothersProfileScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = AnothersProfileUIState.Data(mockAccount), + uiMessage = null, + onBackClick = {} + ) + } +} + + +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun ProfileScreenTabletPreview() { + OpenEdXTheme { + AnothersProfileScreen( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = AnothersProfileUIState.Data(mockAccount), + uiMessage = null, + onBackClick = {} + ) + } +} + +private val mockAccount = Account( + username = "thom84", + bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", + requiresParentalConsent = true, + name = "Thomas", + country = "Ukraine", + isActive = true, + profileImage = ProfileImage("", "", "", "", false), + yearOfBirth = 2000, + levelOfEducation = "Bachelor", + goals = "130", + languageProficiencies = emptyList(), + gender = "male", + mailingAddress = "", + "", + null, + accountPrivacy = Account.Privacy.ALL_USERS +) diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileUIState.kt new file mode 100644 index 000000000..ebffce6dd --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileUIState.kt @@ -0,0 +1,8 @@ +package org.openedx.profile.presentation.anothers_account + +import org.openedx.profile.domain.model.Account + +sealed class AnothersProfileUIState { + data class Data(val account: Account) : AnothersProfileUIState() + object Loading : AnothersProfileUIState() +} \ No newline at end of file diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileViewModel.kt new file mode 100644 index 000000000..b0c82e3e0 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/anothers_account/AnothersProfileViewModel.kt @@ -0,0 +1,49 @@ +package org.openedx.profile.presentation.anothers_account + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.profile.domain.interactor.ProfileInteractor + +class AnothersProfileViewModel( + private val interactor: ProfileInteractor, + private val resourceManager: ResourceManager, + val username: String +) : BaseViewModel() { + + private val _uiState = mutableStateOf(AnothersProfileUIState.Loading) + val uiState: State + get() = _uiState + + private val _uiMessage = mutableStateOf(null) + val uiMessage: State + get() = _uiMessage + + init { + getAccount(username) + } + + private fun getAccount(username: String) { + _uiState.value = AnothersProfileUIState.Loading + viewModelScope.launch { + try { + val account = interactor.getAccount(username) + _uiState.value = AnothersProfileUIState.Data(account) + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + } else { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + } + } + } + } +} \ No newline at end of file diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt index 96ee13059..93ae78c75 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt @@ -7,7 +7,6 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowForwardIos @@ -28,8 +27,6 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -37,13 +34,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.fragment.app.Fragment -import coil.compose.AsyncImage -import coil.request.ImageRequest import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.R import org.openedx.core.UIMessage -import org.openedx.profile.domain.model.Account import org.openedx.core.domain.model.ProfileImage import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.AppDataHolder @@ -53,7 +47,10 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.EmailUtil +import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter +import org.openedx.profile.presentation.ui.ProfileInfoSection +import org.openedx.profile.presentation.ui.ProfileTopic class ProfileFragment : Fragment() { @@ -254,67 +251,29 @@ private fun ProfileScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - val profileImage = if (uiState.account.profileImage.hasImage) { - uiState.account.profileImage.imageUrlFull - } else { - R.drawable.core_ic_default_profile_picture - } - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(profileImage) - .error(R.drawable.core_ic_default_profile_picture) - .placeholder(R.drawable.core_ic_default_profile_picture) - .build(), - contentDescription = null, - modifier = Modifier - .border( - 2.dp, - MaterialTheme.appColors.onSurface, - CircleShape - ) - .padding(2.dp) - .size(100.dp) - .clip(CircleShape) - ) - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = uiState.account.name, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.headlineSmall - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "@${uiState.account.username}", - color = MaterialTheme.appColors.textPrimaryVariant, - style = MaterialTheme.appTypography.labelLarge - ) - Spacer(modifier = Modifier.height(36.dp)) + ProfileTopic(uiState.account) - Column( - Modifier - .fillMaxWidth() - ) { - ProfileInfoSection(uiState.account) + Spacer(modifier = Modifier.height(36.dp)) - Spacer(modifier = Modifier.height(24.dp)) + ProfileInfoSection(uiState.account) - SettingsSection(onVideoSettingsClick = { - onVideoSettingsClick() - }) + Spacer(modifier = Modifier.height(24.dp)) - Spacer(modifier = Modifier.height(24.dp)) + SettingsSection(onVideoSettingsClick = { + onVideoSettingsClick() + }) - SupportInfoSection(appData, onClick = onSupportClick) + Spacer(modifier = Modifier.height(24.dp)) - Spacer(modifier = Modifier.height(24.dp)) + SupportInfoSection(appData, onClick = onSupportClick) - LogoutButton( - onClick = { showLogoutDialog = true } - ) + Spacer(modifier = Modifier.height(24.dp)) - Spacer(Modifier.height(30.dp)) - } + LogoutButton( + onClick = { showLogoutDialog = true } + ) + Spacer(Modifier.height(30.dp)) } } } @@ -330,78 +289,6 @@ private fun ProfileScreen( } } -@Composable -private fun ProfileInfoSection(account: Account) { - - if (account.yearOfBirth != null || account.bio.isNotEmpty()) { - Column { - Text( - text = stringResource(id = org.openedx.profile.R.string.profile_prof_info), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary - ) - Spacer(modifier = Modifier.height(14.dp)) - Card( - modifier = Modifier, - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Column( - Modifier - .fillMaxWidth() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - if (account.yearOfBirth != null) { - Text( - text = buildAnnotatedString { - val value = if (account.yearOfBirth != null) { - account.yearOfBirth.toString() - } else "" - val text = stringResource( - id = org.openedx.profile.R.string.profile_year_of_birth, - value - ) - append(text) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimaryVariant - ), - start = 0, - end = text.length - value.length - ) - }, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - } - if (account.bio.isNotEmpty()) { - Text( - text = buildAnnotatedString { - val text = stringResource( - id = org.openedx.profile.R.string.profile_bio, - account.bio - ) - append(text) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimaryVariant - ), - start = 0, - end = text.length - account.bio.length - ) - }, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - } - } - } - } - } -} - @Composable fun SettingsSection(onVideoSettingsClick: () -> Unit) { Column { diff --git a/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt new file mode 100644 index 000000000..9dceab592 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt @@ -0,0 +1,148 @@ +package org.openedx.profile.presentation.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.profile.domain.model.Account + +@Composable +fun ProfileTopic(account: Account) { + Column( + Modifier.fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val profileImage = if (account.profileImage.hasImage) { + account.profileImage.imageUrlFull + } else { + R.drawable.core_ic_default_profile_picture + } + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(profileImage) + .error(R.drawable.core_ic_default_profile_picture) + .placeholder(R.drawable.core_ic_default_profile_picture) + .build(), + contentDescription = stringResource(id = R.string.core_accessibility_user_profile_image, account.username), + modifier = Modifier + .border( + 2.dp, + MaterialTheme.appColors.onSurface, + CircleShape + ) + .padding(2.dp) + .size(100.dp) + .clip(CircleShape) + ) + if (account.name.isNotEmpty()) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = account.name, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.headlineSmall + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "@${account.username}", + color = MaterialTheme.appColors.textPrimaryVariant, + style = MaterialTheme.appTypography.labelLarge + ) + } +} + +@Composable +fun ProfileInfoSection(account: Account) { + + if (account.yearOfBirth != null || account.bio.isNotEmpty()) { + Column { + Text( + text = stringResource(id = org.openedx.profile.R.string.profile_prof_info), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column( + Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (account.yearOfBirth != null) { + Text( + text = buildAnnotatedString { + val value = if (account.yearOfBirth != null) { + account.yearOfBirth.toString() + } else "" + val text = stringResource( + id = org.openedx.profile.R.string.profile_year_of_birth, + value + ) + append(text) + addStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textPrimaryVariant + ), + start = 0, + end = text.length - value.length + ) + }, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + } + if (account.bio.isNotEmpty()) { + Text( + text = buildAnnotatedString { + val text = stringResource( + id = org.openedx.profile.R.string.profile_bio, + account.bio + ) + append(text) + addStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textPrimaryVariant + ), + start = 0, + end = text.length - account.bio.length + ) + }, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt new file mode 100644 index 000000000..990248b2e --- /dev/null +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt @@ -0,0 +1,121 @@ +package org.openedx.profile.presentation.profile + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.ProfileImage +import org.openedx.core.system.ResourceManager +import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.presentation.anothers_account.AnothersProfileUIState +import org.openedx.profile.presentation.anothers_account.AnothersProfileViewModel +import java.net.UnknownHostException + +@OptIn(ExperimentalCoroutinesApi::class) +class AnothersProfileViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + private val resourceManager = mockk() + private val interactor = mockk() + private val username = "username" + + private val account = org.openedx.profile.domain.model.Account( + username = "", + bio = "", + requiresParentalConsent = false, + name = "", + country = "", + isActive = true, + profileImage = ProfileImage("", "", "", "", false), + yearOfBirth = 2000, + levelOfEducation = "", + goals = "", + languageProficiencies = emptyList(), + gender = "", + mailingAddress = "", + email = "", + dateJoined = null, + accountPrivacy = org.openedx.profile.domain.model.Account.Privacy.PRIVATE + ) + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getAccount no internetConnection`() = runTest { + val viewModel = AnothersProfileViewModel( + interactor, + resourceManager, + username + ) + coEvery { interactor.getAccount(username) } throws UnknownHostException() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getAccount(username) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + assert(viewModel.uiState.value is AnothersProfileUIState.Loading) + assertEquals(noInternet, message?.message) + } + + @Test + fun `getAccount unknown exception`() = runTest { + val viewModel = AnothersProfileViewModel( + interactor, + resourceManager, + username + ) + coEvery { interactor.getAccount(username) } throws Exception() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getAccount(username) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + assert(viewModel.uiState.value is AnothersProfileUIState.Loading) + assertEquals(somethingWrong, message?.message) + } + + @Test + fun `getAccount success`() = runTest { + val viewModel = AnothersProfileViewModel( + interactor, + resourceManager, + username + ) + coEvery { interactor.getAccount(username) } returns account + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getAccount(username) } + + assert(viewModel.uiState.value is AnothersProfileUIState.Data) + assert(viewModel.uiMessage.value == null) + } +} \ No newline at end of file