diff --git a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt index b30746fe3..43f75dbf2 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -77,14 +77,23 @@ fun LazyListState.shouldLoadMore(rememberedIndex: MutableState, threshold: fun LazyGridState.shouldLoadMore(rememberedIndex: MutableState, threshold: Int): Boolean { val firstVisibleIndex = this.firstVisibleItemIndex + val totalItems = layoutInfo.totalItemsCount + val visibleItems = layoutInfo.visibleItemsInfo.size + + // Extra check: if all items fit on screen, trigger load more + if (totalItems in 1..visibleItems) { + return true + } + if (rememberedIndex.value != firstVisibleIndex) { rememberedIndex.value = firstVisibleIndex val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - return lastVisibleIndex >= layoutInfo.totalItemsCount - 1 - threshold + return lastVisibleIndex >= totalItems - 1 - threshold } return false } + fun Modifier.statusBarsInset(): Modifier = composed { val topInset = (LocalContext.current as? InsetHolder)?.topInset ?: 0 return@composed this diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 9d26e39df..4f5f09080 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxHeight @@ -406,10 +407,10 @@ fun CourseItem( apiHostUrl: String, onClick: (EnrolledCourse) -> Unit, ) { + val courseNameHeight = 60.dp Card( modifier = modifier .width(170.dp) - .height(180.dp) .clickable { onClick(course) }, @@ -429,7 +430,7 @@ fun CourseItem( contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() - .height(90.dp) + .aspectRatio(16f / 9f) ) val progress: Float = try { course.progress.assignmentsCompleted.toFloat() / course.progress.totalAssignmentsCount.toFloat() @@ -467,7 +468,8 @@ fun CourseItem( ) Text( modifier = Modifier - .padding(horizontal = 8.dp, vertical = 4.dp), + .padding(horizontal = 8.dp, vertical = 2.dp) + .height(courseNameHeight), text = course.course.name, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textDark, diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt index 5a00301e6..10a7078b9 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt @@ -23,7 +23,7 @@ class DashboardGalleryFragment : Fragment() { } companion object { - const val TABLET_COURSE_LIST_ITEM_COUNT = 7 - const val MOBILE_COURSE_LIST_ITEM_COUNT = 7 + const val TABLET_COURSE_LIST_ITEM_COUNT = 5 + const val MOBILE_COURSE_LIST_ITEM_COUNT = 3 } } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index 2c44c2c61..e036238d5 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.heightIn 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.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.items @@ -46,10 +48,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale @@ -62,6 +66,7 @@ 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 androidx.lifecycle.Lifecycle @@ -100,6 +105,7 @@ import org.openedx.dashboard.R import org.openedx.foundation.extension.toImageLink import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.util.Date import org.openedx.core.R as CoreR @@ -190,6 +196,17 @@ private fun DashboardGalleryView( mutableStateOf(false) } + val windowSize = rememberWindowSize() + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 700.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + Scaffold( scaffoldState = scaffoldState, modifier = Modifier.fillMaxSize(), @@ -205,11 +222,13 @@ private fun DashboardGalleryView( color = MaterialTheme.appColors.background ) { Box( - Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { Box( - Modifier - .fillMaxSize() + modifier = Modifier + .fillMaxHeight() + .then(contentWidth) .pullRefresh(pullRefreshState) .verticalScroll(rememberScrollState()), ) { @@ -334,8 +353,8 @@ private fun SecondaryCourses( } else { MOBILE_COURSE_LIST_ITEM_COUNT } - val rows = if (windowSize.isTablet) 2 else 1 - val height = if (windowSize.isTablet) 322.dp else 152.dp + val rows = if (windowSize.isTablet) 1 else 1 + val height = if (windowSize.isTablet) 200.dp else 200.dp val items = courses.take(itemsCount) Column( modifier = Modifier @@ -426,8 +445,7 @@ private fun CourseListItem( ) { Card( modifier = Modifier - .width(140.dp) - .height(152.dp) + .width(190.dp) .padding(4.dp) .clickable { onCourseClick(course) @@ -448,17 +466,18 @@ private fun CourseListItem( contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() - .height(90.dp) + .aspectRatio(16f / 9f) + .clip(MaterialTheme.appShapes.courseImageShape) ) Text( modifier = Modifier - .fillMaxHeight() + .fillMaxWidth() .padding(horizontal = 4.dp, vertical = 8.dp), text = course.course.name, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textDark, overflow = TextOverflow.Ellipsis, - maxLines = 2, + maxLines = 3, minLines = 2 ) } @@ -552,10 +571,9 @@ private fun PrimaryCourseCard( .placeholder(CoreR.drawable.core_no_image_course) .build(), contentDescription = null, - contentScale = ContentScale.Crop, + contentScale = ContentScale.FillWidth, modifier = Modifier .fillMaxWidth() - .height(140.dp) ) val progress: Float = try { primaryCourse.progress.assignmentsCompleted.toFloat() / diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt index d27e9978a..6ac3d24cb 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt @@ -16,11 +16,11 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -34,10 +34,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -61,6 +63,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import kotlinx.coroutines.flow.collectLatest import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.domain.model.Media @@ -184,9 +187,9 @@ private fun CourseSearchScreen( onSignInClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() - val scrollState = rememberLazyListState() + val scrollState = rememberLazyGridState() val firstVisibleIndex = remember { - mutableStateOf(scrollState.firstVisibleItemIndex) + mutableIntStateOf(scrollState.firstVisibleItemIndex) } val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) @@ -207,6 +210,16 @@ private fun CourseSearchScreen( focusManager.clearFocus() } + LaunchedEffect(scrollState) { + snapshotFlow { firstVisibleIndex }.collectLatest { index -> + val totalItems = scrollState.layoutInfo.totalItemsCount + val lastVisibleItem = scrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + if (lastVisibleItem >= totalItems - LOAD_MORE_THRESHOLD) { + paginationCallback() + } + } + } + Scaffold( scaffoldState = scaffoldState, modifier = Modifier @@ -262,6 +275,12 @@ private fun CourseSearchScreen( ) } + val columns = if (windowSize.width == WindowType.Compact) { + GridCells.Fixed(2) // phone + } else { + GridCells.Fixed(3) // tablet / larger + } + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) Box( @@ -340,12 +359,15 @@ private fun CourseSearchScreen( (state as? CourseSearchUIState.Courses)?.numCourses ?: 0 ) } - LazyColumn( - Modifier.fillMaxSize(), - contentPadding = contentPaddings, - state = scrollState + LazyVerticalGrid( + columns = columns, + modifier = Modifier.fillMaxSize(), + state = scrollState, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = contentPaddings ) { - item { + item(span = { GridItemSpan(maxLineSpan) }) { Column { Text( modifier = Modifier.testTag("txt_search_results_title"), @@ -366,11 +388,9 @@ private fun CourseSearchScreen( } when (state) { is CourseSearchUIState.Loading -> { - item { + item(span = { GridItemSpan(maxLineSpan) }) { Box( - Modifier - .fillMaxSize() - .padding(vertical = 25.dp), + Modifier.fillMaxWidth().padding(vertical = 25.dp), contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) @@ -379,7 +399,11 @@ private fun CourseSearchScreen( } is CourseSearchUIState.Courses -> { - items(state.courses) { course -> + items( + count = state.courses.size, + key = { index -> "${state.courses[index].courseId}_$index" } + ) { index -> + val course = state.courses[index] DiscoveryCourseItem( apiHostUrl = apiHostUrl, course, @@ -388,10 +412,9 @@ private fun CourseSearchScreen( onItemClick(courseId) } ) - Divider() } - item { - if (canLoadMore) { + if (canLoadMore) { + item(span = { GridItemSpan(maxLineSpan) }) { Box( modifier = Modifier .fillMaxWidth() diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt index 4a231618a..bf9eaac24 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -24,12 +25,12 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape 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 @@ -45,7 +46,6 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -62,6 +62,7 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource @@ -163,25 +164,28 @@ fun DiscoveryCourseItem( val adjustedCourseTitle = course.name + "\n" val durationText = if (course.duration.isBlank()) { - stringResource(id = R.string.course_duration_unspecified) + stringResource(id = R.string.discovery_course_duration_unspecified) } else { - stringResource(id = R.string.course_duration_specified, course.duration) + stringResource(id = R.string.discovery_course_duration_specified, course.duration) } + // Height for course title + val lineHeightSp = MaterialTheme.appTypography.titleSmall.lineHeight + val lineHeight = with(LocalDensity.current) { lineHeightSp.toDp() + 5.dp } + Surface( modifier = Modifier .testTag("btn_course_card") .fillMaxWidth() - .height(140.dp) - .clickable { onClick(course.courseId) } - .background(MaterialTheme.appColors.background), + .clickable { onClick(course.courseId) }, + shape = MaterialTheme.appShapes.cardShape, + elevation = 4.dp, + color = MaterialTheme.appColors.surface ) { - Row( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.appColors.background), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) @@ -192,39 +196,41 @@ fun DiscoveryCourseItem( contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier - .width(imageWidth) - .height(105.dp) + .fillMaxWidth() + .aspectRatio(16f / 9f) // image scales with width .clip(MaterialTheme.appShapes.courseImageShape) ) Column( modifier = Modifier .fillMaxWidth() - .height(105.dp), + .padding(10.dp) ) { Text( - modifier = Modifier - .testTag("txt_course_org") - .padding(top = 12.dp), + modifier = Modifier.testTag("txt_course_org"), text = course.org, color = MaterialTheme.appColors.textFieldHint, - style = MaterialTheme.appTypography.labelMedium + style = MaterialTheme.appTypography.labelMedium, + maxLines = 1, + softWrap = true, + overflow = TextOverflow.Ellipsis ) Text( modifier = Modifier .testTag("txt_course_title") .fillMaxWidth() - .padding(top = 8.dp), + .padding(top = 4.dp) + .height(lineHeight * 3), text = adjustedCourseTitle, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleSmall, - maxLines = 2, + maxLines = 3, softWrap = true, overflow = TextOverflow.Ellipsis ) Text( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp) + .padding(top = 4.dp) .testTag("txt_course_duration"), text = durationText, color = MaterialTheme.appColors.textFieldHint, @@ -315,7 +321,7 @@ internal fun DiscoveryScreen( selectedOrg: Organization?, onOrgSelected: (Organization) -> Unit, onClearOrgClick: () -> Unit, - ) { +) { val coroutineScope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, @@ -323,7 +329,7 @@ internal fun DiscoveryScreen( ) val scaffoldState = rememberScaffoldState() - val scrollState = rememberLazyListState() + val scrollState = rememberLazyGridState() val firstVisibleIndex = remember { mutableIntStateOf(scrollState.firstVisibleItemIndex) } @@ -411,6 +417,12 @@ internal fun DiscoveryScreen( ) } + val columns = if (windowSize.width == WindowType.Compact) { + GridCells.Fixed(2) // phone + } else { + GridCells.Fixed(3) // tablet / larger + } + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) if (canShowBackButton) { @@ -572,14 +584,17 @@ internal fun DiscoveryScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - LazyColumn( - Modifier + LazyVerticalGrid( + columns = columns, + modifier = Modifier .fillMaxHeight() .then(contentWidth), contentPadding = contentPaddings, - state = scrollState + state = scrollState, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - item { + item(span = { GridItemSpan(maxLineSpan) }) { Column { if (selectedOrg != null) { Text( @@ -610,18 +625,19 @@ internal fun DiscoveryScreen( Spacer(modifier = Modifier.height(14.dp)) } } - items(state.courses) { course -> + items( + count = state.courses.size, + key = { index -> "${state.courses[index].courseId}_$index" } + ) { index -> + val course = state.courses[index] DiscoveryCourseItem( apiHostUrl = apiHostUrl, course = course, windowSize = windowSize, - onClick = { - onItemClick(course) - } + onClick = { onItemClick(course) } ) - Divider() } - item { + item(span = { GridItemSpan(maxLineSpan) }) { if (canLoadMore) { Box( modifier = Modifier diff --git a/discovery/src/main/res/values/strings.xml b/discovery/src/main/res/values/strings.xml index 18e0853b1..ab45da1d7 100644 --- a/discovery/src/main/res/values/strings.xml +++ b/discovery/src/main/res/values/strings.xml @@ -18,6 +18,8 @@ Schools and Partners Apply Clear + %1$s + Duration not specified Course Duration: %1$s Course Duration: Not specified