From 7b958c68cda02197f8ff4519eb1361b8f466d009 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Fri, 6 Jun 2025 10:43:31 +0530 Subject: [PATCH 01/17] Refactor notification domain: Rename packages and introduce NotificationAction, NotificationEvent, NotificationState, and NotificationViewModel --- .../data/mappers/NotificationMapper.kt | 8 ++++---- .../RemoteNotificationDataSource.kt | 2 +- .../domain/NotificationDataSource.kt | 1 + .../domain/{ => model}/Notification.kt | 2 +- .../notification/domain/{ => model}/Owner.kt | 2 +- .../domain/{ => model}/Repository.kt | 2 +- .../domain/{ => model}/Subject.kt | 2 +- .../presentation/NotificationAction.kt | 7 +++++++ .../presentation/NotificationEvent.kt | 9 +++++++++ .../presentation/NotificationState.kt | 10 ++++++++++ .../presentation/NotificationViewModel.kt | 19 +++++++++++++++++++ 11 files changed, 55 insertions(+), 9 deletions(-) rename app/src/main/java/com/notifier/app/notification/domain/{ => model}/Notification.kt (85%) rename app/src/main/java/com/notifier/app/notification/domain/{ => model}/Owner.kt (78%) rename app/src/main/java/com/notifier/app/notification/domain/{ => model}/Repository.kt (84%) rename app/src/main/java/com/notifier/app/notification/domain/{ => model}/Subject.kt (70%) create mode 100644 app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt create mode 100644 app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt create mode 100644 app/src/main/java/com/notifier/app/notification/presentation/NotificationState.kt create mode 100644 app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt diff --git a/app/src/main/java/com/notifier/app/notification/data/mappers/NotificationMapper.kt b/app/src/main/java/com/notifier/app/notification/data/mappers/NotificationMapper.kt index ae73790..0ad3e21 100644 --- a/app/src/main/java/com/notifier/app/notification/data/mappers/NotificationMapper.kt +++ b/app/src/main/java/com/notifier/app/notification/data/mappers/NotificationMapper.kt @@ -5,10 +5,10 @@ import com.notifier.app.notification.data.networking.dto.OwnerDto import com.notifier.app.notification.data.networking.dto.RepositoryDto import com.notifier.app.notification.data.networking.dto.SubjectDto import com.notifier.app.notification.data.util.toZonedDateTimeOrDefault -import com.notifier.app.notification.domain.Notification -import com.notifier.app.notification.domain.Owner -import com.notifier.app.notification.domain.Repository -import com.notifier.app.notification.domain.Subject +import com.notifier.app.notification.domain.model.Notification +import com.notifier.app.notification.domain.model.Owner +import com.notifier.app.notification.domain.model.Repository +import com.notifier.app.notification.domain.model.Subject fun NotificationDto.toNotification() = Notification( id = id, diff --git a/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt b/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt index a5e556f..9ff06e8 100644 --- a/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt +++ b/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt @@ -7,8 +7,8 @@ import com.notifier.app.core.domain.util.Result import com.notifier.app.core.domain.util.map import com.notifier.app.notification.data.mappers.toNotification import com.notifier.app.notification.data.networking.dto.NotificationResponseDto -import com.notifier.app.notification.domain.Notification import com.notifier.app.notification.domain.NotificationDataSource +import com.notifier.app.notification.domain.model.Notification import io.ktor.client.HttpClient import io.ktor.client.request.get diff --git a/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt index d66a59d..f078bbb 100644 --- a/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt +++ b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt @@ -2,6 +2,7 @@ package com.notifier.app.notification.domain import com.notifier.app.core.domain.util.Error import com.notifier.app.core.domain.util.Result +import com.notifier.app.notification.domain.model.Notification /** * Interface defining the data source for fetching notifications. diff --git a/app/src/main/java/com/notifier/app/notification/domain/Notification.kt b/app/src/main/java/com/notifier/app/notification/domain/model/Notification.kt similarity index 85% rename from app/src/main/java/com/notifier/app/notification/domain/Notification.kt rename to app/src/main/java/com/notifier/app/notification/domain/model/Notification.kt index c5eaa20..b8b8b19 100644 --- a/app/src/main/java/com/notifier/app/notification/domain/Notification.kt +++ b/app/src/main/java/com/notifier/app/notification/domain/model/Notification.kt @@ -1,4 +1,4 @@ -package com.notifier.app.notification.domain +package com.notifier.app.notification.domain.model import java.time.ZonedDateTime diff --git a/app/src/main/java/com/notifier/app/notification/domain/Owner.kt b/app/src/main/java/com/notifier/app/notification/domain/model/Owner.kt similarity index 78% rename from app/src/main/java/com/notifier/app/notification/domain/Owner.kt rename to app/src/main/java/com/notifier/app/notification/domain/model/Owner.kt index 424f291..80f4ca2 100644 --- a/app/src/main/java/com/notifier/app/notification/domain/Owner.kt +++ b/app/src/main/java/com/notifier/app/notification/domain/model/Owner.kt @@ -1,4 +1,4 @@ -package com.notifier.app.notification.domain +package com.notifier.app.notification.domain.model data class Owner( val avatarUrl: String, diff --git a/app/src/main/java/com/notifier/app/notification/domain/Repository.kt b/app/src/main/java/com/notifier/app/notification/domain/model/Repository.kt similarity index 84% rename from app/src/main/java/com/notifier/app/notification/domain/Repository.kt rename to app/src/main/java/com/notifier/app/notification/domain/model/Repository.kt index 3b6f483..9f2f48c 100644 --- a/app/src/main/java/com/notifier/app/notification/domain/Repository.kt +++ b/app/src/main/java/com/notifier/app/notification/domain/model/Repository.kt @@ -1,4 +1,4 @@ -package com.notifier.app.notification.domain +package com.notifier.app.notification.domain.model data class Repository( val fork: Boolean, diff --git a/app/src/main/java/com/notifier/app/notification/domain/Subject.kt b/app/src/main/java/com/notifier/app/notification/domain/model/Subject.kt similarity index 70% rename from app/src/main/java/com/notifier/app/notification/domain/Subject.kt rename to app/src/main/java/com/notifier/app/notification/domain/model/Subject.kt index 307177f..301d91d 100644 --- a/app/src/main/java/com/notifier/app/notification/domain/Subject.kt +++ b/app/src/main/java/com/notifier/app/notification/domain/model/Subject.kt @@ -1,4 +1,4 @@ -package com.notifier.app.notification.domain +package com.notifier.app.notification.domain.model data class Subject( val latestCommentUrl: String, diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt new file mode 100644 index 0000000..f31131c --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt @@ -0,0 +1,7 @@ +package com.notifier.app.notification.presentation + +import com.notifier.app.notification.domain.model.Notification + +sealed interface NotificationAction { + data class OnNotificationItemClick(val notification: Notification) : NotificationAction +} diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt new file mode 100644 index 0000000..7ceba1f --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt @@ -0,0 +1,9 @@ +package com.notifier.app.notification.presentation + +import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.PersistenceError + +sealed interface NotificationEvent { + data class NetworkErrorEvent(val error: NetworkError) : NotificationEvent + data class PersistenceErrorEvent(val error: PersistenceError) : NotificationEvent +} diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationState.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationState.kt new file mode 100644 index 0000000..dd261bb --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationState.kt @@ -0,0 +1,10 @@ +package com.notifier.app.notification.presentation + +import androidx.compose.runtime.Immutable +import com.notifier.app.notification.domain.model.Notification + +@Immutable +data class NotificationState( + val isLoading: Boolean = false, + val notifications: List = emptyList(), +) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt new file mode 100644 index 0000000..13e4238 --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt @@ -0,0 +1,19 @@ +package com.notifier.app.notification.presentation + +import com.notifier.app.core.presentation.BaseViewModel +import com.notifier.app.notification.domain.NotificationDataSource +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class NotificationViewModel @Inject constructor( + private val notificationDataSource: NotificationDataSource, +) : BaseViewModel(NotificationState()) { + override fun onAction(action: NotificationAction) { + when (action) { + is NotificationAction.OnNotificationItemClick -> { + // Handle notification item click, e.g., navigate to details + } + } + } +} From 94c53f68cec04bbe563194dab176b428b3d06d54 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 8 Jun 2025 13:42:00 +0530 Subject: [PATCH 02/17] Implement notification feature: Add NotificationItem component, update NotificationState and ViewModel, and integrate RemoteNotificationDataSource --- .../core/data/networking/HttpClientFactory.kt | 20 ++--- .../util/ZonedDateTimeToRelativeString.kt | 20 +++++ .../java/com/notifier/app/di/ApiModule.kt | 10 +++ .../RemoteNotificationDataSource.kt | 8 +- .../data/networking/dto/NotificationDto.kt | 2 +- .../data/networking/dto/RepositoryDto.kt | 4 - .../data/networking/dto/SubjectDto.kt | 2 +- .../data/util/ZonedDateTimeUtils.kt | 2 +- .../app/notification/domain/model/Subject.kt | 2 +- .../presentation/NotificationRoute.kt | 18 +++- .../presentation/NotificationScreen.kt | 14 +-- .../presentation/NotificationState.kt | 4 +- .../presentation/NotificationViewModel.kt | 44 ++++++++++ .../components/NotificationItem.kt | 85 +++++++++++++++++++ .../presentation/model/NotificationUi.kt | 39 +++++++++ app/src/main/res/drawable/ic_issue.xml | 15 ++++ app/src/main/res/drawable/ic_pull.xml | 10 +++ 17 files changed, 266 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/notifier/app/core/presentation/util/ZonedDateTimeToRelativeString.kt create mode 100644 app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt create mode 100644 app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt create mode 100644 app/src/main/res/drawable/ic_issue.xml create mode 100644 app/src/main/res/drawable/ic_pull.xml diff --git a/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt b/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt index fca9b92..db6d6aa 100644 --- a/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt +++ b/app/src/main/java/com/notifier/app/core/data/networking/HttpClientFactory.kt @@ -11,10 +11,10 @@ import io.ktor.client.plugins.logging.ANDROID import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.header import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.contentType -import io.ktor.http.headers import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json @@ -61,19 +61,17 @@ class HttpClientFactory @Inject constructor( // Set default request headers and properties defaultRequest { contentType(ContentType.Application.Json) - } - val accessToken = runBlocking { - var retrievedToken = "" - dataStoreManager.getAccessToken().onSuccess { - retrievedToken = it + val accessToken = runBlocking { + var retrievedToken = "" + dataStoreManager.getAccessToken().onSuccess { + retrievedToken = it + } + return@runBlocking retrievedToken } - return@runBlocking retrievedToken - } - headers { - append(HttpHeaders.Authorization, "Bearer $accessToken") - append("X-GitHub-Api-Version", "2022-11-28") + header(HttpHeaders.Authorization, "Bearer $accessToken") + header("X-GitHub-Api-Version", "2022-11-28") } } } diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/ZonedDateTimeToRelativeString.kt b/app/src/main/java/com/notifier/app/core/presentation/util/ZonedDateTimeToRelativeString.kt new file mode 100644 index 0000000..39999d1 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/util/ZonedDateTimeToRelativeString.kt @@ -0,0 +1,20 @@ +package com.notifier.app.core.presentation.util + +import java.time.Duration +import java.time.ZonedDateTime +import kotlin.math.abs + +fun ZonedDateTime.toRelativeTimeString(): String { + val now = ZonedDateTime.now() + val duration = Duration.between(this, now) + val seconds = abs(duration.seconds) + + return when { + seconds < 60 -> "${seconds}s" + seconds < 60 * 60 -> "${seconds / 60}m" + seconds < 60 * 60 * 24 -> "${seconds / 3600}h" + seconds < 60 * 60 * 24 * 7 -> "${seconds / 86400}d" + else -> "${seconds / (86400 * 7)}w" + } +} + diff --git a/app/src/main/java/com/notifier/app/di/ApiModule.kt b/app/src/main/java/com/notifier/app/di/ApiModule.kt index 74d1010..27759c9 100644 --- a/app/src/main/java/com/notifier/app/di/ApiModule.kt +++ b/app/src/main/java/com/notifier/app/di/ApiModule.kt @@ -3,6 +3,8 @@ package com.notifier.app.di import com.notifier.app.auth.data.networking.RemoteAuthTokenDataSource import com.notifier.app.auth.domain.AuthTokenDataSource import com.notifier.app.core.data.networking.HttpClientFactory +import com.notifier.app.notification.data.networking.RemoteNotificationDataSource +import com.notifier.app.notification.domain.NotificationDataSource import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -29,4 +31,12 @@ object ApiModule { ): AuthTokenDataSource { return RemoteAuthTokenDataSource(httpClient) } + + @Provides + @Singleton + fun provideRemoteNotificationDataSource( + httpClient: HttpClient, + ): NotificationDataSource { + return RemoteNotificationDataSource(httpClient) + } } diff --git a/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt b/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt index 9ff06e8..1d70188 100644 --- a/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt +++ b/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt @@ -6,7 +6,7 @@ import com.notifier.app.core.domain.util.NetworkError import com.notifier.app.core.domain.util.Result import com.notifier.app.core.domain.util.map import com.notifier.app.notification.data.mappers.toNotification -import com.notifier.app.notification.data.networking.dto.NotificationResponseDto +import com.notifier.app.notification.data.networking.dto.NotificationDto import com.notifier.app.notification.domain.NotificationDataSource import com.notifier.app.notification.domain.model.Notification import io.ktor.client.HttpClient @@ -35,12 +35,12 @@ class RemoteNotificationDataSource( * [NetworkError] on failure. */ override suspend fun getNotifications(): Result, NetworkError> { - return safeCall { + return safeCall> { httpClient.get( - urlString = constructUrl("/notification") + urlString = constructUrl("/notifications") ) }.map { response -> - response.data.map { it.toNotification() } + response.map { it.toNotification() } } } } diff --git a/app/src/main/java/com/notifier/app/notification/data/networking/dto/NotificationDto.kt b/app/src/main/java/com/notifier/app/notification/data/networking/dto/NotificationDto.kt index d6a19e7..279d94c 100644 --- a/app/src/main/java/com/notifier/app/notification/data/networking/dto/NotificationDto.kt +++ b/app/src/main/java/com/notifier/app/notification/data/networking/dto/NotificationDto.kt @@ -8,7 +8,7 @@ data class NotificationDto( @SerialName("id") val id: String, @SerialName("last_read_at") - val lastReadAt: String, + val lastReadAt: String?, @SerialName("reason") val reason: String, @SerialName("repository") diff --git a/app/src/main/java/com/notifier/app/notification/data/networking/dto/RepositoryDto.kt b/app/src/main/java/com/notifier/app/notification/data/networking/dto/RepositoryDto.kt index de68280..7bf9ec4 100644 --- a/app/src/main/java/com/notifier/app/notification/data/networking/dto/RepositoryDto.kt +++ b/app/src/main/java/com/notifier/app/notification/data/networking/dto/RepositoryDto.kt @@ -45,8 +45,6 @@ data class RepositoryDto( val gitRefsUrl: String, @SerialName("git_tags_url") val gitTagsUrl: String, - @SerialName("git_url") - val gitUrl: String, @SerialName("hooks_url") val hooksUrl: String, @SerialName("html_url") @@ -83,8 +81,6 @@ data class RepositoryDto( val pullsUrl: String, @SerialName("releases_url") val releasesUrl: String, - @SerialName("ssh_url") - val sshUrl: String, @SerialName("stargazers_url") val stargazersUrl: String, @SerialName("statuses_url") diff --git a/app/src/main/java/com/notifier/app/notification/data/networking/dto/SubjectDto.kt b/app/src/main/java/com/notifier/app/notification/data/networking/dto/SubjectDto.kt index 08fc864..311290e 100644 --- a/app/src/main/java/com/notifier/app/notification/data/networking/dto/SubjectDto.kt +++ b/app/src/main/java/com/notifier/app/notification/data/networking/dto/SubjectDto.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable @Serializable data class SubjectDto( @SerialName("latest_comment_url") - val latestCommentUrl: String, + val latestCommentUrl: String?, @SerialName("title") val title: String, @SerialName("type") diff --git a/app/src/main/java/com/notifier/app/notification/data/util/ZonedDateTimeUtils.kt b/app/src/main/java/com/notifier/app/notification/data/util/ZonedDateTimeUtils.kt index cc62aa9..5db2c0b 100644 --- a/app/src/main/java/com/notifier/app/notification/data/util/ZonedDateTimeUtils.kt +++ b/app/src/main/java/com/notifier/app/notification/data/util/ZonedDateTimeUtils.kt @@ -16,7 +16,7 @@ import java.time.ZonedDateTime * Defaults to [Instant.EPOCH] at the system's default time zone. * @return The parsed [ZonedDateTime] or the provided default value if parsing fails. */ -fun String.toZonedDateTimeOrDefault( +fun String?.toZonedDateTimeOrDefault( default: ZonedDateTime = Instant.EPOCH.atZone(ZoneId.systemDefault()), ): ZonedDateTime { return try { diff --git a/app/src/main/java/com/notifier/app/notification/domain/model/Subject.kt b/app/src/main/java/com/notifier/app/notification/domain/model/Subject.kt index 301d91d..cd5aba2 100644 --- a/app/src/main/java/com/notifier/app/notification/domain/model/Subject.kt +++ b/app/src/main/java/com/notifier/app/notification/domain/model/Subject.kt @@ -1,7 +1,7 @@ package com.notifier.app.notification.domain.model data class Subject( - val latestCommentUrl: String, + val latestCommentUrl: String?, val title: String, val type: String, val url: String, diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt index d8e33b3..02cfbfe 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt @@ -1,13 +1,27 @@ package com.notifier.app.notification.presentation import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.notifier.app.core.presentation.util.ObserveAsEvents import kotlinx.serialization.Serializable @Serializable data object NotificationScreen @Composable -fun NotificationRoute() { - NotificationScreen() +fun NotificationRoute( + viewModel: NotificationViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + ObserveAsEvents(events = viewModel.events) { _ -> + + } + + NotificationScreen( + state = state, + ) } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt index f589281..fdbc687 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt @@ -1,28 +1,29 @@ package com.notifier.app.notification.presentation -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.notifier.app.notification.presentation.components.NotificationItem import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable fun NotificationScreen( + state: NotificationState, modifier: Modifier = Modifier, ) { - Column( + LazyColumn( modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = "Notification Screen") + items(state.notifications.size) { + NotificationItem(state.notifications[it]) + } } } @@ -33,6 +34,7 @@ private fun NotificationScreenPreview() { GitHubNotifierTheme { Scaffold { innerPadding -> NotificationScreen( + state = NotificationState(), modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationState.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationState.kt index dd261bb..7291293 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationState.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationState.kt @@ -1,10 +1,10 @@ package com.notifier.app.notification.presentation import androidx.compose.runtime.Immutable -import com.notifier.app.notification.domain.model.Notification +import com.notifier.app.notification.presentation.model.NotificationUi @Immutable data class NotificationState( val isLoading: Boolean = false, - val notifications: List = emptyList(), + val notifications: List = emptyList(), ) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt index 13e4238..67aada7 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt @@ -1,14 +1,36 @@ package com.notifier.app.notification.presentation +import androidx.lifecycle.viewModelScope +import com.notifier.app.core.domain.util.NetworkError +import com.notifier.app.core.domain.util.onError +import com.notifier.app.core.domain.util.onSuccess import com.notifier.app.core.presentation.BaseViewModel import com.notifier.app.notification.domain.NotificationDataSource +import com.notifier.app.notification.presentation.model.toNotificationUi import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class NotificationViewModel @Inject constructor( private val notificationDataSource: NotificationDataSource, ) : BaseViewModel(NotificationState()) { + override val state: StateFlow = mutableStateFlow + .onStart { + mutableStateFlow.update { it.copy(isLoading = true) } + getNotifications() + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + initialValue = NotificationState() + ) + override fun onAction(action: NotificationAction) { when (action) { is NotificationAction.OnNotificationItemClick -> { @@ -16,4 +38,26 @@ class NotificationViewModel @Inject constructor( } } } + + private fun getNotifications() { + viewModelScope.launch { + notificationDataSource.getNotifications() + .onSuccess { notifications -> + mutableStateFlow.update { + it.copy( + notifications = notifications.map { notification -> + notification.toNotificationUi() + }, + isLoading = false + ) + } + } + .onError { error -> + mutableStateFlow.update { it.copy(isLoading = false) } + sendEvent( + NotificationEvent.NetworkErrorEvent(error as NetworkError) + ) + } + } + } } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt new file mode 100644 index 0000000..dfaa817 --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt @@ -0,0 +1,85 @@ +package com.notifier.app.notification.presentation.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.notifier.app.R +import com.notifier.app.notification.presentation.model.NotificationUi +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun NotificationItem( + notificationUi: NotificationUi, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Image( + modifier = Modifier.padding(end = 16.dp), + painter = painterResource(id = notificationUi.iconResId), + contentDescription = null + ) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = notificationUi.repositoryInfo, + fontWeight = FontWeight.Light + + ) + Text( + text = notificationUi.title, + fontWeight = FontWeight.SemiBold, + lineHeight = 20.sp, + ) + Text( + text = notificationUi.description, + fontWeight = FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Column( + modifier = Modifier.padding(start = 16.dp) + ) { + Text(text = notificationUi.time) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NotificationItemPreview() { + val notificationUi = NotificationUi( + id = "1", + iconResId = R.drawable.ic_pull, + repositoryInfo = "owner/repo #1234", + title = "Title of the notification goes here " + + "(e.g., comment, issue, or pull request update)", + description = "This is a brief description of the notification content. It can be a " + + "comment, issue, or pull request update.", + time = "3h", + ) + + GitHubNotifierTheme { + NotificationItem(notificationUi) + } +} diff --git a/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt b/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt new file mode 100644 index 0000000..5ac836a --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt @@ -0,0 +1,39 @@ +package com.notifier.app.notification.presentation.model + +import androidx.annotation.DrawableRes +import com.notifier.app.R +import com.notifier.app.core.presentation.util.toRelativeTimeString +import com.notifier.app.notification.domain.model.Notification + +data class NotificationUi( + val id: String, + @DrawableRes val iconResId: Int, + val repositoryInfo: String, + val title: String, + val description: String, + val time: String, +) + +fun Notification.toNotificationUi(): NotificationUi { + val id = id + val iconResId = when (subject.type) { + "PullRequest" -> R.drawable.ic_pull + "Issue" -> R.drawable.ic_issue + else -> R.drawable.ic_issue + } + val repositoryInfo = repository.fullName + val title = subject.title + val description = "This is a brief description of the notification content. It can be a " + + "comment, issue, or pull request update." + val time = updatedAt.toRelativeTimeString() + + return NotificationUi( + id, + iconResId, + repositoryInfo, + title, + description, + time + ) +} + diff --git a/app/src/main/res/drawable/ic_issue.xml b/app/src/main/res/drawable/ic_issue.xml new file mode 100644 index 0000000..be4adcb --- /dev/null +++ b/app/src/main/res/drawable/ic_issue.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_pull.xml b/app/src/main/res/drawable/ic_pull.xml new file mode 100644 index 0000000..6aca701 --- /dev/null +++ b/app/src/main/res/drawable/ic_pull.xml @@ -0,0 +1,10 @@ + + + From 2393ab340a7d38a83f541ac7b87b8dcdbd0277c4 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 8 Jun 2025 13:46:05 +0530 Subject: [PATCH 03/17] Remove unused variable in NotificationUi.kt --- .../app/notification/presentation/model/NotificationUi.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt b/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt index 5ac836a..2d32f9f 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt @@ -15,7 +15,6 @@ data class NotificationUi( ) fun Notification.toNotificationUi(): NotificationUi { - val id = id val iconResId = when (subject.type) { "PullRequest" -> R.drawable.ic_pull "Issue" -> R.drawable.ic_issue From f68963e851203a52f3776941e8520ee009b28603 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sun, 8 Jun 2025 18:56:58 +0530 Subject: [PATCH 04/17] Add isRead property to NotificationUi and NotificationItem, update UI to show badge for unread notifications --- .../presentation/components/NotificationItem.kt | 10 +++++++++- .../notification/presentation/model/NotificationUi.kt | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt index dfaa817..cc8edc9 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt @@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Badge import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight @@ -58,9 +60,14 @@ fun NotificationItem( } Column( - modifier = Modifier.padding(start = 16.dp) + modifier = Modifier.padding(start = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text(text = notificationUi.time) + + if (!notificationUi.isRead) { + Badge() + } } } } @@ -77,6 +84,7 @@ private fun NotificationItemPreview() { description = "This is a brief description of the notification content. It can be a " + "comment, issue, or pull request update.", time = "3h", + isRead = false, ) GitHubNotifierTheme { diff --git a/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt b/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt index 2d32f9f..612e306 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt @@ -12,6 +12,7 @@ data class NotificationUi( val title: String, val description: String, val time: String, + val isRead: Boolean, ) fun Notification.toNotificationUi(): NotificationUi { @@ -25,6 +26,7 @@ fun Notification.toNotificationUi(): NotificationUi { val description = "This is a brief description of the notification content. It can be a " + "comment, issue, or pull request update." val time = updatedAt.toRelativeTimeString() + val isRead = !unread return NotificationUi( id, @@ -32,7 +34,8 @@ fun Notification.toNotificationUi(): NotificationUi { repositoryInfo, title, description, - time + time, + isRead ) } From 44b202449b41488a9f3b2bc8b13ef78989699203 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Mon, 9 Jun 2025 09:13:10 +0530 Subject: [PATCH 05/17] Add loader on NotificationScreen --- .../notification/presentation/NotificationScreen.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt index fdbc687..7a9d194 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt @@ -1,8 +1,10 @@ package com.notifier.app.notification.presentation +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -17,6 +19,16 @@ fun NotificationScreen( state: NotificationState, modifier: Modifier = Modifier, ) { + if (state.isLoading) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + return + } + LazyColumn( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally From 64a93682a5ee72cfe1e47d000d81164e87a372f6 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sat, 14 Jun 2025 07:40:35 +0530 Subject: [PATCH 06/17] Add includeRead parameter to getNotifications method and update related components --- .../RemoteNotificationDataSource.kt | 9 +++- .../domain/NotificationDataSource.kt | 5 ++- .../presentation/NotificationScreen.kt | 45 ++++++++++++++++++- .../components/NotificationItem.kt | 3 +- 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt b/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt index 1d70188..86cda8f 100644 --- a/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt +++ b/app/src/main/java/com/notifier/app/notification/data/networking/RemoteNotificationDataSource.kt @@ -34,11 +34,16 @@ class RemoteNotificationDataSource( * @return A [Result] containing either a list of [Notification] objects on success, or a * [NetworkError] on failure. */ - override suspend fun getNotifications(): Result, NetworkError> { + override suspend fun getNotifications(includeRead: Boolean): Result, + NetworkError> { return safeCall> { httpClient.get( urlString = constructUrl("/notifications") - ) + ) { + url { + parameters.append("all", includeRead.toString()) + } + } }.map { response -> response.map { it.toNotification() } } diff --git a/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt index f078bbb..f180d5e 100644 --- a/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt +++ b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt @@ -20,7 +20,10 @@ interface NotificationDataSource { * - A **successful** list of [Notification] objects. * - A **failure** with an appropriate [Error]. * + * @param includeRead Whether to include read notifications in the result. Defaults to `true`. * @return A [Result] containing either a list of notifications or an error. */ - suspend fun getNotifications(): Result, Error> + suspend fun getNotifications( + includeRead: Boolean = true + ): Result, Error> } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt index 7a9d194..a8a8d39 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt @@ -11,8 +11,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewDynamicColors import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.notifier.app.R import com.notifier.app.notification.presentation.components.NotificationItem +import com.notifier.app.notification.presentation.model.NotificationUi import com.notifier.app.ui.theme.GitHubNotifierTheme +import kotlin.random.Random @Composable fun NotificationScreen( @@ -39,14 +44,50 @@ fun NotificationScreen( } } +class NotificationStateParameterProvider : PreviewParameterProvider { + private val notificationUiList = (1..10).map { + val iconResIdList = listOf( + R.drawable.ic_pull, + R.drawable.ic_issue + ) + val randomIconResIdIndex = Random.nextInt(until = iconResIdList.size) + NotificationUi( + id = it.toString(), + iconResId = iconResIdList[randomIconResIdIndex], + repositoryInfo = "theMr17/github-notifier #1234", + title = "Title of the notification goes here " + + "(e.g., comment, issue, or pull request update)", + description = "This is a brief description of the notification content. It can be a " + + "comment, issue, or pull request update.", + time = "${it}h", + isRead = Random.nextBoolean(), + ) + } + + override val values: Sequence + get() = sequenceOf( + NotificationState( + isLoading = true, + notifications = emptyList() + ), + NotificationState( + isLoading = false, + notifications = notificationUiList + ) + ) +} + @PreviewLightDark @PreviewDynamicColors @Composable -private fun NotificationScreenPreview() { +private fun NotificationScreenPreview( + @PreviewParameter(NotificationStateParameterProvider::class) + state: NotificationState, +) { GitHubNotifierTheme { Scaffold { innerPadding -> NotificationScreen( - state = NotificationState(), + state = state, modifier = Modifier.padding(innerPadding) ) } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt index cc8edc9..9c3b2d6 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt @@ -48,8 +48,9 @@ fun NotificationItem( ) Text( text = notificationUi.title, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Medium, lineHeight = 20.sp, + fontSize = 18.sp ) Text( text = notificationUi.description, From 508730bba2199e21e624da25951b6aabfaced04f Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sat, 14 Jun 2025 07:49:50 +0530 Subject: [PATCH 07/17] Refactor toRelativeTimeString function for improved readability and accuracy in time representation --- .../util/ZonedDateTimeToRelativeString.kt | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/notifier/app/core/presentation/util/ZonedDateTimeToRelativeString.kt b/app/src/main/java/com/notifier/app/core/presentation/util/ZonedDateTimeToRelativeString.kt index 39999d1..c6f8bbf 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/util/ZonedDateTimeToRelativeString.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/util/ZonedDateTimeToRelativeString.kt @@ -7,14 +7,20 @@ import kotlin.math.abs fun ZonedDateTime.toRelativeTimeString(): String { val now = ZonedDateTime.now() val duration = Duration.between(this, now) - val seconds = abs(duration.seconds) + val seconds = duration.seconds + + val absSeconds = abs(seconds) + + val minute = 60 + val hour = 60 * minute + val day = 24 * hour + val week = 7 * day return when { - seconds < 60 -> "${seconds}s" - seconds < 60 * 60 -> "${seconds / 60}m" - seconds < 60 * 60 * 24 -> "${seconds / 3600}h" - seconds < 60 * 60 * 24 * 7 -> "${seconds / 86400}d" - else -> "${seconds / (86400 * 7)}w" + absSeconds < minute -> "${absSeconds}s" + absSeconds < hour -> "${absSeconds / minute}m" + absSeconds < day -> "${absSeconds / hour}h" + absSeconds < week -> "${absSeconds / day}d" + else -> "${absSeconds / week}w" } } - From 42236c8c5263f488094e0c4f1154ed42d99254da Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sat, 14 Jun 2025 08:13:30 +0530 Subject: [PATCH 08/17] Add redirectUrl to NotificationUi and related components for improved navigation --- .../presentation/NotificationScreen.kt | 1 + .../components/NotificationItem.kt | 1 + .../presentation/model/NotificationUi.kt | 43 +++++++++++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt index a8a8d39..6a3f511 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt @@ -61,6 +61,7 @@ class NotificationStateParameterProvider : PreviewParameterProvider R.drawable.ic_issue else -> R.drawable.ic_issue } - val repositoryInfo = repository.fullName + val number = subject.url.substringAfterLast("/").toInt() + val repositoryInfo = "${repository.fullName} #${number}" val title = subject.title - val description = "This is a brief description of the notification content. It can be a " + - "comment, issue, or pull request update." + val description = when (reason) { + "assign" -> "You were assigned to this ${subject.type.lowercase()}." + "author" -> "You're the author of this ${subject.type.lowercase()}." + "comment" -> "Someone commented on this ${subject.type.lowercase()}." + "invitation" -> "You've been invited to collaborate on this repository." + "manual" -> "You manually subscribed to this ${subject.type.lowercase()}." + "mention" -> "You were mentioned in this ${subject.type.lowercase()}." + "review_requested" -> "You were requested to review this pull request." + "security_alert" -> "There is a security alert related to this repository." + "state_change" -> "This ${subject.type.lowercase()} was updated." + "subscribed" -> "You are subscribed to updates on this ${subject.type.lowercase()}." + "team_mention" -> "Your team was mentioned in this ${subject.type.lowercase()}." + else -> "There’s an update to this ${subject.type.lowercase()}." + } val time = updatedAt.toRelativeTimeString() val isRead = !unread + val redirectUrl = getHtmlUrlFromApiUrl(subject.url, repository.htmlUrl) return NotificationUi( id, @@ -35,7 +50,27 @@ fun Notification.toNotificationUi(): NotificationUi { title, description, time, - isRead + isRead, + redirectUrl ) } +fun getHtmlUrlFromApiUrl(apiUrl: String?, repositoryHtmlUrl: String): String { + if (apiUrl.isNullOrBlank()) return repositoryHtmlUrl + + val regex = Regex("""https://api.github.com/repos/([^/]+)/([^/]+)/([^/]+)/(.+)""") + val match = regex.find(apiUrl) ?: return repositoryHtmlUrl + val (owner, repo, type, rest) = match.destructured + + val path = when (type) { + "pulls" -> "pull/$rest" + "issues" -> "issues/$rest" + "commits" -> "commit/$rest" + "releases" -> "releases/$rest" + "discussions" -> "discussions/$rest" + "comments" -> "issues/comments/$rest" + else -> return repositoryHtmlUrl + } + + return "https://github.com/$owner/$repo/$path" +} From 8efe331d32e4b5bcef6dda4de86555bb6cc481f1 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sat, 21 Jun 2025 22:42:08 +0530 Subject: [PATCH 09/17] Add discussion icon and update NotificationItem to display badge for unread notifications --- .../presentation/components/NotificationItem.kt | 5 ++++- .../notification/presentation/model/NotificationUi.kt | 1 + app/src/main/res/drawable/ic_discussion.xml | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable/ic_discussion.xml diff --git a/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt index 483b8a2..cfacb91 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Badge import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -67,7 +68,9 @@ fun NotificationItem( Text(text = notificationUi.time) if (!notificationUi.isRead) { - Badge() + Badge( + modifier = Modifier.size(8.dp), + ) } } } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt b/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt index d05f7a7..7f1d7e4 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt @@ -20,6 +20,7 @@ fun Notification.toNotificationUi(): NotificationUi { val iconResId = when (subject.type) { "PullRequest" -> R.drawable.ic_pull "Issue" -> R.drawable.ic_issue + "Discussion" -> R.drawable.ic_discussion else -> R.drawable.ic_issue } val number = subject.url.substringAfterLast("/").toInt() diff --git a/app/src/main/res/drawable/ic_discussion.xml b/app/src/main/res/drawable/ic_discussion.xml new file mode 100644 index 0000000..6eea5dd --- /dev/null +++ b/app/src/main/res/drawable/ic_discussion.xml @@ -0,0 +1,10 @@ + + + From 7a480347af333b2c527854f4c7e6c67b34487434 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sat, 21 Jun 2025 22:44:03 +0530 Subject: [PATCH 10/17] Add discussion icon to NotificationScreen for enhanced visual representation --- .../app/notification/presentation/NotificationScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt index 6a3f511..036746a 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt @@ -48,7 +48,8 @@ class NotificationStateParameterProvider : PreviewParameterProvider Date: Mon, 7 Jul 2025 14:18:47 +0530 Subject: [PATCH 11/17] Add unit tests for toRelativeTimeString function to ensure accurate time representation --- .../ZonedDateTimeToRelativeStringTest.kt | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 app/src/test/java/com/notifier/app/core/presentation/ZonedDateTimeToRelativeStringTest.kt diff --git a/app/src/test/java/com/notifier/app/core/presentation/ZonedDateTimeToRelativeStringTest.kt b/app/src/test/java/com/notifier/app/core/presentation/ZonedDateTimeToRelativeStringTest.kt new file mode 100644 index 0000000..7451b01 --- /dev/null +++ b/app/src/test/java/com/notifier/app/core/presentation/ZonedDateTimeToRelativeStringTest.kt @@ -0,0 +1,100 @@ +package com.notifier.app.core.presentation + +import com.google.common.truth.Truth.assertThat +import com.notifier.app.core.presentation.util.toRelativeTimeString +import org.junit.Test +import java.time.ZonedDateTime + +class ZonedDateTimeToRelativeStringTest { + private val now: ZonedDateTime = ZonedDateTime.now() + + @Test + fun testToRelativeTimeString_withSameTime_returns0s() { + val time = now + assertThat(time.toRelativeTimeString()).isEqualTo("0s") + } + + @Test + fun testToRelativeTimeString_with59SecondsAgo_returns59s() { + val time = now.minusSeconds(59) + assertThat(time.toRelativeTimeString()).isEqualTo("59s") + } + + @Test + fun testToRelativeTimeString_with60SecondsAgo_returns1m() { + val time = now.minusSeconds(60) + assertThat(time.toRelativeTimeString()).isEqualTo("1m") + } + + @Test + fun testToRelativeTimeString_with3599SecondsAgo_returns59m() { + val time = now.minusSeconds(3599) + assertThat(time.toRelativeTimeString()).isEqualTo("59m") + } + + @Test + fun testToRelativeTimeString_with3600SecondsAgo_returns1h() { + val time = now.minusSeconds(3600) + assertThat(time.toRelativeTimeString()).isEqualTo("1h") + } + + @Test + fun testToRelativeTimeString_with86399SecondsAgo_returns23h() { + val time = now.minusSeconds(86399) + assertThat(time.toRelativeTimeString()).isEqualTo("23h") + } + + @Test + fun testToRelativeTimeString_with86400SecondsAgo_returns1d() { + val time = now.minusSeconds(86400) + assertThat(time.toRelativeTimeString()).isEqualTo("1d") + } + + @Test + fun testToRelativeTimeString_with604799SecondsAgo_returns6d() { + val time = now.minusSeconds(604799) + assertThat(time.toRelativeTimeString()).isEqualTo("6d") + } + + @Test + fun testToRelativeTimeString_with604800SecondsAgo_returns1w() { + val time = now.minusSeconds(604800) + assertThat(time.toRelativeTimeString()).isEqualTo("1w") + } + + @Test + fun testToRelativeTimeString_withFutureTimeWithinAnHour_returns25m() { + val time = now.plusMinutes(25) + assertThat(time.toRelativeTimeString()).isEqualTo("25m") + } + + @Test + fun testToRelativeTimeString_with90DaysInFuture_returns12w() { + val time = now.plusDays(90) + assertThat(time.toRelativeTimeString()).isEqualTo("12w") + } + + @Test + fun testToRelativeTimeString_with365DaysInPast_returns52w() { + val time = now.minusDays(365) + assertThat(time.toRelativeTimeString()).isEqualTo("${365 / 7}w") + } + + @Test + fun testToRelativeTimeString_with119SecondsAgo_returns1m() { + val time = now.minusSeconds(119) + assertThat(time.toRelativeTimeString()).isEqualTo("1m") + } + + @Test + fun testToRelativeTimeString_withNegativeDuration_returns5h() { + val time = now.plusHours(5) + assertThat(time.toRelativeTimeString()).isEqualTo("5h") + } + + @Test + fun testToRelativeTimeString_with1000DaysInFuture_returns142w() { + val time = now.plusDays(1000) + assertThat(time.toRelativeTimeString()).isEqualTo("${1000 / 7}w") + } +} From 8843bbafe1cf46d843b07996799643c28c042df1 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sat, 19 Jul 2025 08:57:40 +0530 Subject: [PATCH 12/17] Refactor Notification components for improved clarity and performance --- .../app/notification/presentation/NotificationAction.kt | 6 +----- .../app/notification/presentation/NotificationEvent.kt | 2 -- .../app/notification/presentation/NotificationRoute.kt | 9 +-------- .../app/notification/presentation/NotificationScreen.kt | 7 +++++-- .../notification/presentation/NotificationViewModel.kt | 6 +----- 5 files changed, 8 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt index f31131c..3abe256 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt @@ -1,7 +1,3 @@ package com.notifier.app.notification.presentation -import com.notifier.app.notification.domain.model.Notification - -sealed interface NotificationAction { - data class OnNotificationItemClick(val notification: Notification) : NotificationAction -} +sealed interface NotificationAction diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt index 7ceba1f..f6fc594 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt @@ -1,9 +1,7 @@ package com.notifier.app.notification.presentation import com.notifier.app.core.domain.util.NetworkError -import com.notifier.app.core.domain.util.PersistenceError sealed interface NotificationEvent { data class NetworkErrorEvent(val error: NetworkError) : NotificationEvent - data class PersistenceErrorEvent(val error: PersistenceError) : NotificationEvent } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt index 02cfbfe..96231c8 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.notifier.app.core.presentation.util.ObserveAsEvents import kotlinx.serialization.Serializable @Serializable @@ -16,12 +15,6 @@ fun NotificationRoute( ) { val state by viewModel.state.collectAsStateWithLifecycle() - ObserveAsEvents(events = viewModel.events) { _ -> - - } - - NotificationScreen( - state = state, - ) + NotificationScreen(state) } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt index 036746a..b33424d 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt @@ -38,8 +38,11 @@ fun NotificationScreen( modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally ) { - items(state.notifications.size) { - NotificationItem(state.notifications[it]) + items( + count = state.notifications.size, + key = { index -> state.notifications[index].id } + ) { index -> + NotificationItem(state.notifications[index]) } } } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt index 67aada7..47b4696 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt @@ -32,11 +32,7 @@ class NotificationViewModel @Inject constructor( ) override fun onAction(action: NotificationAction) { - when (action) { - is NotificationAction.OnNotificationItemClick -> { - // Handle notification item click, e.g., navigate to details - } - } + TODO("Not yet implemented") } private fun getNotifications() { From eea89790920a72aac87f947659514feba71af68a Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 22 Jul 2025 19:40:05 +0530 Subject: [PATCH 13/17] Enhance Notification components with detailed documentation and improved content --- .../presentation/NotificationAction.kt | 6 ++++++ .../presentation/NotificationEvent.kt | 14 ++++++++++++++ .../presentation/NotificationRoute.kt | 15 ++++++++++++++- .../presentation/components/NotificationItem.kt | 6 ++---- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt index 3abe256..ad5304f 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt @@ -1,3 +1,9 @@ package com.notifier.app.notification.presentation +/** + * A sealed interface representing different user actions in the Notification screen. + * + * These actions are dispatched based on user interactions with the notification UI, + * such as clicking on a notification or performing bulk actions. + */ sealed interface NotificationAction diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt index f6fc594..029a9fd 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt @@ -2,6 +2,20 @@ package com.notifier.app.notification.presentation import com.notifier.app.core.domain.util.NetworkError +/** + * A sealed interface representing different notification-related events. + * + * These events are used to trigger UI updates or error messages in response + * to state changes in the Notification screen. + */ sealed interface NotificationEvent { + /** + * Event that triggers a toast or error message for a network-related error. + * + * This event is fired when a network error occurs while loading or interacting + * with notifications. + * + * @param error The network error that occurred. + */ data class NetworkErrorEvent(val error: NetworkError) : NotificationEvent } diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt index 96231c8..d3a23f5 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationRoute.kt @@ -6,9 +6,23 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.serialization.Serializable +/** + * Navigation route object for the Notification screen. + * + * This object is used for deep linking and navigation to the Notification screen within the app. + */ @Serializable data object NotificationScreen +/** + * The NotificationRoute Composable is the entry point to the Notification screen. + * + * It observes the notification state from the [NotificationViewModel] and renders + * the Notification screen with the latest state. + * + * @param viewModel The instance of [NotificationViewModel] used for managing notification logic + * and state. + */ @Composable fun NotificationRoute( viewModel: NotificationViewModel = hiltViewModel(), @@ -17,4 +31,3 @@ fun NotificationRoute( NotificationScreen(state) } - diff --git a/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt index cfacb91..1b4d739 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt @@ -83,10 +83,8 @@ private fun NotificationItemPreview() { id = "1", iconResId = R.drawable.ic_pull, repositoryInfo = "owner/repo #1234", - title = "Title of the notification goes here " + - "(e.g., comment, issue, or pull request update)", - description = "This is a brief description of the notification content. It can be a " + - "comment, issue, or pull request update.", + title = "New comment on your pull request", + description = "A GitHub user commented: 'Looks good to me!'", time = "3h", isRead = false, redirectUrl = "https://github.com/theMr17/github-notifier", From 2418d17e2c2f78687c555bcd2bc167ad7c38c7a7 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 22 Jul 2025 20:40:35 +0530 Subject: [PATCH 14/17] Add green color resource and update icons to use it for consistent styling --- app/src/main/res/drawable/ic_discussion.xml | 2 +- app/src/main/res/drawable/ic_issue.xml | 4 ++-- app/src/main/res/drawable/ic_pull.xml | 2 +- app/src/main/res/values/colors.xml | 4 +++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/drawable/ic_discussion.xml b/app/src/main/res/drawable/ic_discussion.xml index 6eea5dd..7d4eac5 100644 --- a/app/src/main/res/drawable/ic_discussion.xml +++ b/app/src/main/res/drawable/ic_discussion.xml @@ -4,7 +4,7 @@ android:viewportWidth="16" android:viewportHeight="16"> diff --git a/app/src/main/res/drawable/ic_issue.xml b/app/src/main/res/drawable/ic_issue.xml index be4adcb..edcfafd 100644 --- a/app/src/main/res/drawable/ic_issue.xml +++ b/app/src/main/res/drawable/ic_issue.xml @@ -4,12 +4,12 @@ android:viewportWidth="16" android:viewportHeight="16"> diff --git a/app/src/main/res/drawable/ic_pull.xml b/app/src/main/res/drawable/ic_pull.xml index 6aca701..66b2ceb 100644 --- a/app/src/main/res/drawable/ic_pull.xml +++ b/app/src/main/res/drawable/ic_pull.xml @@ -4,7 +4,7 @@ android:viewportWidth="16" android:viewportHeight="16"> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 545704f..54f3f26 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,2 +1,4 @@ - + + #FF1A7F37 + From 2ada9f0f061323ae5391d0756c8e6bc4004304f3 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 22 Jul 2025 20:55:31 +0530 Subject: [PATCH 15/17] Enhance NotificationViewModel with comprehensive documentation for better clarity and maintainability --- .../presentation/NotificationViewModel.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt index 47b4696..f2c5ff1 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt @@ -16,10 +16,26 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +/** + * ViewModel for managing the Notification screen's state and events. + * + * This ViewModel handles: + * - Fetching notifications from the domain layer + * - Updating the UI state based on loading and error results + * - Sending one-time events such as network errors + * + * @property notificationDataSource Used to fetch notification data from the domain layer. + */ @HiltViewModel class NotificationViewModel @Inject constructor( private val notificationDataSource: NotificationDataSource, ) : BaseViewModel(NotificationState()) { + + /** + * The state flow that exposes the current notification UI state. + * + * Starts by setting loading to true and then fetching the notifications. + */ override val state: StateFlow = mutableStateFlow .onStart { mutableStateFlow.update { it.copy(isLoading = true) } @@ -31,10 +47,22 @@ class NotificationViewModel @Inject constructor( initialValue = NotificationState() ) + /** + * Handles user-triggered actions from the UI. + * + * @param action the user action to handle + */ override fun onAction(action: NotificationAction) { + // Future user interactions (e.g., mark as read, refresh) will be handled here. TODO("Not yet implemented") } + /** + * Fetches notifications from the [notificationDataSource]. + * + * Updates the [mutableStateFlow] with the fetched data on success, + * or sends a [NotificationEvent.NetworkErrorEvent] on failure. + */ private fun getNotifications() { viewModelScope.launch { notificationDataSource.getNotifications() From ea836479b16c6e3a46dde26297090b560d8d2370 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 29 Jul 2025 08:25:17 +0530 Subject: [PATCH 16/17] Enhance NotificationItem and NotificationScreen with detailed documentation for improved clarity and maintainability --- .../presentation/NotificationScreen.kt | 30 ++++++++++++++++--- .../components/NotificationItem.kt | 27 ++++++++++++++--- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt index b33424d..84f9699 100644 --- a/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationScreen.kt @@ -19,6 +19,17 @@ import com.notifier.app.notification.presentation.model.NotificationUi import com.notifier.app.ui.theme.GitHubNotifierTheme import kotlin.random.Random +/** + * A composable function that displays the GitHub notification screen UI. + * + * Based on the current [NotificationState], this screen renders either a loading indicator or + * a list of notifications: + * - **Loading State**: A centered [CircularProgressIndicator] is shown when [NotificationState.isLoading] is `true`. + * - **Loaded State**: A scrollable list of [NotificationItem]s is displayed when notifications are available. + * + * @param state The current state of the notification screen, including loading status and the list of notifications. + * @param modifier An optional [Modifier] to be applied to the root layout. + */ @Composable fun NotificationScreen( state: NotificationState, @@ -47,6 +58,12 @@ fun NotificationScreen( } } +/** + * Preview parameter provider for displaying different notification states in previews. + * + * Provides sample values for the [NotificationState] to simulate both loading and loaded states + * with fake notification data, including randomized icons and read/unread states. + */ class NotificationStateParameterProvider : PreviewParameterProvider { private val notificationUiList = (1..10).map { val iconResIdList = listOf( @@ -54,15 +71,13 @@ class NotificationStateParameterProvider : PreviewParameterProvider Date: Tue, 29 Jul 2025 08:27:57 +0530 Subject: [PATCH 17/17] Enhance code consistency by adding missing commas in parameter lists across multiple files --- .../notification/FakeNotificationPermissionState.kt | 6 ++++-- .../presentation/notification/WithNotificationPermission.kt | 2 +- .../app/notification/domain/NotificationDataSource.kt | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/com/notifier/app/core/presentation/notification/FakeNotificationPermissionState.kt b/app/src/androidTest/java/com/notifier/app/core/presentation/notification/FakeNotificationPermissionState.kt index b1c9940..6c3ae19 100644 --- a/app/src/androidTest/java/com/notifier/app/core/presentation/notification/FakeNotificationPermissionState.kt +++ b/app/src/androidTest/java/com/notifier/app/core/presentation/notification/FakeNotificationPermissionState.kt @@ -11,8 +11,10 @@ package com.notifier.app.core.presentation.notification */ class FakeNotificationPermissionState( override val isGranted: Boolean, - override val shouldShowRationale: Boolean + override val shouldShowRationale: Boolean, ) : NotificationPermissionState { /** No-op implementation since this is only used in tests. */ - override fun requestPermission() { /* No-op for tests */ } + override fun requestPermission() { + /* No-op for tests */ + } } diff --git a/app/src/main/java/com/notifier/app/core/presentation/notification/WithNotificationPermission.kt b/app/src/main/java/com/notifier/app/core/presentation/notification/WithNotificationPermission.kt index effae59..5d7386a 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/notification/WithNotificationPermission.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/notification/WithNotificationPermission.kt @@ -67,7 +67,7 @@ fun WithNotificationPermission( */ @Composable private fun NotificationPermissionHandler( - permissionState: NotificationPermissionState + permissionState: NotificationPermissionState, ) { val context = LocalContext.current var hasRequested by rememberSaveable { mutableStateOf(false) } diff --git a/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt index f180d5e..452ba3d 100644 --- a/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt +++ b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt @@ -24,6 +24,6 @@ interface NotificationDataSource { * @return A [Result] containing either a list of notifications or an error. */ suspend fun getNotifications( - includeRead: Boolean = true + includeRead: Boolean = true, ): Result, Error> }