From bbc713bb8300574a1fcdbe34f951bf8c9af4f895 Mon Sep 17 00:00:00 2001 From: k1rill Date: Tue, 12 Dec 2023 22:15:01 +0300 Subject: [PATCH] refactor: support info section --- .../java/org/openedx/app/AnalyticsManager.kt | 5 + .../main/java/org/openedx/app/AppRouter.kt | 12 +- .../java/org/openedx/app/di/ScreenModule.kt | 20 +- .../global/webview/WebContentFragment.kt | 48 ++ .../org/openedx/core/ui/WebContentScreen.kt | 213 +++++ .../java/org/openedx/core/utils/EmailUtil.kt | 12 +- .../handouts/HandoutsWebViewFragment.kt | 106 +++ .../presentation/handouts/WebViewFragment.kt | 287 ------- .../profile/presentation/ProfileAnalytics.kt | 1 + .../profile/presentation/ProfileRouter.kt | 2 + .../presentation/profile/ProfileFragment.kt | 790 +----------------- .../presentation/profile/ProfileUIState.kt | 10 +- .../presentation/profile/ProfileViewModel.kt | 74 +- .../profile/compose/ProfileView.kt | 741 ++++++++++++++++ .../profile/ProfileViewModelTest.kt | 26 + 15 files changed, 1285 insertions(+), 1062 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt create mode 100644 core/src/main/java/org/openedx/core/ui/WebContentScreen.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt delete mode 100644 course/src/main/java/org/openedx/course/presentation/handouts/WebViewFragment.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 264b52286..4f1f089b1 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -163,6 +163,10 @@ class AnalyticsManager( logEvent(Event.PRIVACY_POLICY_CLICKED) } + override fun termsOfUseClickedEvent() { + logEvent(Event.TERMS_OF_USE_CLICKED) + } + override fun cookiePolicyClickedEvent() { logEvent(Event.COOKIE_POLICY_CLICKED) } @@ -415,6 +419,7 @@ private enum class Event(val eventName: String) { PROFILE_DELETE_ACCOUNT_CLICKED("Profile_Delete_Account_Clicked"), PROFILE_VIDEO_SETTINGS_CLICKED("Profile_Video_settings_Clicked"), PRIVACY_POLICY_CLICKED("Privacy_Policy_Clicked"), + TERMS_OF_USE_CLICKED("Terms_Of_Use_Clicked"), COOKIE_POLICY_CLICKED("Cookie_Policy_Clicked"), EMAIL_SUPPORT_CLICKED("Email_Support_Clicked"), COURSE_ENROLL_CLICKED("Course_Enroll_Clicked"), diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 90e2610f1..f6a0814ff 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -13,12 +13,13 @@ import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment +import org.openedx.core.presentation.global.webview.WebContentFragment import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.container.NoAccessCourseContainerFragment import org.openedx.course.presentation.detail.CourseDetailsFragment import org.openedx.course.presentation.handouts.HandoutsType -import org.openedx.course.presentation.handouts.WebViewFragment +import org.openedx.course.presentation.handouts.HandoutsWebViewFragment import org.openedx.course.presentation.section.CourseSectionFragment import org.openedx.course.presentation.unit.container.CourseUnitContainerFragment import org.openedx.course.presentation.unit.video.VideoFullScreenFragment @@ -204,7 +205,7 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ) { replaceFragmentWithBackStack( fm, - WebViewFragment.newInstance(title, type.name, courseId) + HandoutsWebViewFragment.newInstance(title, type.name, courseId) ) } //endregion @@ -288,6 +289,13 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, DeleteProfileFragment()) } + override fun navigateToWebContent(fm: FragmentManager, title: String, url: String) { + replaceFragmentWithBackStack( + fm, + WebContentFragment.newInstance(title = title, url = url) + ) + } + override fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) { fm.apply { for (fragment in fragments) { 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 5abb6fb05..dbb861232 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -89,15 +89,17 @@ val screenModule = module { factory { ProfileInteractor(get()) } viewModel { ProfileViewModel( - get(), - get(), - get(), - get(), - get(named("IODispatcher")), - get(), - get(), - get(), - get() + appData = get(), + config = get(), + interactor = get(), + resourceManager = get(), + notifier = get(), + dispatcher = get(named("IODispatcher")), + cookieManager = get(), + workerController = get(), + analytics = get(), + appUpgradeNotifier = get(), + router = get(), ) } viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), account) } diff --git a/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt b/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt new file mode 100644 index 000000000..de7346dcb --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/webview/WebContentFragment.kt @@ -0,0 +1,48 @@ +package org.openedx.core.presentation.global.webview + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import org.openedx.core.ui.WebContentScreen +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.theme.OpenEdXTheme + +class WebContentFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + WebContentScreen( + windowSize = windowSize, + title = requireArguments().getString(ARG_TITLE, ""), + contentUrl = requireArguments().getString(ARG_URL, ""), + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }) + } + } + } + + companion object { + private const val ARG_TITLE = "argTitle" + private const val ARG_URL = "argUrl" + + fun newInstance(title: String, url: String): WebContentFragment { + val fragment = WebContentFragment() + fragment.arguments = bundleOf( + ARG_TITLE to title, + ARG_URL to url, + ) + return fragment + } + } +} diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt new file mode 100644 index 000000000..e58879326 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -0,0 +1,213 @@ +package org.openedx.core.ui + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import org.openedx.core.extension.isEmailValid +import org.openedx.core.extension.replaceLinkTags +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.EmailUtil +import java.nio.charset.StandardCharsets + +@Composable +fun WebContentScreen( + windowSize: WindowSize, + apiHostUrl: String? = null, + title: String, + onBackClick: () -> Unit, + htmlBody: String? = null, + contentUrl: String? = null, +) { + val scaffoldState = rememberScaffoldState() + Scaffold( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 16.dp), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(it) + .statusBarsInset() + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column(screenWidth) { + Box( + Modifier + .fillMaxWidth() + .zIndex(1f), + contentAlignment = Alignment.CenterStart + ) { + BackBtn { + onBackClick() + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 56.dp), + text = title, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.height(6.dp)) + Surface( + Modifier.fillMaxSize(), + color = MaterialTheme.appColors.background + ) { + if (htmlBody.isNullOrEmpty() && contentUrl.isNullOrEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.appColors.background) + .zIndex(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else { + var webViewAlpha by rememberSaveable { mutableFloatStateOf(0f) } + Surface( + Modifier + .padding(horizontal = 16.dp, vertical = 24.dp) + .alpha(webViewAlpha), + color = MaterialTheme.appColors.background + ) { + WebViewContent( + apiHostUrl = apiHostUrl, + body = htmlBody, + contentUrl = contentUrl, + onWebPageLoaded = { + webViewAlpha = 1f + }) + } + } + } + } + } + } +} + +@Composable +@SuppressLint("SetJavaScriptEnabled") +private fun WebViewContent( + apiHostUrl: String? = null, + body: String? = null, + contentUrl: String? = null, + onWebPageLoaded: () -> Unit +) { + val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() + AndroidView( + factory = { + WebView(context).apply { + webViewClient = object : WebViewClient() { + override fun onPageCommitVisible(view: WebView?, url: String?) { + super.onPageCommitVisible(view, url) + onWebPageLoaded() + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val clickUrl = request?.url?.toString() ?: "" + return if (clickUrl.isNotEmpty() && + (clickUrl.startsWith("http://") || + clickUrl.startsWith("https://")) + ) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) + true + } else if (clickUrl.startsWith("mailto:")) { + val email = clickUrl.replace("mailto:", "") + if (email.isEmailValid()) { + EmailUtil.sendEmailIntent(context, email, "", "") + true + } else { + false + } + } else { + false + } + } + } + with(settings) { + javaScriptEnabled = true + loadWithOverviewMode = true + builtInZoomControls = false + setSupportZoom(true) + loadsImagesAutomatically = true + domStorageEnabled = true + } + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + body?.let { + loadDataWithBaseURL( + apiHostUrl, + body.replaceLinkTags(isDarkTheme), + "text/html", + StandardCharsets.UTF_8.name(), + null + ) + } + contentUrl?.let { + loadUrl(it) + } + } + }, + ) +} diff --git a/core/src/main/java/org/openedx/core/utils/EmailUtil.kt b/core/src/main/java/org/openedx/core/utils/EmailUtil.kt index 0ad35de51..c56b606ae 100644 --- a/core/src/main/java/org/openedx/core/utils/EmailUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/EmailUtil.kt @@ -3,6 +3,7 @@ package org.openedx.core.utils import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build import android.widget.Toast import org.openedx.core.R @@ -37,11 +38,12 @@ object EmailUtil { subject: String, email: String ) { - val emailIntent = Intent(Intent.ACTION_SEND) - emailIntent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) - emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject) - emailIntent.putExtra(Intent.EXTRA_TEXT, email) - emailIntent.type = "plain/text" + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, email) + } try { emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context?.let { diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt new file mode 100644 index 000000000..cb1816b47 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt @@ -0,0 +1,106 @@ +package org.openedx.course.presentation.handouts + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +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.ui.WebContentScreen +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +class HandoutsWebViewFragment : Fragment() { + + private val viewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_TYPE, "") + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + + val htmlBody by viewModel.htmlContent.observeAsState("") + val colorBackgroundValue = MaterialTheme.appColors.background.value + val colorTextValue = MaterialTheme.appColors.textPrimary.value + + WebContentScreen( + windowSize = windowSize, + apiHostUrl = viewModel.apiHostUrl, + title = requireArguments().getString(ARG_TITLE, ""), + htmlBody = viewModel.injectDarkMode( + htmlBody, + colorBackgroundValue, + colorTextValue + ), + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }) + } + } + } + + companion object { + private val ARG_TITLE = "argTitle" + private val ARG_TYPE = "argType" + private val ARG_COURSE_ID = "argCourse" + + fun newInstance( + title: String, + type: String, + courseId: String + ): HandoutsWebViewFragment { + val fragment = HandoutsWebViewFragment() + fragment.arguments = bundleOf( + ARG_TITLE to title, + ARG_TYPE to type, + ARG_COURSE_ID to courseId + ) + return fragment + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun WebContentScreenPreview() { + WebContentScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + apiHostUrl = "http://localhost:8000", + title = "Handouts", onBackClick = { }, htmlBody = "" + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +fun WebContentScreenTabletPreview() { + WebContentScreen( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + apiHostUrl = "http://localhost:8000", + title = "Handouts", onBackClick = { }, htmlBody = "" + ) +} diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/WebViewFragment.kt b/course/src/main/java/org/openedx/course/presentation/handouts/WebViewFragment.kt deleted file mode 100644 index 957d9ad8f..000000000 --- a/course/src/main/java/org/openedx/course/presentation/handouts/WebViewFragment.kt +++ /dev/null @@ -1,287 +0,0 @@ -package org.openedx.course.presentation.handouts - -import android.annotation.SuppressLint -import android.content.Intent -import android.content.res.Configuration -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -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.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.zIndex -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.extension.isEmailValid -import org.openedx.core.extension.replaceLinkTags -import org.openedx.core.ui.* -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.utils.EmailUtil -import java.nio.charset.StandardCharsets - -class WebViewFragment : Fragment() { - - private val viewModel by viewModel { - parametersOf( - requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_TYPE, "") - ) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - - val htmlBody by viewModel.htmlContent.observeAsState("") - val colorBackgroundValue = MaterialTheme.appColors.background.value - val colorTextValue = MaterialTheme.appColors.textPrimary.value - - WebContentScreen( - windowSize = windowSize, - apiHostUrl = viewModel.apiHostUrl, - title = requireArguments().getString(ARG_TITLE, ""), - htmlBody = viewModel.injectDarkMode( - htmlBody, - colorBackgroundValue, - colorTextValue - ), - onBackClick = { - requireActivity().supportFragmentManager.popBackStack() - }) - } - } - } - - companion object { - private val ARG_TITLE = "argTitle" - private val ARG_TYPE = "argType" - private val ARG_COURSE_ID = "argCourse" - - fun newInstance( - title: String, - type: String, - courseId: String - ): WebViewFragment { - val fragment = WebViewFragment() - fragment.arguments = bundleOf( - ARG_TITLE to title, - ARG_TYPE to type, - ARG_COURSE_ID to courseId - ) - return fragment - } - } -} - -@Composable -private fun WebContentScreen( - windowSize: WindowSize, - apiHostUrl: String, - title: String, - onBackClick: () -> Unit, - htmlBody: String -) { - val scaffoldState = rememberScaffoldState() - Scaffold( - modifier = Modifier - .fillMaxSize() - .padding(bottom = 16.dp), - scaffoldState = scaffoldState, - backgroundColor = MaterialTheme.appColors.background - ) { - - val screenWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), - compact = Modifier.fillMaxWidth() - ) - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(it) - .statusBarsInset() - .displayCutoutForLandscape(), - contentAlignment = Alignment.TopCenter - ) { - Column(screenWidth) { - Box( - Modifier - .fillMaxWidth() - .zIndex(1f), - contentAlignment = Alignment.CenterStart - ) { - BackBtn { - onBackClick() - } - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 56.dp), - text = title, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center - ) - } - Spacer(Modifier.height(6.dp)) - Surface( - Modifier.fillMaxSize(), - color = MaterialTheme.appColors.background - ) { - if (htmlBody.isNotEmpty()) { - var webViewAlpha by rememberSaveable { mutableStateOf(0f) } - Surface( - Modifier - .padding(horizontal = 16.dp, vertical = 24.dp) - .alpha(webViewAlpha), - color = MaterialTheme.appColors.background - ) { - HandoutsContent( - apiHostUrl = apiHostUrl, - body = htmlBody, - onWebPageLoaded = { - webViewAlpha = 1f - }) - } - } else { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.appColors.background) - .zIndex(1f), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - } - } - } - } -} - -@Composable -@SuppressLint("SetJavaScriptEnabled") -private fun HandoutsContent(apiHostUrl: String, body: String, onWebPageLoaded: () -> Unit) { - val context = LocalContext.current - val isDarkTheme = isSystemInDarkTheme() - AndroidView(modifier = Modifier, factory = { - WebView(context).apply { - webViewClient = object : WebViewClient() { - override fun onPageCommitVisible(view: WebView?, url: String?) { - super.onPageCommitVisible(view, url) - onWebPageLoaded() - } - - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest? - ): Boolean { - val clickUrl = request?.url?.toString() ?: "" - return if (clickUrl.isNotEmpty() && - (clickUrl.startsWith("http://") || - clickUrl.startsWith("https://")) - ) { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(clickUrl))) - true - } else if (clickUrl.startsWith("mailto:")) { - val email = clickUrl.replace("mailto:", "") - if (email.isEmailValid()) { - EmailUtil.sendEmailIntent(context, email, "", "") - true - } else { - false - } - } else { - false - } - } - } - with(settings) { - javaScriptEnabled = true - loadWithOverviewMode = true - builtInZoomControls = false - setSupportZoom(true) - loadsImagesAutomatically = true - domStorageEnabled = true - } - isVerticalScrollBarEnabled = false - isHorizontalScrollBarEnabled = false - loadDataWithBaseURL( - apiHostUrl, - body.replaceLinkTags(isDarkTheme), - "text/html", - StandardCharsets.UTF_8.name(), - null - ) - } - }, update = { - it.loadDataWithBaseURL( - apiHostUrl, - body.replaceLinkTags(isDarkTheme), - "text/html", - StandardCharsets.UTF_8.name(), - null - ) - }) -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun WebContentScreenPreview() { - WebContentScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - apiHostUrl = "http://localhost:8000", - title = "Handouts", onBackClick = { }, htmlBody = "" - ) -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) -@Composable -fun WebContentScreenTabletPreview() { - WebContentScreen( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - apiHostUrl = "http://localhost:8000", - title = "Handouts", onBackClick = { }, htmlBody = "" - ) -} \ No newline at end of file diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt index 27990b563..418ce08e0 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileAnalytics.kt @@ -6,6 +6,7 @@ interface ProfileAnalytics { fun profileDeleteAccountClickedEvent() fun profileVideoSettingsClickedEvent() fun privacyPolicyClickedEvent() + fun termsOfUseClickedEvent() fun cookiePolicyClickedEvent() fun emailSupportClickedEvent() fun logoutEvent(force: Boolean) diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt index fda55061e..2bf343284 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -13,5 +13,7 @@ interface ProfileRouter { fun navigateToDeleteAccount(fm: FragmentManager) + fun navigateToWebContent(fm: FragmentManager, title: String, url: String) + fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) } 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 cbafc859a..6f558bd3a 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 @@ -1,64 +1,24 @@ package org.openedx.profile.presentation.profile -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.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForwardIos -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ExitToApp -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -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.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.compose.ui.window.Dialog import androidx.fragment.app.Fragment -import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.AppUpdateState -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.presentation.global.AppData -import org.openedx.core.system.notifier.AppUpgradeEvent -import org.openedx.core.ui.* +import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme -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 +import org.openedx.profile.presentation.profile.compose.ProfileView +import org.openedx.profile.presentation.profile.compose.ProfileViewAction class ProfileFragment : Fragment() { private val viewModel: ProfileViewModel by viewModel() - private val router by inject() - private val appData: AppData by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -74,731 +34,69 @@ class ProfileFragment : Fragment() { setContent { OpenEdXTheme { val windowSize = rememberWindowSize() - - val uiState by viewModel.uiState.observeAsState() + val uiState by viewModel.uiState.collectAsState() val logoutSuccess by viewModel.successLogout.observeAsState(false) val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.isUpdating.observeAsState(false) val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) - ProfileScreen( + ProfileView( windowSize = windowSize, - uiState = uiState!!, + uiState = uiState, uiMessage = uiMessage, - appData = appData, refreshing = refreshing, appUpgradeEvent = appUpgradeEvent, - logout = { - viewModel.logout() - }, - editAccountClicked = { - viewModel.profileEditClickedEvent() - router.navigateToEditProfile( - requireParentFragment().parentFragmentManager, - it - ) - }, - onSwipeRefresh = { - viewModel.updateAccount() - }, - onVideoSettingsClick = { - viewModel.profileVideoSettingsClickedEvent() - router.navigateToVideoSettings( - requireParentFragment().parentFragmentManager - ) - }, - onSupportClick = { action -> + onAction = { action -> when (action) { - SupportClickAction.SUPPORT -> viewModel.emailSupportClickedEvent() - SupportClickAction.COOKIE_POLICY -> viewModel.cookiePolicyClickedEvent() - SupportClickAction.PRIVACY_POLICY -> viewModel.privacyPolicyClickedEvent() - } - }, - onAppVersionClick = { - AppUpdateState.openPlayMarket(requireContext()) - } - ) - - LaunchedEffect(logoutSuccess) { - if (logoutSuccess) { - router.restartApp(requireParentFragment().parentFragmentManager, viewModel.isLogistrationEnabled) - } - } - } - } - } -} - -private enum class SupportClickAction { - SUPPORT, PRIVACY_POLICY, COOKIE_POLICY -} - - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun ProfileScreen( - windowSize: WindowSize, - uiState: ProfileUIState, - appData: AppData, - uiMessage: UIMessage?, - refreshing: Boolean, - appUpgradeEvent: AppUpgradeEvent?, - onVideoSettingsClick: () -> Unit, - logout: () -> Unit, - onSwipeRefresh: () -> Unit, - onSupportClick: (SupportClickAction) -> Unit, - editAccountClicked: (Account) -> Unit, - onAppVersionClick: () -> Unit -) { - val scaffoldState = rememberScaffoldState() - var showLogoutDialog by rememberSaveable { mutableStateOf(false) } - - val pullRefreshState = - rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - - Scaffold( - modifier = Modifier.fillMaxSize(), - scaffoldState = scaffoldState - ) { paddingValues -> - - 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() - ) - ) - } - - - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - if (showLogoutDialog) { - LogoutDialog( - onDismissRequest = { - showLogoutDialog = false - }, - onLogoutClick = { - showLogoutDialog = false - logout() - } - ) - } - - Column( - modifier = Modifier - .padding(paddingValues) - .statusBarsInset() - .displayCutoutForLandscape(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = topBarWidth, - contentAlignment = Alignment.CenterEnd - ) { - Text( - modifier = Modifier - .fillMaxWidth(), - text = stringResource(id = R.string.core_profile), - color = MaterialTheme.appColors.textPrimary, - textAlign = TextAlign.Center, - style = MaterialTheme.appTypography.titleMedium - ) - - IconText( - modifier = Modifier - .height(48.dp) - .padding(end = 24.dp), - text = stringResource(org.openedx.profile.R.string.profile_edit), - painter = painterResource(id = R.drawable.core_ic_edit), - textStyle = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.primary, - onClick = { - if (uiState is ProfileUIState.Data) { - editAccountClicked(uiState.account) - } - } - ) - } - Surface( - color = MaterialTheme.appColors.background - ) { - Box( - modifier = Modifier.pullRefresh(pullRefreshState), - contentAlignment = Alignment.TopCenter - ) { - Column( - modifier = Modifier - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - when (uiState) { - is ProfileUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } + ProfileViewAction.AppVersionClick -> { + viewModel.appVersionClickedEvent(requireContext()) } - is ProfileUIState.Data -> { - Column( - Modifier - .fillMaxHeight() - .then(contentWidth) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - ProfileTopic(uiState.account) - - Spacer(modifier = Modifier.height(36.dp)) - - ProfileInfoSection(uiState.account) - - Spacer(modifier = Modifier.height(24.dp)) + ProfileViewAction.EditAccountClick -> { + viewModel.profileEditClicked( + requireParentFragment().parentFragmentManager + ) + } - SettingsSection(onVideoSettingsClick = { - onVideoSettingsClick() - }) + ProfileViewAction.LogoutClick -> { + viewModel.logout() + } - Spacer(modifier = Modifier.height(24.dp)) + ProfileViewAction.PrivacyPolicyClick -> { + viewModel.privacyPolicyClicked( + requireParentFragment().parentFragmentManager + ) + } - SupportInfoSection( - appData = appData, - onClick = onSupportClick, - appUpgradeEvent = appUpgradeEvent, - onAppVersionClick = onAppVersionClick - ) + ProfileViewAction.SupportClick -> { + viewModel.emailSupportClicked(requireContext()) + } - Spacer(modifier = Modifier.height(24.dp)) + ProfileViewAction.TermsClick -> { + viewModel.termsOfUseClicked( + requireParentFragment().parentFragmentManager + ) + } - LogoutButton( - onClick = { showLogoutDialog = true } - ) + ProfileViewAction.VideoSettingsClick -> { + viewModel.profileVideoSettingsClicked( + requireParentFragment().parentFragmentManager + ) + } - Spacer(Modifier.height(30.dp)) - } + ProfileViewAction.SwipeRefresh -> { + viewModel.updateAccount() } } - } - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - } - } - } - } -} - -@Composable -fun SettingsSection(onVideoSettingsClick: () -> Unit) { - Column { - Text( - text = stringResource(id = org.openedx.profile.R.string.profile_settings), - 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(24.dp) - ) { - ProfileInfoItem( - text = stringResource(id = org.openedx.profile.R.string.profile_video_settings), - onClick = onVideoSettingsClick - ) - } - } - } -} - -@Composable -private fun SupportInfoSection( - appData: AppData, - appUpgradeEvent: AppUpgradeEvent?, - onAppVersionClick: () -> Unit, - onClick: (SupportClickAction) -> Unit -) { - val uriHandler = LocalUriHandler.current - val context = LocalContext.current - Column { - Text( - text = stringResource(id = org.openedx.profile.R.string.profile_support_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(24.dp) - ) { - ProfileInfoItem( - text = stringResource(id = org.openedx.profile.R.string.profile_contact_support), - onClick = { - onClick(SupportClickAction.SUPPORT) - EmailUtil.showFeedbackScreen( - context = context, - feedbackEmailAddress = appData.feedbackEmailAddress, - appVersion = appData.versionName - ) - } - ) - Divider(color = MaterialTheme.appColors.divider) - ProfileInfoItem( - text = stringResource(id = R.string.core_terms_of_use), - onClick = { - onClick(SupportClickAction.COOKIE_POLICY) - uriHandler.openUri(appData.tosUrl) - } - ) - Divider(color = MaterialTheme.appColors.divider) - ProfileInfoItem( - text = stringResource(id = R.string.core_privacy_policy), - onClick = { - onClick(SupportClickAction.PRIVACY_POLICY) - uriHandler.openUri(appData.privacyPolicyUrl) - } - ) - Divider(color = MaterialTheme.appColors.divider) - AppVersionItem( - appData = appData, - appUpgradeEvent = appUpgradeEvent, - onClick = onAppVersionClick + }, ) - } - } - } -} - -@Composable -private fun LogoutButton(onClick: () -> Unit) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { - onClick() - }, - shape = MaterialTheme.appShapes.cardShape, - elevation = 0.dp, - backgroundColor = MaterialTheme.appColors.cardViewBackground - ) { - Row( - modifier = Modifier.padding(20.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = org.openedx.profile.R.string.profile_logout), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.error - ) - Icon( - imageVector = Icons.Filled.ExitToApp, - contentDescription = null, - tint = MaterialTheme.appColors.error - ) - } - } -} -@Composable -private fun LogoutDialog( - onDismissRequest: () -> Unit, - onLogoutClick: () -> Unit, -) { - Dialog( - onDismissRequest = onDismissRequest, - content = { - Column( - Modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .background( - MaterialTheme.appColors.background, - MaterialTheme.appShapes.cardShape - ) - .clip(MaterialTheme.appShapes.cardShape) - .border( - 1.dp, - MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.cardShape - ) - .padding(horizontal = 40.dp, vertical = 36.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterEnd - ) { - IconButton( - modifier = Modifier.size(24.dp), - onClick = onDismissRequest - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.core_cancel), - tint = MaterialTheme.appColors.primary - ) + LaunchedEffect(logoutSuccess) { + if (logoutSuccess) { + viewModel.restartApp(requireParentFragment().parentFragmentManager) } } - Icon( - modifier = Modifier - .width(88.dp) - .height(85.dp), - painter = painterResource(org.openedx.profile.R.drawable.profile_ic_exit), - contentDescription = null, - tint = MaterialTheme.appColors.onBackground - ) - Spacer(Modifier.size(36.dp)) - Text( - text = stringResource(id = org.openedx.profile.R.string.profile_logout_dialog_body), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleLarge, - textAlign = TextAlign.Center - ) - Spacer(Modifier.size(36.dp)) - OpenEdXButton( - text = stringResource(id = org.openedx.profile.R.string.profile_logout), - backgroundColor = MaterialTheme.appColors.warning, - onClick = onLogoutClick, - content = { - Box( - Modifier - .fillMaxWidth(), - contentAlignment = Alignment.CenterEnd - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = org.openedx.profile.R.string.profile_logout), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.labelLarge, - textAlign = TextAlign.Center - ) - Icon( - painter = painterResource(id = org.openedx.profile.R.drawable.profile_ic_logout), - contentDescription = null, - tint = Color.Black - ) - } - } - ) - } - } - ) -} - -@Composable -private fun ProfileInfoItem(text: String, onClick: () -> Unit) { - Row( - Modifier - .fillMaxWidth() - .clickable { onClick() }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = text, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.Filled.ArrowForwardIos, - contentDescription = null - ) - } -} - -@Composable -fun AppVersionItem( - appData: AppData, - appUpgradeEvent: AppUpgradeEvent?, - onClick: () -> Unit -) { - when (appUpgradeEvent) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - AppVersionItemUpgradeRecommended( - versionName = appData.versionName, - appUpgradeEvent = appUpgradeEvent, - onClick = onClick - ) - } - - is AppUpgradeEvent.UpgradeRequiredEvent -> { - AppVersionItemUpgradeRequired( - versionName = appData.versionName, - onClick = onClick - ) - } - - else -> { - AppVersionItemAppToDate( - versionName = appData.versionName - ) - } - } -} - -@Composable -private fun AppVersionItemAppToDate(versionName: String) { - Column( - Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(id = R.string.core_version, versionName), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - modifier = Modifier.size((MaterialTheme.appTypography.labelLarge.fontSize.value + 4).dp), - painter = painterResource(id = R.drawable.core_ic_check), - contentDescription = null, - tint = MaterialTheme.appColors.accessGreen - ) - Text( - text = stringResource(id = R.string.core_up_to_date), - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelLarge - ) - } - } -} - -@Composable -fun AppVersionItemUpgradeRecommended( - versionName: String, - appUpgradeEvent: AppUpgradeEvent.UpgradeRecommendedEvent, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onClick() - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(id = R.string.core_version, versionName), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) - Text( - text = stringResource( - id = R.string.core_tap_to_update_to_version, - appUpgradeEvent.newVersionName - ), - color = MaterialTheme.appColors.textAccent, - style = MaterialTheme.appTypography.labelLarge - ) - } - Icon( - modifier = Modifier.size(28.dp), - painter = painterResource(id = R.drawable.core_ic_icon_upgrade), - tint = MaterialTheme.appColors.primary, - contentDescription = null - ) - } -} - -@Composable -fun AppVersionItemUpgradeRequired( - versionName: String, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onClick() - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Image( - modifier = Modifier - .size((MaterialTheme.appTypography.labelLarge.fontSize.value + 8).dp), - painter = painterResource(id = R.drawable.core_ic_warning), - contentDescription = null - ) - Text( - text = stringResource(id = R.string.core_version, versionName), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) } - Text( - text = stringResource(id = R.string.core_tap_to_install_required_app_update), - color = MaterialTheme.appColors.textAccent, - style = MaterialTheme.appTypography.labelLarge - ) } - Icon( - modifier = Modifier.size(28.dp), - painter = painterResource(id = R.drawable.core_ic_icon_upgrade), - tint = MaterialTheme.appColors.primary, - contentDescription = null - ) } } - -@Preview -@Composable -fun AppVersionItemAppToDatePreview() { - OpenEdXTheme { - AppVersionItem( - appData = mockAppData, - appUpgradeEvent = null, - onClick = {} - ) - } -} - -@Preview -@Composable -fun AppVersionItemUpgradeRecommendedPreview() { - OpenEdXTheme { - AppVersionItem( - appData = mockAppData, - appUpgradeEvent = AppUpgradeEvent.UpgradeRecommendedEvent("1.0.1"), - onClick = {} - ) - } -} - -@Preview -@Composable -fun AppVersionItemUpgradeRequiredPreview() { - OpenEdXTheme { - AppVersionItem( - appData = mockAppData, - appUpgradeEvent = AppUpgradeEvent.UpgradeRequiredEvent, - onClick = {} - ) - } -} - -@Preview -@Composable -fun LogoutDialogPreview() { - LogoutDialog({}, {}) -} - -@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 { - ProfileScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = ProfileUIState.Data(mockAccount), - uiMessage = null, - refreshing = false, - logout = {}, - onSwipeRefresh = {}, - editAccountClicked = {}, - onVideoSettingsClick = {}, - onSupportClick = {}, - appData = mockAppData, - appUpgradeEvent = null, - onAppVersionClick = {} - ) - } -} - - -@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 { - ProfileScreen( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = ProfileUIState.Data(mockAccount), - uiMessage = null, - refreshing = false, - logout = {}, - onSwipeRefresh = {}, - editAccountClicked = {}, - onVideoSettingsClick = {}, - onSupportClick = {}, - appData = mockAppData, - appUpgradeEvent = null, - onAppVersionClick = {} - ) - } -} - -private val mockAppData = AppData( - versionName = "1.0.0", - feedbackEmailAddress = "support@example.com", - tosUrl = "https://example.com/tos", - privacyPolicyUrl = "https://example.com/privacy", -) - -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/profile/ProfileUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt index 83fe04108..86c0f364b 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileUIState.kt @@ -3,6 +3,14 @@ package org.openedx.profile.presentation.profile import org.openedx.profile.domain.model.Account sealed class ProfileUIState { - data class Data(val account: Account) : ProfileUIState() + /** + * @param account User account data + * @param versionName Version of the application (1.0.0) + */ + data class Data( + val account: Account, + val versionName: String, + ) : ProfileUIState() + object Loading : ProfileUIState() } diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt index 044fa5b65..139a8f81f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt @@ -1,29 +1,39 @@ package org.openedx.profile.presentation.profile +import android.content.Context +import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.openedx.core.AppUpdateState import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeEvent import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.utils.EmailUtil import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.system.notifier.AccountDeactivated import org.openedx.profile.system.notifier.AccountUpdated import org.openedx.profile.system.notifier.ProfileNotifier class ProfileViewModel( + private val appData: AppData, private val config: Config, private val interactor: ProfileInteractor, private val resourceManager: ResourceManager, @@ -32,12 +42,13 @@ class ProfileViewModel( private val cookieManager: AppCookieManager, private val workerController: DownloadWorkerController, private val analytics: ProfileAnalytics, + private val router: ProfileRouter, private val appUpgradeNotifier: AppUpgradeNotifier ) : BaseViewModel() { - private val _uiState = MutableLiveData(ProfileUIState.Loading) - val uiState: LiveData - get() = _uiState + private val _uiState: MutableStateFlow = + MutableStateFlow(ProfileUIState.Loading) + internal val uiState: StateFlow = _uiState.asStateFlow() private val _successLogout = MutableLiveData() val successLogout: LiveData @@ -83,10 +94,16 @@ class ProfileViewModel( if (cachedAccount == null) { _uiState.value = ProfileUIState.Loading } else { - _uiState.value = ProfileUIState.Data(cachedAccount) + _uiState.value = ProfileUIState.Data( + account = cachedAccount, + versionName = appData.versionName + ) } val account = interactor.getAccount() - _uiState.value = ProfileUIState.Data(account) + _uiState.value = ProfileUIState.Data( + account = account, + versionName = appData.versionName + ) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = @@ -137,24 +154,57 @@ class ProfileViewModel( } } - fun profileEditClickedEvent() { + fun profileEditClicked(fragmentManager: FragmentManager) { + (uiState.value as? ProfileUIState.Data)?.let { data -> + router.navigateToEditProfile( + fragmentManager, + data.account + ) + } analytics.profileEditClickedEvent() } - fun profileVideoSettingsClickedEvent() { + fun profileVideoSettingsClicked(fragmentManager: FragmentManager) { + router.navigateToVideoSettings(fragmentManager) analytics.profileVideoSettingsClickedEvent() } - fun privacyPolicyClickedEvent() { + fun privacyPolicyClicked(fragmentManager: FragmentManager) { + router.navigateToWebContent( + fm = fragmentManager, + title = resourceManager.getString(R.string.core_privacy_policy), + url = appData.privacyPolicyUrl, + ) analytics.privacyPolicyClickedEvent() } - fun cookiePolicyClickedEvent() { - analytics.cookiePolicyClickedEvent() + fun termsOfUseClicked(fragmentManager: FragmentManager) { + router.navigateToWebContent( + fm = fragmentManager, + title = resourceManager.getString(R.string.core_terms_of_use), + url = appData.tosUrl, + ) + analytics.termsOfUseClickedEvent() } - fun emailSupportClickedEvent() { + fun emailSupportClicked(context: Context) { + EmailUtil.showFeedbackScreen( + context = context, + feedbackEmailAddress = appData.feedbackEmailAddress, + appVersion = appData.versionName + ) analytics.emailSupportClickedEvent() } -} \ No newline at end of file + fun appVersionClickedEvent(context: Context) { + AppUpdateState.openPlayMarket(context) + } + + fun restartApp(fragmentManager: FragmentManager) { + router.restartApp( + fragmentManager, + isLogistrationEnabled + ) + } + +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt new file mode 100644 index 000000000..f03a17b18 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt @@ -0,0 +1,741 @@ +package org.openedx.profile.presentation.profile.compose + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ExitToApp +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +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.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +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.compose.ui.window.Dialog +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.ProfileImage +import org.openedx.core.presentation.global.AppData +import org.openedx.core.system.notifier.AppUpgradeEvent +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +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.appShapes +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.profile.ProfileUIState +import org.openedx.profile.presentation.ui.ProfileInfoSection +import org.openedx.profile.presentation.ui.ProfileTopic + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun ProfileView( + windowSize: WindowSize, + uiState: ProfileUIState, + uiMessage: UIMessage?, + refreshing: Boolean, + appUpgradeEvent: AppUpgradeEvent?, + onAction: (ProfileViewAction) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + var showLogoutDialog by rememberSaveable { mutableStateOf(false) } + + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { onAction(ProfileViewAction.SwipeRefresh) }) + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState + ) { paddingValues -> + + 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() + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + if (showLogoutDialog) { + LogoutDialog( + onDismissRequest = { + showLogoutDialog = false + }, + onLogoutClick = { + showLogoutDialog = false + onAction(ProfileViewAction.LogoutClick) + } + ) + } + + Column( + modifier = Modifier + .padding(paddingValues) + .statusBarsInset() + .displayCutoutForLandscape(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = topBarWidth, + contentAlignment = Alignment.CenterEnd + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(id = R.string.core_profile), + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + style = MaterialTheme.appTypography.titleMedium + ) + + IconText( + modifier = Modifier + .height(48.dp) + .padding(end = 24.dp), + text = stringResource(org.openedx.profile.R.string.profile_edit), + painter = painterResource(id = R.drawable.core_ic_edit), + textStyle = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + onClick = { + if (uiState is ProfileUIState.Data) { + onAction(ProfileViewAction.EditAccountClick) + } + } + ) + } + Surface( + color = MaterialTheme.appColors.background + ) { + Box( + modifier = Modifier.pullRefresh(pullRefreshState), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (uiState) { + is ProfileUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is ProfileUIState.Data -> { + Column( + Modifier + .fillMaxHeight() + .then(contentWidth) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProfileTopic(uiState.account) + + Spacer(modifier = Modifier.height(36.dp)) + + ProfileInfoSection(uiState.account) + + Spacer(modifier = Modifier.height(24.dp)) + + SettingsSection(onVideoSettingsClick = { + onAction(ProfileViewAction.VideoSettingsClick) + }) + + Spacer(modifier = Modifier.height(24.dp)) + + SupportInfoSection( + versionName = uiState.versionName, + onAction = onAction, + appUpgradeEvent = appUpgradeEvent, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + LogoutButton( + onClick = { showLogoutDialog = true } + ) + + Spacer(Modifier.height(30.dp)) + } + } + } + } + PullRefreshIndicator( + refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + } + } + } + } +} + +@Composable +private fun SettingsSection(onVideoSettingsClick: () -> Unit) { + Column { + Text( + text = stringResource(id = org.openedx.profile.R.string.profile_settings), + 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(24.dp) + ) { + ProfileInfoItem( + text = stringResource(id = org.openedx.profile.R.string.profile_video_settings), + onClick = onVideoSettingsClick + ) + } + } + } +} + +@Composable +private fun SupportInfoSection( + versionName: String, + appUpgradeEvent: AppUpgradeEvent?, + onAction: (ProfileViewAction) -> Unit +) { + Column { + Text( + text = stringResource(id = org.openedx.profile.R.string.profile_support_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(24.dp) + ) { + ProfileInfoItem( + text = stringResource(id = org.openedx.profile.R.string.profile_contact_support), + onClick = { + onAction(ProfileViewAction.SupportClick) + } + ) + Divider(color = MaterialTheme.appColors.divider) + ProfileInfoItem( + text = stringResource(id = R.string.core_terms_of_use), + onClick = { + onAction(ProfileViewAction.TermsClick) + } + ) + Divider(color = MaterialTheme.appColors.divider) + ProfileInfoItem( + text = stringResource(id = R.string.core_privacy_policy), + onClick = { + onAction(ProfileViewAction.PrivacyPolicyClick) + } + ) + Divider(color = MaterialTheme.appColors.divider) + AppVersionItem( + versionName = versionName, + appUpgradeEvent = appUpgradeEvent, + ) { + onAction(ProfileViewAction.AppVersionClick) + } + } + } + } +} + +@Composable +private fun LogoutButton(onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + onClick() + }, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Row( + modifier = Modifier.padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = org.openedx.profile.R.string.profile_logout), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.error + ) + Icon( + imageVector = Icons.Filled.ExitToApp, + contentDescription = null, + tint = MaterialTheme.appColors.error + ) + } + } +} + +@Composable +private fun LogoutDialog( + onDismissRequest: () -> Unit, + onLogoutClick: () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + content = { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .background( + MaterialTheme.appColors.background, + MaterialTheme.appShapes.cardShape + ) + .clip(MaterialTheme.appShapes.cardShape) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.cardShape + ) + .padding(horizontal = 40.dp, vertical = 36.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = onDismissRequest + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + Icon( + modifier = Modifier + .width(88.dp) + .height(85.dp), + painter = painterResource(org.openedx.profile.R.drawable.profile_ic_exit), + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.size(36.dp)) + Text( + text = stringResource(id = org.openedx.profile.R.string.profile_logout_dialog_body), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(Modifier.size(36.dp)) + OpenEdXButton( + text = stringResource(id = org.openedx.profile.R.string.profile_logout), + backgroundColor = MaterialTheme.appColors.warning, + onClick = onLogoutClick, + content = { + Box( + Modifier + .fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = org.openedx.profile.R.string.profile_logout), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelLarge, + textAlign = TextAlign.Center + ) + Icon( + painter = painterResource(id = org.openedx.profile.R.drawable.profile_ic_logout), + contentDescription = null, + tint = Color.Black + ) + } + } + ) + } + } + ) +} + +@Composable +private fun ProfileInfoItem(text: String, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable { onClick() }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.fillMaxSize(), + text = text, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Filled.ArrowForwardIos, + contentDescription = null + ) + } +} + +@Composable +private fun AppVersionItem( + versionName: String, + appUpgradeEvent: AppUpgradeEvent?, + onClick: () -> Unit +) { + when (appUpgradeEvent) { + is AppUpgradeEvent.UpgradeRecommendedEvent -> { + AppVersionItemUpgradeRecommended( + versionName = versionName, + appUpgradeEvent = appUpgradeEvent, + onClick = onClick + ) + } + + is AppUpgradeEvent.UpgradeRequiredEvent -> { + AppVersionItemUpgradeRequired( + versionName = versionName, + onClick = onClick + ) + } + + else -> { + AppVersionItemAppToDate( + versionName = versionName + ) + } + } +} + +@Composable +private fun AppVersionItemAppToDate(versionName: String) { + Column( + Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(id = R.string.core_version, versionName), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + modifier = Modifier.size( + (MaterialTheme.appTypography.labelLarge.fontSize.value + 4).dp + ), + painter = painterResource(id = R.drawable.core_ic_check), + contentDescription = null, + tint = MaterialTheme.appColors.accessGreen + ) + Text( + text = stringResource(id = R.string.core_up_to_date), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelLarge + ) + } + } +} + +@Composable +private fun AppVersionItemUpgradeRecommended( + versionName: String, + appUpgradeEvent: AppUpgradeEvent.UpgradeRecommendedEvent, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onClick() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(id = R.string.core_version, versionName), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + Text( + text = stringResource( + id = R.string.core_tap_to_update_to_version, + appUpgradeEvent.newVersionName + ), + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelLarge + ) + } + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(id = R.drawable.core_ic_icon_upgrade), + tint = MaterialTheme.appColors.primary, + contentDescription = null + ) + } +} + +@Composable +fun AppVersionItemUpgradeRequired( + versionName: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onClick() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + modifier = Modifier + .size((MaterialTheme.appTypography.labelLarge.fontSize.value + 8).dp), + painter = painterResource(id = R.drawable.core_ic_warning), + contentDescription = null + ) + Text( + text = stringResource(id = R.string.core_version, versionName), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + } + Text( + text = stringResource(id = R.string.core_tap_to_install_required_app_update), + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelLarge + ) + } + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(id = R.drawable.core_ic_icon_upgrade), + tint = MaterialTheme.appColors.primary, + contentDescription = null + ) + } +} + +@Preview +@Composable +fun AppVersionItemAppToDatePreview() { + OpenEdXTheme { + AppVersionItem( + versionName = mockAppData.versionName, + appUpgradeEvent = null, + onClick = {} + ) + } +} + +@Preview +@Composable +fun AppVersionItemUpgradeRecommendedPreview() { + OpenEdXTheme { + AppVersionItem( + versionName = mockAppData.versionName, + appUpgradeEvent = AppUpgradeEvent.UpgradeRecommendedEvent("1.0.1"), + onClick = {} + ) + } +} + +@Preview +@Composable +fun AppVersionItemUpgradeRequiredPreview() { + OpenEdXTheme { + AppVersionItem( + versionName = mockAppData.versionName, + appUpgradeEvent = AppUpgradeEvent.UpgradeRequiredEvent, + onClick = {} + ) + } +} + +@Preview +@Composable +fun LogoutDialogPreview() { + LogoutDialog({}, {}) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ProfileScreenPreview() { + OpenEdXTheme { + ProfileView( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = ProfileUIState.Data( + account = mockAccount, + versionName = mockAppData.versionName, + ), + uiMessage = null, + refreshing = false, + onAction = {}, + appUpgradeEvent = null, + ) + } +} + + +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Dark", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ProfileScreenTabletPreview() { + OpenEdXTheme { + ProfileView( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = ProfileUIState.Data( + account = mockAccount, + versionName = mockAppData.versionName, + ), + uiMessage = null, + refreshing = false, + onAction = {}, + appUpgradeEvent = null, + ) + } +} + +private val mockAppData = AppData( + versionName = "1.0.0", + feedbackEmailAddress = "support@example.com", + tosUrl = "https://example.com/tos", + privacyPolicyUrl = "https://example.com/privacy", +) + +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 +) + + +internal interface ProfileViewAction { + object AppVersionClick : ProfileViewAction + object EditAccountClick : ProfileViewAction + object LogoutClick : ProfileViewAction + object PrivacyPolicyClick : ProfileViewAction + object TermsClick : ProfileViewAction + object SupportClick : ProfileViewAction + object VideoSettingsClick : ProfileViewAction + object SwipeRefresh : ProfileViewAction +} diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index c7cea7cfb..e997dc037 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -25,11 +25,13 @@ import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.domain.model.ProfileImage import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.system.notifier.AccountUpdated import org.openedx.profile.system.notifier.ProfileNotifier import java.net.UnknownHostException @@ -49,8 +51,16 @@ class ProfileViewModelTest { private val cookieManager = mockk() private val workerController = mockk() private val analytics = mockk() + private val router = mockk() private val appUpgradeNotifier = mockk() + private val appData = AppData( + versionName = "1.0.0", + feedbackEmailAddress = "support@example.com", + tosUrl = "https://example.com/tos", + privacyPolicyUrl = "https://example.com/privacy", + ) + private val account = org.openedx.profile.domain.model.Account( username = "", bio = "", @@ -90,6 +100,7 @@ class ProfileViewModelTest { @Test fun `getAccount no internetConnection and cache is null`() = runTest { val viewModel = ProfileViewModel( + appData, config, interactor, resourceManager, @@ -98,6 +109,7 @@ class ProfileViewModelTest { cookieManager, workerController, analytics, + router, appUpgradeNotifier ) coEvery { interactor.getCachedAccount() } returns null @@ -115,6 +127,7 @@ class ProfileViewModelTest { @Test fun `getAccount no internetConnection and cache is not null`() = runTest { val viewModel = ProfileViewModel( + appData, config, interactor, resourceManager, @@ -123,6 +136,7 @@ class ProfileViewModelTest { cookieManager, workerController, analytics, + router, appUpgradeNotifier ) coEvery { interactor.getCachedAccount() } returns account @@ -140,6 +154,7 @@ class ProfileViewModelTest { @Test fun `getAccount unknown exception`() = runTest { val viewModel = ProfileViewModel( + appData, config, interactor, resourceManager, @@ -148,6 +163,7 @@ class ProfileViewModelTest { cookieManager, workerController, analytics, + router, appUpgradeNotifier ) coEvery { interactor.getCachedAccount() } returns null @@ -165,6 +181,7 @@ class ProfileViewModelTest { @Test fun `getAccount success`() = runTest { val viewModel = ProfileViewModel( + appData, config, interactor, resourceManager, @@ -173,6 +190,7 @@ class ProfileViewModelTest { cookieManager, workerController, analytics, + router, appUpgradeNotifier ) coEvery { interactor.getCachedAccount() } returns null @@ -189,6 +207,7 @@ class ProfileViewModelTest { @Test fun `logout no internet connection`() = runTest { val viewModel = ProfileViewModel( + appData, config, interactor, resourceManager, @@ -197,6 +216,7 @@ class ProfileViewModelTest { cookieManager, workerController, analytics, + router, appUpgradeNotifier ) coEvery { interactor.logout() } throws UnknownHostException() @@ -217,6 +237,7 @@ class ProfileViewModelTest { @Test fun `logout unknown exception`() = runTest { val viewModel = ProfileViewModel( + appData, config, interactor, resourceManager, @@ -225,6 +246,7 @@ class ProfileViewModelTest { cookieManager, workerController, analytics, + router, appUpgradeNotifier ) coEvery { interactor.logout() } throws Exception() @@ -247,6 +269,7 @@ class ProfileViewModelTest { @Test fun `logout success`() = runTest { val viewModel = ProfileViewModel( + appData, config, interactor, resourceManager, @@ -255,6 +278,7 @@ class ProfileViewModelTest { cookieManager, workerController, analytics, + router, appUpgradeNotifier ) coEvery { interactor.getCachedAccount() } returns mockk() @@ -277,6 +301,7 @@ class ProfileViewModelTest { @Test fun `AccountUpdated notifier test`() = runTest { val viewModel = ProfileViewModel( + appData, config, interactor, resourceManager, @@ -285,6 +310,7 @@ class ProfileViewModelTest { cookieManager, workerController, analytics, + router, appUpgradeNotifier ) coEvery { interactor.getCachedAccount() } returns null