Skip to content

Commit

Permalink
feat: Mark Notifications as Seen and Read (#79)
Browse files Browse the repository at this point in the history
Fixes: LEARNER-10290
  • Loading branch information
HamzaIsrar12 authored Jan 8, 2025
1 parent f589e54 commit dfc37d6
Show file tree
Hide file tree
Showing 11 changed files with 169 additions and 13 deletions.
2 changes: 1 addition & 1 deletion app/src/main/java/org/openedx/app/di/ScreenModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ val screenModule = module {
single { NotificationsRepository(get()) }
factory { NotificationsInteractor(get()) }

viewModel { NotificationsInboxViewModel(get()) }
viewModel { NotificationsInboxViewModel(get(), get()) }

single { IAPRepository(get()) }
factory { IAPInteractor(get(), get(), get(), get(), get()) }
Expand Down
5 changes: 5 additions & 0 deletions core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ 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
}

fun Modifier.statusBarsInset(): Modifier = composed {
val topInset = (LocalContext.current as? InsetHolder)?.topInset ?: 0
return@composed this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package org.openedx.notifications.data.api
object APIConstants {
const val NOTIFICATION_COUNT = "/api/notifications/count/"
const val NOTIFICATIONS_INBOX = "/api/notifications/"
const val NOTIFICATIONS_SEEN = "/api/notifications/mark-seen/{app_name}/"
const val NOTIFICATION_READ = "/api/notifications/read/"

const val APP_NAME_DISCUSSION = "discussion"
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package org.openedx.notifications.data.api

import org.openedx.notifications.data.model.InboxNotificationsResponse
import org.openedx.notifications.data.model.MarkNotificationReadBody
import org.openedx.notifications.data.model.NotificationsCountResponse
import org.openedx.notifications.data.model.NotificationsMarkResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query

interface NotificationsApi {
Expand All @@ -14,4 +20,14 @@ interface NotificationsApi {
@Query("app_name") appName: String,
@Query("page") page: Int,
): InboxNotificationsResponse

@PUT(APIConstants.NOTIFICATIONS_SEEN)
suspend fun markNotificationsAsSeen(
@Path("app_name") appName: String,
): NotificationsMarkResponse

@PATCH(APIConstants.NOTIFICATION_READ)
suspend fun markNotificationAsRead(
@Body markNotification: MarkNotificationReadBody,
): NotificationsMarkResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.openedx.notifications.data.model

import com.google.gson.annotations.SerializedName

data class MarkNotificationReadBody(
@SerializedName("notification_id")
val notificationId: Int,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.openedx.notifications.data.model

import com.google.gson.annotations.SerializedName

data class NotificationsMarkResponse(
@SerializedName("message")
val message: String,
)
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package org.openedx.notifications.data.repository

import org.openedx.core.extension.isNotNull
import org.openedx.notifications.data.api.APIConstants
import org.openedx.notifications.data.api.NotificationsApi
import org.openedx.notifications.data.model.MarkNotificationReadBody
import org.openedx.notifications.domain.model.InboxNotifications
import org.openedx.notifications.domain.model.NotificationsCount

Expand All @@ -16,4 +18,18 @@ class NotificationsRepository(private val api: NotificationsApi) {
page = page
).mapToDomain()
}

suspend fun markNotificationsAsSeen(): Boolean {
return api.markNotificationsAsSeen(
appName = APIConstants.APP_NAME_DISCUSSION,
).message.isNotNull()
}

suspend fun markNotificationAsRead(notificationId: Int): Boolean {
return api.markNotificationAsRead(
MarkNotificationReadBody(
notificationId = notificationId,
)
).message.isNotNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@ class NotificationsInteractor(private val repository: NotificationsRepository) {
suspend fun getInboxNotifications(page: Int): InboxNotifications {
return repository.getInboxNotifications(page)
}

suspend fun markNotificationsAsSeen(): Boolean {
return repository.markNotificationsAsSeen()
}

suspend fun markNotificationAsRead(notificationId: Int): Boolean {
return repository.markNotificationAsRead(notificationId = notificationId)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.openedx.notifications.domain.model

import org.openedx.core.extension.isNull
import java.util.Date

data class InboxNotifications(
Expand All @@ -26,7 +27,11 @@ data class NotificationItem(
val lastRead: Date?,
val lastSeen: Date?,
val created: Date?,
)
) {
fun isUnread(): Boolean {
return lastRead.isNull()
}
}

data class NotificationContent(
val paragraph: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -36,7 +37,6 @@ import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
Expand All @@ -56,8 +56,10 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.Fragment
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.openedx.core.UIMessage
import org.openedx.core.extension.isNull
import org.openedx.core.ui.BackBtn
import org.openedx.core.ui.HandleUIMessage
import org.openedx.core.ui.OpenEdXPrimaryButton
import org.openedx.core.ui.WindowSize
import org.openedx.core.ui.WindowType
Expand Down Expand Up @@ -91,11 +93,13 @@ class NotificationsInboxFragment : Fragment() {
OpenEdXTheme {
val windowSize = rememberWindowSize()
val uiState by viewModel.uiState.collectAsState()
val uiMessage by viewModel.uiMessage.collectAsState(null)
val canLoadMore by viewModel.canLoadMore.collectAsState()

InboxView(
windowSize = windowSize,
uiState = uiState,
uiMessage = uiMessage,
canLoadMore = canLoadMore,
onBackClick = {
requireActivity().supportFragmentManager.popBackStack()
Expand All @@ -109,6 +113,12 @@ class NotificationsInboxFragment : Fragment() {
paginationCallBack = {
viewModel.fetchMore()
},
markNotificationAsRead = { notification, inboxSection ->
viewModel.markNotificationAsRead(
notification = notification,
inboxSection = inboxSection,
)
}
)
}
}
Expand All @@ -119,17 +129,16 @@ class NotificationsInboxFragment : Fragment() {
private fun InboxView(
windowSize: WindowSize,
uiState: InboxUIState,
uiMessage: UIMessage?,
canLoadMore: Boolean,
onBackClick: () -> Unit,
onSettingsClick: () -> Unit,
onReloadNotifications: () -> Unit,
paginationCallBack: () -> Unit,
markNotificationAsRead: (notificationItem: NotificationItem, inboxSection: InboxSection) -> Unit,
) {
val scaffoldState = rememberScaffoldState()
val scrollState = rememberLazyListState()
val firstVisibleIndex = remember {
mutableIntStateOf(scrollState.firstVisibleItemIndex)
}
val topBarWidth by remember(key1 = windowSize) {
mutableStateOf(
windowSize.windowSizeValue(
Expand All @@ -149,6 +158,7 @@ private fun InboxView(
)
)
}
val loadMoreTriggerThreshold = 4

Scaffold(
scaffoldState = scaffoldState,
Expand All @@ -157,6 +167,9 @@ private fun InboxView(
.navigationBarsPadding(),
backgroundColor = MaterialTheme.appColors.background
) { paddingValues ->

HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState)

Column(
Modifier
.fillMaxSize()
Expand Down Expand Up @@ -192,7 +205,12 @@ private fun InboxView(
}

items(items) { item ->
NotificationItemView(item = item)
NotificationItemView(
modifier = Modifier.clickable {
markNotificationAsRead(item, section)
},
item = item,
)
}

item {
Expand All @@ -212,7 +230,7 @@ private fun InboxView(
}
}

if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) {
if (scrollState.shouldLoadMore(loadMoreTriggerThreshold)) {
paginationCallBack()
}
}
Expand Down Expand Up @@ -342,6 +360,8 @@ private fun NotificationItemView(
.size(8.dp)
.background(MaterialTheme.appColors.primaryButtonBackground)
)
} else {
Spacer(modifier = Modifier.size(8.dp))
}
}
Text(
Expand Down Expand Up @@ -430,11 +450,13 @@ private fun InboxPreview(
InboxView(
windowSize = WindowSize(WindowType.Compact, WindowType.Compact),
uiState = uiState,
uiMessage = null,
canLoadMore = true,
onBackClick = { },
onSettingsClick = { },
onReloadNotifications = { },
paginationCallBack = { },
markNotificationAsRead = { _, _ -> },
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,65 @@
package org.openedx.notifications.presentation.inbox

import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.openedx.core.BaseViewModel
import org.openedx.core.UIMessage
import org.openedx.core.extension.isInternetError
import org.openedx.core.system.ResourceManager
import org.openedx.notifications.domain.interactor.NotificationsInteractor
import org.openedx.notifications.domain.model.InboxSection
import org.openedx.notifications.domain.model.NotificationItem
import java.util.Date
import org.openedx.core.R as coreR

class NotificationsInboxViewModel(
private val interactor: NotificationsInteractor,
private val resourceManager: ResourceManager,
) : BaseViewModel() {

private val _uiState = MutableStateFlow<InboxUIState>(InboxUIState.Loading)
val uiState = _uiState.asStateFlow()

private val _uiMessage = MutableSharedFlow<UIMessage>()
val uiMessage = _uiMessage.asSharedFlow()

private val _canLoadMore = MutableStateFlow(true)
val canLoadMore = _canLoadMore.asStateFlow()

private val notifications: Map<InboxSection, MutableList<NotificationItem>> = mapOf(
InboxSection.RECENT to mutableListOf(),
InboxSection.THIS_WEEK to mutableListOf(),
InboxSection.OLDER to mutableListOf(),
)
private val notifications: MutableMap<InboxSection, MutableList<NotificationItem>> =
mutableMapOf(
InboxSection.RECENT to mutableListOf(),
InboxSection.THIS_WEEK to mutableListOf(),
InboxSection.OLDER to mutableListOf(),
)

private var isLoading = false
private var nextPage = 1

init {
getInboxNotifications()
markNotificationsAsSeen()
}

private fun getInboxNotifications() {
_uiState.value = InboxUIState.Loading
internalLoadNotifications()
}

private fun markNotificationsAsSeen() {
viewModelScope.launch {
try {
interactor.markNotificationsAsSeen()
} catch (e: Exception) {
e.printStackTrace()
}
}
}

fun fetchMore() {
if (!isLoading && nextPage != -1) {
internalLoadNotifications()
Expand Down Expand Up @@ -84,4 +107,47 @@ class NotificationsInboxViewModel(
nextPage = 1
internalLoadNotifications()
}

fun markNotificationAsRead(
notification: NotificationItem,
inboxSection: InboxSection,
) {
viewModelScope.launch {
try {
if (notification.isUnread() && interactor.markNotificationAsRead(notification.id)) {
val currentSection = notifications[inboxSection] ?: return@launch

val index = currentSection.indexOfFirst { it.id == notification.id }
if (index == -1) return@launch

// Locally update the lastRead timestamp to avoid refreshing the entire list.
currentSection[index] = currentSection[index].copy(lastRead = Date())

notifications[inboxSection] = currentSection
_uiState.value = InboxUIState.Data(
notifications = notifications.toMap()
)
}

// Navigating the user to the related post or response in the Course Discussion Tab
// will be implemented in a separate PR.

} catch (e: Exception) {
e.printStackTrace()
emitErrorMessage(e)
}
}
}

private suspend fun emitErrorMessage(e: Exception) {
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))
)
}
}
}

0 comments on commit dfc37d6

Please sign in to comment.