Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Infinite Scrolling Optimization and Swipe-to-Refresh for Notifications Inbox Screen #85

Merged
merged 6 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.openedx.core.presentation.global

import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PriorityHigh
import androidx.compose.material.icons.outlined.SignalWifiStatusbarConnectedNoInternet4
import androidx.compose.ui.graphics.vector.ImageVector
import org.openedx.core.R

open class FullScreenState(
val imageVector: ImageVector,
@StringRes val titleResId: Int,
@StringRes val descriptionResId: Int,
@StringRes val actionButtonResId: Int? = null,
) {
companion object {
val NetworkError = FullScreenState(
imageVector = Icons.Outlined.SignalWifiStatusbarConnectedNoInternet4,
titleResId = R.string.core_no_internet_connection,
descriptionResId = R.string.core_no_internet_connection_description,
actionButtonResId = R.string.core_reload
)

val ServerError = FullScreenState(
imageVector = Icons.Outlined.PriorityHigh,
titleResId = R.string.core_server_error,
descriptionResId = R.string.core_server_error_description,
actionButtonResId = R.string.core_reload
)
}
}
85 changes: 82 additions & 3 deletions core/src/main/java/org/openedx/core/ui/ComposeCommon.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
Expand Down Expand Up @@ -102,6 +101,9 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
Expand All @@ -120,6 +122,7 @@ import org.openedx.core.extension.tagId
import org.openedx.core.extension.takeIfNotEmpty
import org.openedx.core.extension.toastMessage
import org.openedx.core.presentation.global.ErrorType
import org.openedx.core.presentation.global.FullScreenState
import org.openedx.core.ui.theme.OpenEdXTheme
import org.openedx.core.ui.theme.appColors
import org.openedx.core.ui.theme.appShapes
Expand Down Expand Up @@ -221,7 +224,6 @@ fun Toolbar(
}
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SearchBar(
modifier: Modifier,
Expand Down Expand Up @@ -321,7 +323,6 @@ fun SearchBar(
)
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SearchBarStateless(
modifier: Modifier,
Expand Down Expand Up @@ -1464,6 +1465,67 @@ private fun RoundTab(
}
}

@Composable
fun FullScreenStateView(
modifier: Modifier = Modifier,
state: FullScreenState,
onAction: () -> Unit = { },
) {
Column(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.appColors.background)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(62.dp)
.background(MaterialTheme.appColors.primaryCardCautionBackground)
.padding(4.dp),
) {
Icon(
modifier = Modifier
.size(42.dp)
.align(Alignment.Center),
imageVector = state.imageVector,
contentDescription = null,
tint = MaterialTheme.appColors.onSurface
)
}
Spacer(modifier = Modifier.height(12.dp))

Text(
text = stringResource(id = state.titleResId),
style = MaterialTheme.appTypography.titleLarge,
color = MaterialTheme.appColors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))

Text(
text = stringResource(id = state.descriptionResId),
style = MaterialTheme.appTypography.bodyLarge,
color = MaterialTheme.appColors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))

state.actionButtonResId?.let {
OpenEdXPrimaryButton(
modifier = Modifier
.widthIn(Dp.Unspecified, 162.dp),
text = stringResource(id = it),
textColor = MaterialTheme.appColors.secondaryButtonText,
backgroundColor = MaterialTheme.appColors.secondaryButtonBackground,
onClick = onAction,
)
}
}
}

@Preview
@Composable
private fun StaticSearchBarPreview() {
Expand Down Expand Up @@ -1612,3 +1674,20 @@ private fun PreviewNoContentScreen() {
)
}
}

private class FullScreenStatePreviewParameterProvider : PreviewParameterProvider<FullScreenState> {
override val values = sequenceOf(
FullScreenState.NetworkError,
FullScreenState.ServerError,
)
}

@PreviewLightDark
@Composable
private fun FullScreenStatePreview(
@PreviewParameter(FullScreenStatePreviewParameterProvider::class) state: FullScreenState,
) {
OpenEdXTheme {
FullScreenStateView(state = state)
}
}
36 changes: 33 additions & 3 deletions core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,39 @@ fun LazyGridState.shouldLoadMore(rememberedIndex: MutableState<Int>, threshold:
return false
}

fun LazyListState.shouldLoadMore(threshold: Int): Boolean {
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return false
return lastVisibleIndex >= layoutInfo.totalItemsCount - 1 - threshold
/**
* Tracks changes to both the first and last visible item indices and accounts for
* edge cases where all items on the current page might fit within the viewport (e.g., due to
* dynamic item sizes).
*
* Triggers a load when:
* - The first or last visible item index changes.
* - All items fit on the screen (e.g., due to dynamic sizes).
*
* @param rememberedFirstIndex Tracks the previous first visible item index.
* @param rememberedLastIndex Tracks the previous last visible item index.
* @param threshold Number of items from the end of the list to trigger loading.
* @return `true` if more items should be loaded; `false` otherwise.
*/
fun LazyListState.shouldLoadMore(
rememberedFirstIndex: MutableState<Int>,
rememberedLastIndex: MutableState<Int>,
threshold: Int
): Boolean {
val firstVisibleIndex = this.firstVisibleItemIndex
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItemsCount = layoutInfo.totalItemsCount

if (
rememberedFirstIndex.value != firstVisibleIndex ||
rememberedLastIndex.value != lastVisibleIndex ||
(firstVisibleIndex == 0 && lastVisibleIndex == totalItemsCount - 1)
) {
rememberedFirstIndex.value = firstVisibleIndex
rememberedLastIndex.value = lastVisibleIndex
return lastVisibleIndex >= totalItemsCount - 1 - threshold
}
return false
}

fun Modifier.statusBarsInset(): Modifier = composed {
Expand Down
2 changes: 2 additions & 0 deletions core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
<string name="core_thank_you_dialog_negative_description">We received your feedback and will use it to help improve your learning experience going forward. Thank you for sharing!</string>
<string name="core_no_internet_connection">No internet connection</string>
<string name="core_no_internet_connection_description">Please connect to the internet to view this content.</string>
<string name="core_server_error">Server error</string>
<string name="core_server_error_description">Something went wrong on our end. Please try again later.</string>
<string name="core_try_again">Try Again</string>
<string name="core_something_went_wrong_description">Something went wrong</string>
<string name="core_ok" tools:ignore="MissingTranslation">OK</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ class LearnViewModel(
private fun checkNotificationCount() {
if (config.isPushNotificationsEnabled()) {
viewModelScope.launch(Dispatchers.IO) {
val unreadNotifications = pushManager.getUnreadNotificationsCount()
_uiState.update { it.copy(hasUnreadNotifications = unreadNotifications > 0) }
try {
val unreadNotifications = pushManager.getUnreadNotificationsCount()
_uiState.update { it.copy(hasUnreadNotifications = unreadNotifications > 0) }
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.openedx.notifications.presentation.inbox

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Notifications
import org.openedx.core.presentation.global.FullScreenState
import org.openedx.notifications.R

object InboxFullScreenState {
val Empty = FullScreenState(
imageVector = Icons.Outlined.Notifications,
titleResId = R.string.notifications_no_notifications_yet,
descriptionResId = R.string.notifications_no_notifications_yet_description
)
val NetworkError = FullScreenState.NetworkError
val ServerError = FullScreenState.ServerError
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.openedx.notifications.presentation.inbox

import org.openedx.core.presentation.global.FullScreenState
import org.openedx.notifications.domain.model.InboxSection
import org.openedx.notifications.domain.model.NotificationItem

Expand All @@ -9,7 +10,9 @@ sealed class InboxUIState {
val notifications: Map<InboxSection, List<NotificationItem>>,
) : InboxUIState()

data object Empty : InboxUIState()
data object Error : InboxUIState()
data object Loading : InboxUIState()

data class Fallback(
val state: FullScreenState,
) : InboxUIState()
}
Loading