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: Mark Notifications as Seen and Read #79

Merged
merged 3 commits into from
Jan 8, 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
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)) {
HamzaIsrar12 marked this conversation as resolved.
Show resolved Hide resolved
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))
)
}
}
}
Loading