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