diff --git a/core/src/main/java/org/openedx/core/NoContentScreenType.kt b/core/src/main/java/org/openedx/core/NoContentScreenType.kt new file mode 100644 index 000000000..88e8ad94b --- /dev/null +++ b/core/src/main/java/org/openedx/core/NoContentScreenType.kt @@ -0,0 +1,31 @@ +package org.openedx.core + +enum class NoContentScreenType( + val iconResId: Int, + val messageResId: Int, +) { + COURSE_OUTLINE( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_course_content + ), + COURSE_VIDEOS( + iconResId = R.drawable.core_ic_no_videos, + messageResId = R.string.core_no_videos + ), + COURSE_DATES( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_dates + ), + COURSE_DISCUSSIONS( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_discussion + ), + COURSE_HANDOUTS( + iconResId = R.drawable.core_ic_no_handouts, + messageResId = R.string.core_no_handouts + ), + COURSE_ANNOUNCEMENTS( + iconResId = R.drawable.core_ic_no_announcements, + messageResId = R.string.core_no_announcements + ) +} diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index d50b05cbe..3c4578d58 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -31,12 +31,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -49,6 +52,7 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable @@ -97,11 +101,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import coil.ImageLoader import coil.compose.AsyncImage import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import kotlinx.coroutines.launch +import org.openedx.core.NoContentScreenType import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.domain.model.RegistrationField @@ -1185,6 +1191,41 @@ fun FullScreenErrorView( } } +@Composable +fun NoContentScreen(noContentScreenType: NoContentScreenType) { + NoContentScreen( + message = stringResource(id = noContentScreenType.messageResId), + icon = painterResource(id = noContentScreenType.iconResId) + ) +} + +@Composable +fun NoContentScreen(message: String, icon: Painter) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(80.dp), + painter = icon, + contentDescription = null, + tint = MaterialTheme.appColors.progressBarBackgroundColor, + ) + Spacer(Modifier.height(24.dp)) + Text( + modifier = Modifier.fillMaxWidth(0.8f), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } +} + @Composable fun AuthButtonsPanel( onRegisterClick: () -> Unit, @@ -1280,6 +1321,19 @@ fun RoundTabsBar( } } +@Composable +fun CircularProgress() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.appColors.background) + .zIndex(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } +} + @Composable private fun RoundTab( modifier: Modifier = Modifier, @@ -1400,3 +1454,14 @@ private fun RoundTabsBarPreview() { ) } } + +@Preview +@Composable +private fun PreviewNoContentScreen() { + OpenEdXTheme(darkTheme = true) { + NoContentScreen( + "No Content available", + rememberVectorPainter(image = Icons.Filled.Info) + ) + } +} diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index 06aa70ea2..2fe762b26 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -6,7 +6,6 @@ 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 @@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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 @@ -101,15 +99,7 @@ fun WebContentScreen( 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) - } + CircularProgress() } else { var webViewAlpha by rememberSaveable { mutableFloatStateOf(0f) } Surface( diff --git a/core/src/main/res/drawable/core_ic_no_announcements.xml b/core/src/main/res/drawable/core_ic_no_announcements.xml new file mode 100644 index 000000000..fc85b3fe1 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_announcements.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/drawable/core_ic_no_content.xml b/core/src/main/res/drawable/core_ic_no_content.xml new file mode 100644 index 000000000..94a134d7e --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_content.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/drawable/core_ic_no_handouts.xml b/core/src/main/res/drawable/core_ic_no_handouts.xml new file mode 100644 index 000000000..d1f19a3d3 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_handouts.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/drawable/core_ic_no_videos.xml b/core/src/main/res/drawable/core_ic_no_videos.xml new file mode 100644 index 000000000..f8a55d1b9 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_no_videos.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index b023e8845..c8d529afa 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -169,6 +169,12 @@ Discussions More Dates + No course content is currently available. + There are currently no videos for this course. + Course dates are currently not available. + Unable to load discussions.\n Please try again later. + There are currently no handouts for this course. + There are currently no announcements for this course. Confirm Download Edit Offline Progress Sync diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index d76eb5eab..e15d3f7d4 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -56,13 +56,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -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.fragment.app.FragmentManager +import org.openedx.core.NoContentScreenType import org.openedx.core.UIMessage import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock @@ -74,7 +74,10 @@ import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState +import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState +import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -336,22 +339,13 @@ private fun CourseDatesUI( } } - CourseDatesUIState.Empty -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.course_dates_unavailable_message), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - textAlign = TextAlign.Center - ) - } + CourseDatesUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_DATES) } - CourseDatesUIState.Loading -> {} + CourseDatesUIState.Loading -> { + CircularProgress() + } } } } @@ -676,6 +670,26 @@ private fun CourseDateItem( } } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EmptyCourseDatesScreenPreview() { + OpenEdXTheme { + CourseDatesUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseDatesUIState.Error, + uiMessage = null, + isSelfPaced = true, + useRelativeDates = true, + onItemClick = {}, + onPLSBannerViewed = {}, + onSyncDates = {}, + onCalendarSyncStateClick = {}, + ) + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt index 5623129d0..17f6e3b46 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesUIState.kt @@ -9,6 +9,6 @@ sealed interface CourseDatesUIState { val calendarSyncState: CalendarSyncState, ) : CourseDatesUIState - data object Empty : CourseDatesUIState + data object Error : CourseDatesUIState data object Loading : CourseDatesUIState } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index 48fd0a524..54406019d 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -101,7 +101,7 @@ class CourseDatesViewModel( isSelfPaced = courseStructure?.isSelfPaced ?: false val datesResponse = interactor.getCourseDates(courseId = courseId) if (datesResponse.datesSection.isEmpty()) { - _uiState.value = CourseDatesUIState.Empty + _uiState.value = CourseDatesUIState.Error } else { val courseDates = datesResponse.datesSection.values.flatten() val calendarState = getCalendarState(courseDates) @@ -110,10 +110,9 @@ class CourseDatesViewModel( checkIfCalendarOutOfDate() } } catch (e: Exception) { + _uiState.value = CourseDatesUIState.Error if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection))) - } else { - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error))) } } finally { courseNotifier.send(CourseLoading(false)) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt index 18aebac3f..6dbb71fb2 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt @@ -3,12 +3,11 @@ package org.openedx.course.presentation.dates import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState -sealed interface DatesUIState { +sealed class DatesUIState { data class Dates( val courseDatesResult: CourseDatesResult, - val calendarSyncState: CalendarSyncState - ) : DatesUIState - - data object Empty : DatesUIState - data object Loading : DatesUIState + val calendarSyncState: CalendarSyncState, + ) : DatesUIState() + data object Error : DatesUIState() + data object Loading : DatesUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsUIState.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsUIState.kt new file mode 100644 index 000000000..860e4261f --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsUIState.kt @@ -0,0 +1,7 @@ +package org.openedx.course.presentation.handouts + +sealed class HandoutsUIState { + data object Loading : HandoutsUIState() + data class HTMLContent(val htmlContent: String) : HandoutsUIState() + data object Error : HandoutsUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt index 92aaa139d..424f71f81 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt @@ -1,8 +1,9 @@ package org.openedx.course.presentation.handouts -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.config.Config @@ -23,26 +24,40 @@ class HandoutsViewModel( val apiHostUrl get() = config.getApiHostURL() - private val _htmlContent = MutableLiveData() - val htmlContent: LiveData - get() = _htmlContent + private val _uiState = MutableStateFlow(HandoutsUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() init { - getEnrolledCourse() + getCourseHandouts() } - private fun getEnrolledCourse() { + private fun getCourseHandouts() { viewModelScope.launch { + var emptyState = false try { if (HandoutsType.valueOf(handoutsType) == HandoutsType.Handouts) { val handouts = interactor.getHandouts(courseId) - _htmlContent.value = handoutsToHtml(handouts) + if (handouts.handoutsHtml.isNotBlank()) { + _uiState.value = HandoutsUIState.HTMLContent(handoutsToHtml(handouts)) + } else { + emptyState = true + } } else { val announcements = interactor.getAnnouncements(courseId) - _htmlContent.value = announcementsToHtml(announcements) + if (announcements.isNotEmpty()) { + _uiState.value = + HandoutsUIState.HTMLContent(announcementsToHtml(announcements)) + } else { + emptyState = true + } } } catch (e: Exception) { //ignore e.printStackTrace() + emptyState = true + } + if (emptyState) { + _uiState.value = HandoutsUIState.Error } } } 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 index 16cc67b84..dbcbde30a 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsWebViewFragment.kt @@ -4,24 +4,49 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId 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.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.NoContentScreenType +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.NoContentScreen +import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WebContentScreen import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape 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.windowSizeValue import org.openedx.course.R import org.openedx.course.presentation.CourseAnalyticsEvent @@ -51,31 +76,32 @@ class HandoutsWebViewFragment : Fragment() { 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, + val uiState by viewModel.uiState.collectAsState() + HandoutsScreens( + handoutType = HandoutsType.valueOf(viewModel.handoutsType), + uiState = uiState, title = title, - htmlBody = viewModel.injectDarkMode( - htmlBody, - colorBackgroundValue, - colorTextValue - ), + apiHostUrl = viewModel.apiHostUrl, + onInjectDarkMode = { + viewModel.injectDarkMode( + (uiState as HandoutsUIState.HTMLContent).htmlContent, + colorBackgroundValue, + colorTextValue + ) + }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() - }) + } + ) } } } companion object { - private val ARG_TYPE = "argType" - private val ARG_COURSE_ID = "argCourse" + private const val ARG_TYPE = "argType" + private const val ARG_COURSE_ID = "argCourse" fun newInstance( type: String, @@ -91,24 +117,163 @@ class HandoutsWebViewFragment : Fragment() { } } +@Composable +fun HandoutsScreens( + handoutType: HandoutsType, + uiState: HandoutsUIState, + title: String, + apiHostUrl: String, + onInjectDarkMode: () -> String, + onBackClick: () -> Unit +) { + val windowSize = rememberWindowSize() + when (uiState) { + is HandoutsUIState.Loading -> { + CircularProgress() + } + + is HandoutsUIState.HTMLContent -> { + WebContentScreen( + windowSize = windowSize, + apiHostUrl = apiHostUrl, + title = title, + htmlBody = onInjectDarkMode(), + onBackClick = onBackClick + ) + } + + HandoutsUIState.Error -> { + HandoutsEmptyScreen( + windowSize = windowSize, + handoutType = handoutType, + title = title, + onBackClick = onBackClick + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun HandoutsEmptyScreen( + windowSize: WindowSize, + handoutType: HandoutsType, + title: String, + onBackClick: () -> Unit +) { + val handoutScreenType = + if (handoutType == HandoutsType.Handouts) NoContentScreenType.COURSE_HANDOUTS + else NoContentScreenType.COURSE_ANNOUNCEMENTS + + val scaffoldState = rememberScaffoldState() + Scaffold( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 24.dp) + .semantics { + testTagsAsResourceId = true + }, + 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 + ) { + Toolbar( + label = title, + canShowBackBtn = true, + onBackClick = onBackClick + ) + } + Surface( + Modifier.fillMaxSize(), + color = MaterialTheme.appColors.background + ) { + NoContentScreen(noContentScreenType = handoutScreenType) + } + } + } + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -fun WebContentScreenPreview() { - WebContentScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), +fun HandoutsScreensPreview() { + HandoutsScreens( + handoutType = HandoutsType.Handouts, + uiState = HandoutsUIState.HTMLContent(htmlContent = ""), + title = "Handouts", apiHostUrl = "http://localhost:8000", - title = "Handouts", onBackClick = { }, htmlBody = "" + onInjectDarkMode = { "" }, + onBackClick = { } ) } @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), +fun HandoutsScreensTabletPreview() { + HandoutsScreens( + handoutType = HandoutsType.Handouts, + uiState = HandoutsUIState.HTMLContent(htmlContent = ""), + title = "Handouts", apiHostUrl = "http://localhost:8000", - title = "Handouts", onBackClick = { }, htmlBody = "" + onInjectDarkMode = { "" }, + onBackClick = { } ) } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun EmptyHandoutsScreensPreview() { + OpenEdXTheme(darkTheme = true) { + HandoutsScreens( + handoutType = HandoutsType.Handouts, + uiState = HandoutsUIState.Error, + title = "Handouts", + apiHostUrl = "http://localhost:8000", + onInjectDarkMode = { "" }, + onBackClick = { } + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun EmptyAnnouncementsScreensPreview() { + OpenEdXTheme(darkTheme = true) { + HandoutsScreens( + handoutType = HandoutsType.Announcements, + uiState = HandoutsUIState.Error, + title = "Handouts", + apiHostUrl = "http://localhost:8000", + onInjectDarkMode = { "" }, + onBackClick = { } + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 10ad4f932..90d74e7f5 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType +import org.openedx.core.NoContentScreenType import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block @@ -56,7 +57,9 @@ import org.openedx.core.domain.model.OfflineDownload import org.openedx.core.domain.model.Progress import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.TextIcon import org.openedx.core.ui.WindowSize @@ -222,116 +225,130 @@ private fun CourseOutlineUI( Box { when (uiState) { is CourseOutlineUIState.CourseData -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listBottomPadding - ) { - if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { - item { - Box( - modifier = Modifier - .padding(all = 8.dp) - ) { - if (windowSize.isTablet) { - CourseDatesBannerTablet( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } else { - CourseDatesBanner( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) + if (uiState.courseStructure.blockData.isEmpty()) { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listBottomPadding + ) { + if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { + item { + Box( + modifier = Modifier + .padding(all = 8.dp) + ) { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } else { + CourseDatesBanner( + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } } } } - } - val certificate = uiState.courseStructure.certificate - if (certificate?.isCertificateEarned() == true) { - item { - CourseMessage( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp) - .then(listPadding), - icon = painterResource(R.drawable.ic_course_certificate), - message = stringResource( - R.string.course_you_earned_certificate, - uiState.courseStructure.name - ), - action = stringResource(R.string.course_view_certificate), - onActionClick = { - onCertificateClick(certificate.certificateURL ?: "") - } - ) + val certificate = uiState.courseStructure.certificate + if (certificate?.isCertificateEarned() == true) { + item { + CourseMessage( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .then(listPadding), + icon = painterResource(R.drawable.ic_course_certificate), + message = stringResource( + R.string.course_you_earned_certificate, + uiState.courseStructure.name + ), + action = stringResource(R.string.course_view_certificate), + onActionClick = { + onCertificateClick( + certificate.certificateURL ?: "" + ) + } + ) + } } - } - val progress = uiState.courseStructure.progress - if (progress != null && progress.totalAssignmentsCount > 0) { - item { - CourseProgress( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp, start = 24.dp, end = 24.dp), - progress = progress - ) + val progress = uiState.courseStructure.progress + if (progress != null && progress.totalAssignmentsCount > 0) { + item { + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding( + top = 16.dp, + start = 24.dp, + end = 24.dp + ), + progress = progress + ) + } } - } - if (uiState.resumeComponent != null) { - item { - Box(listPadding) { - if (windowSize.isTablet) { - ResumeCourseTablet( - modifier = Modifier.padding(vertical = 16.dp), - block = uiState.resumeComponent, - displayName = uiState.resumeUnitTitle, - onResumeClick = onResumeClick - ) - } else { - ResumeCourse( - modifier = Modifier.padding(vertical = 16.dp), - block = uiState.resumeComponent, - displayName = uiState.resumeUnitTitle, - onResumeClick = onResumeClick - ) + if (uiState.resumeComponent != null) { + item { + Box(listPadding) { + if (windowSize.isTablet) { + ResumeCourseTablet( + modifier = Modifier.padding(vertical = 16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) + } else { + ResumeCourse( + modifier = Modifier.padding(vertical = 16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) + } } } } - } - - item { - Spacer(modifier = Modifier.height(12.dp)) - } - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] item { - CourseSection( - modifier = listPadding.padding(vertical = 4.dp), - block = section, - onItemClick = onExpandClick, - useRelativeDates = uiState.useRelativeDates, - courseSectionsState = courseSectionsState, - courseSubSections = courseSubSections, - downloadedStateMap = uiState.downloadedState, - onSubSectionClick = onSubSectionClick, - onDownloadClick = onDownloadClick - ) + Spacer(modifier = Modifier.height(12.dp)) + } + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] + + item { + CourseSection( + modifier = listPadding.padding(vertical = 4.dp), + block = section, + onItemClick = onExpandClick, + useRelativeDates = uiState.useRelativeDates, + courseSectionsState = courseSectionsState, + courseSubSections = courseSubSections, + downloadedStateMap = uiState.downloadedState, + onSubSectionClick = onSubSectionClick, + onDownloadClick = onDownloadClick + ) + } } } } } - CourseOutlineUIState.Error -> {} + CourseOutlineUIState.Error -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + } - CourseOutlineUIState.Loading -> {} + CourseOutlineUIState.Loading -> { + CircularProgress() + } } } } diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index 73afb3d0b..5fd4ea981 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -18,8 +18,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider @@ -48,7 +46,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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 @@ -56,6 +53,7 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.AppDataConstants import org.openedx.core.BlockType +import org.openedx.core.NoContentScreenType import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block @@ -68,7 +66,9 @@ import org.openedx.core.extension.toFileSize import org.openedx.core.module.download.DownloadModelsSize import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.settings.video.VideoQualityType +import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -241,20 +241,7 @@ private fun CourseVideosUI( ) { when (uiState) { is CourseVideosUIState.Empty -> { - Box( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(id = R.string.course_does_not_include_videos), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.headlineSmall, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 40.dp) - ) - } + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_VIDEOS) } is CourseVideosUIState.CourseData -> { @@ -309,7 +296,9 @@ private fun CourseVideosUI( } } - CourseVideosUIState.Loading -> {} + CourseVideosUIState.Loading -> { + CircularProgress() + } } } } @@ -656,9 +645,7 @@ private fun CourseVideosScreenEmptyPreview() { CourseVideosUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, - uiState = CourseVideosUIState.Empty( - "This course does not include any videos." - ), + uiState = CourseVideosUIState.Empty, courseTitle = "", onExpandClick = { }, onSubSectionClick = { }, diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index e5bbffe05..a02eac54c 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -154,9 +154,7 @@ class CourseVideoViewModel( var courseStructure = interactor.getCourseStructureForVideos(courseId) val blocks = courseStructure.blockData if (blocks.isEmpty()) { - _uiState.value = CourseVideosUIState.Empty( - message = resourceManager.getString(R.string.course_does_not_include_videos) - ) + _uiState.value = CourseVideosUIState.Empty } else { setBlocks(courseStructure.blockData) courseSubSections.clear() @@ -180,9 +178,7 @@ class CourseVideoViewModel( } courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { - _uiState.value = CourseVideosUIState.Empty( - message = resourceManager.getString(R.string.course_does_not_include_videos) - ) + _uiState.value = CourseVideosUIState.Empty } } } diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt index 44f485c98..245fb2380 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt @@ -16,6 +16,6 @@ sealed class CourseVideosUIState { val useRelativeDates: Boolean ) : CourseVideosUIState() - data class Empty(val message: String) : CourseVideosUIState() - object Loading : CourseVideosUIState() + data object Empty : CourseVideosUIState() + data object Loading : CourseVideosUIState() } diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 8be55b9d4..c0b03e756 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -12,7 +12,6 @@ Next Next Unit Finish - This course does not include any videos. Last unit: Resume Discussion diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index ed4e28f58..389196f31 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -180,7 +180,7 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is CourseDatesUIState.Loading) + assert(viewModel.uiState.value is CourseDatesUIState.Error) } @Test @@ -209,8 +209,8 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } - Assert.assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is CourseDatesUIState.Loading) + assert(message.await()?.message.isNullOrEmpty()) + assert(viewModel.uiState.value is CourseDatesUIState.Error) } @Test @@ -273,6 +273,6 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } assert(message.await()?.message.isNullOrEmpty()) - assert(viewModel.uiState.value is CourseDatesUIState.Empty) + assert(viewModel.uiState.value is CourseDatesUIState.Error) } } diff --git a/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt index 6e8d2dab2..41074294a 100644 --- a/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt @@ -5,22 +5,24 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.config.Config -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.AnnouncementModel +import org.openedx.core.domain.model.HandoutsModel import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import java.net.UnknownHostException -import java.util.* @OptIn(ExperimentalCoroutinesApi::class) class HandoutsViewModelTest { @@ -57,7 +59,7 @@ class HandoutsViewModelTest { coEvery { interactor.getHandouts(any()) } throws UnknownHostException() advanceUntilIdle() - assert(viewModel.htmlContent.value == null) + assert(viewModel.uiState.value == HandoutsUIState.Error) } @Test @@ -66,7 +68,7 @@ class HandoutsViewModelTest { coEvery { interactor.getHandouts(any()) } throws Exception() advanceUntilIdle() - assert(viewModel.htmlContent.value == null) + assert(viewModel.uiState.value == HandoutsUIState.Error) } @Test @@ -79,7 +81,7 @@ class HandoutsViewModelTest { coVerify(exactly = 1) { interactor.getHandouts(any()) } coVerify(exactly = 0) { interactor.getAnnouncements(any()) } - assert(viewModel.htmlContent.value != null) + assert(viewModel.uiState.value is HandoutsUIState.HTMLContent) } @Test @@ -97,7 +99,7 @@ class HandoutsViewModelTest { coVerify(exactly = 0) { interactor.getHandouts(any()) } coVerify(exactly = 1) { interactor.getAnnouncements(any()) } - assert(viewModel.htmlContent.value != null) + assert(viewModel.uiState.value is HandoutsUIState.HTMLContent) } @Test @@ -111,7 +113,7 @@ class HandoutsViewModelTest { ) ) viewModel.injectDarkMode( - viewModel.htmlContent.value.toString(), + viewModel.uiState.value.toString(), ULong.MAX_VALUE, ULong.MAX_VALUE ) @@ -119,6 +121,6 @@ class HandoutsViewModelTest { coVerify(exactly = 0) { interactor.getHandouts(any()) } coVerify(exactly = 1) { interactor.getAnnouncements(any()) } - assert(viewModel.htmlContent.value != null) + assert(viewModel.uiState.value is HandoutsUIState.HTMLContent) } } diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index 562bca77b..812962c83 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -193,7 +193,6 @@ class CourseVideoViewModelTest { @Before fun setUp() { - every { resourceManager.getString(R.string.course_does_not_include_videos) } returns "" every { resourceManager.getString(R.string.course_can_download_only_with_wifi) } returns cantDownload Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt index 62ec564b6..990e14260 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt @@ -37,10 +37,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager -import org.koin.androidx.compose.koinViewModel import org.openedx.core.FragmentViewType +import org.openedx.core.NoContentScreenType import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.StaticSearchBar import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType @@ -51,10 +52,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.ui.windowSizeValue +import org.openedx.discussion.R import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.ui.ThreadItemCategory import org.openedx.discussion.presentation.ui.TopicItem -import org.openedx.discussion.R as discussionR @Composable fun DiscussionTopicsScreen( @@ -157,15 +158,17 @@ private fun DiscussionTopicsUI( contentAlignment = Alignment.TopCenter ) { Column(screenWidth) { - StaticSearchBar( - modifier = Modifier - .height(48.dp) - .then(searchTabWidth) - .padding(horizontal = contentPaddings) - .fillMaxWidth(), - text = stringResource(id = discussionR.string.discussion_search_all_posts), - onClick = onSearchClick - ) + if ((uiState is DiscussionTopicsUIState.Error).not()) { + StaticSearchBar( + modifier = Modifier + .height(48.dp) + .then(searchTabWidth) + .padding(horizontal = contentPaddings) + .fillMaxWidth(), + text = stringResource(id = R.string.discussion_search_all_posts), + onClick = onSearchClick + ) + } Surface( modifier = Modifier.padding(top = 10.dp), color = MaterialTheme.appColors.background, @@ -188,7 +191,7 @@ private fun DiscussionTopicsUI( item { Text( modifier = Modifier, - text = stringResource(id = discussionR.string.discussion_main_categories), + text = stringResource(id = R.string.discussion_main_categories), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textPrimaryVariant ) @@ -199,8 +202,8 @@ private fun DiscussionTopicsUI( horizontalArrangement = Arrangement.spacedBy(14.dp) ) { ThreadItemCategory( - name = stringResource(id = discussionR.string.discussion_all_posts), - painterResource = painterResource(id = discussionR.drawable.discussion_all_posts), + name = stringResource(id = R.string.discussion_all_posts), + painterResource = painterResource(id = R.drawable.discussion_all_posts), modifier = Modifier .weight(1f) .height(categoriesHeight), @@ -208,12 +211,12 @@ private fun DiscussionTopicsUI( onItemClick( DiscussionTopicsViewModel.ALL_POSTS, "", - context.getString(discussionR.string.discussion_all_posts) + context.getString(R.string.discussion_all_posts) ) }) ThreadItemCategory( - name = stringResource(id = discussionR.string.discussion_posts_following), - painterResource = painterResource(id = discussionR.drawable.discussion_star), + name = stringResource(id = R.string.discussion_posts_following), + painterResource = painterResource(id = R.drawable.discussion_star), modifier = Modifier .weight(1f) .height(categoriesHeight), @@ -221,7 +224,7 @@ private fun DiscussionTopicsUI( onItemClick( DiscussionTopicsViewModel.FOLLOWING_POSTS, "", - context.getString(discussionR.string.discussion_posts_following) + context.getString(R.string.discussion_posts_following) ) }) } @@ -253,6 +256,9 @@ private fun DiscussionTopicsUI( } DiscussionTopicsUIState.Loading -> {} + else -> { + NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_DISCUSSIONS) + } } } } @@ -279,6 +285,23 @@ private fun DiscussionTopicsScreenPreview() { } } +@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 ErrorDiscussionTopicsScreenPreview() { + OpenEdXTheme { + DiscussionTopicsUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = DiscussionTopicsUIState.Error, + uiMessage = null, + onItemClick = { _, _, _ -> }, + onSearchClick = {} + ) + } +} + @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 diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt index c57f55e9b..f1becc420 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsUIState.kt @@ -5,5 +5,6 @@ import org.openedx.discussion.domain.model.Topic sealed class DiscussionTopicsUIState { data class Topics(val data: List) : DiscussionTopicsUIState() - object Loading : DiscussionTopicsUIState() -} \ No newline at end of file + data object Loading : DiscussionTopicsUIState() + data object Error : DiscussionTopicsUIState() +} diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index 456eb79c2..516ee50f8 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -47,12 +47,15 @@ class DiscussionTopicsViewModel( viewModelScope.launch { try { val response = interactor.getCourseTopics(courseId) - _uiState.value = DiscussionTopicsUIState.Topics(response) + if (response.isEmpty().not()) { + _uiState.value = DiscussionTopicsUIState.Topics(response) + } else { + _uiState.value = DiscussionTopicsUIState.Error + } } catch (e: Exception) { + _uiState.value = DiscussionTopicsUIState.Error if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) - } else { - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } } finally { courseNotifier.send(CourseLoading(false)) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index 29a38a6a9..96e3c49f4 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -23,20 +23,16 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule -import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.UIMessage -import org.openedx.core.domain.model.AssignmentProgress -import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import java.net.UnknownHostException -import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class DiscussionTopicsViewModelTest { @@ -53,79 +49,18 @@ class DiscussionTopicsViewModelTest { private val courseNotifier = mockk() private val noInternet = "Slow or no internet connection" - private val somethingWrong = "Something went wrong" - private val assignmentProgress = AssignmentProgress( - assignmentType = "Homework", - numPointsEarned = 1f, - numPointsPossible = 3f - ) - - private val blocks = listOf( - Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = listOf("1", "id1"), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - Block( - id = "id1", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.HTML, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = listOf("id2"), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - Block( - id = "id2", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.HTML, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = emptyList(), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ) + private val mockTopic = Topic( + id = "", + name = "All Topics", + threadListUrl = "", + children = emptyList() ) @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 every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) coEvery { courseNotifier.send(any()) } returns Unit } @@ -166,14 +101,15 @@ class DiscussionTopicsViewModelTest { coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - assertEquals(somethingWrong, message.await()?.message) + assert(message.await()?.message.isNullOrEmpty()) + assert(viewModel.uiState.value is DiscussionTopicsUIState.Error) } @Test fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) - coEvery { interactor.getCourseTopics(any()) } returns mockk() + coEvery { interactor.getCourseTopics(any()) } returns listOf(mockTopic, mockTopic) advanceUntilIdle() val message = async { withTimeoutOrNull(5000) { @@ -217,14 +153,15 @@ class DiscussionTopicsViewModelTest { coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - assertEquals(somethingWrong, message.await()?.message) + assert(message.await()?.message.isNullOrEmpty()) + assert(viewModel.uiState.value is DiscussionTopicsUIState.Error) } @Test fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) - coEvery { interactor.getCourseTopics(any()) } returns mockk() + coEvery { interactor.getCourseTopics(any()) } returns listOf(mockTopic, mockTopic) val message = async { withTimeoutOrNull(5000) { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage