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/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/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/core/presentation/util/ZonedDateTimeToRelativeString.kt b/app/src/main/java/com/notifier/app/core/presentation/util/ZonedDateTimeToRelativeString.kt new file mode 100644 index 0000000..c6f8bbf --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/util/ZonedDateTimeToRelativeString.kt @@ -0,0 +1,26 @@ +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 = duration.seconds + + val absSeconds = abs(seconds) + + val minute = 60 + val hour = 60 * minute + val day = 24 * hour + val week = 7 * day + + return when { + absSeconds < minute -> "${absSeconds}s" + absSeconds < hour -> "${absSeconds / minute}m" + absSeconds < day -> "${absSeconds / hour}h" + absSeconds < week -> "${absSeconds / day}d" + else -> "${absSeconds / week}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/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..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 @@ -6,9 +6,9 @@ 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.domain.Notification +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 import io.ktor.client.request.get @@ -34,13 +34,18 @@ 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> { - return safeCall { + override suspend fun getNotifications(includeRead: Boolean): Result, + NetworkError> { + return safeCall> { httpClient.get( - urlString = constructUrl("/notification") - ) + urlString = constructUrl("/notifications") + ) { + url { + parameters.append("all", includeRead.toString()) + } + } }.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/NotificationDataSource.kt b/app/src/main/java/com/notifier/app/notification/domain/NotificationDataSource.kt index d66a59d..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 @@ -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. @@ -19,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/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 50% 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..cd5aba2 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,7 +1,7 @@ -package com.notifier.app.notification.domain +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/NotificationAction.kt b/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt new file mode 100644 index 0000000..ad5304f --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationAction.kt @@ -0,0 +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 new file mode 100644 index 0000000..029a9fd --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationEvent.kt @@ -0,0 +1,21 @@ +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 d8e33b3..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 @@ -1,13 +1,33 @@ 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 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() { - NotificationScreen() -} +fun NotificationRoute( + viewModel: NotificationViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + 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 f589281..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 @@ -1,38 +1,120 @@ package com.notifier.app.notification.presentation -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +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.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 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 +/** + * 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, modifier: Modifier = Modifier, ) { - Column( + if (state.isLoading) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + return + } + + LazyColumn( modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = "Notification Screen") + items( + count = state.notifications.size, + key = { index -> state.notifications[index].id } + ) { index -> + NotificationItem(state.notifications[index]) + } + } +} + +/** + * 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( + R.drawable.ic_pull, + R.drawable.ic_issue, + R.drawable.ic_discussion + ) + NotificationUi( + id = it.toString(), + iconResId = iconResIdList.random(), + 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.", + time = "${it}h", + isRead = Random.nextBoolean(), + redirectUrl = "https://github.com/theMr17/github-notifier", + ) } + + override val values: Sequence + get() = sequenceOf( + NotificationState( + isLoading = true, + notifications = emptyList() + ), + NotificationState( + isLoading = false, + notifications = notificationUiList + ) + ) } +/** + * Preview of the [NotificationScreen] composable with dynamic colors and light/dark theme support. + * + * This preview helps visualize how the Notification screen looks in different visual states. + * + * @param state The preview parameter simulating different [NotificationState]s. + */ @PreviewLightDark @PreviewDynamicColors @Composable -private fun NotificationScreenPreview() { +private fun NotificationScreenPreview( + @PreviewParameter(NotificationStateParameterProvider::class) + state: NotificationState, +) { GitHubNotifierTheme { Scaffold { innerPadding -> NotificationScreen( + state = state, 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 new file mode 100644 index 0000000..7291293 --- /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.presentation.model.NotificationUi + +@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..f2c5ff1 --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/NotificationViewModel.kt @@ -0,0 +1,87 @@ +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 + +/** + * 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) } + getNotifications() + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), + 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() + .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..67ae472 --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/components/NotificationItem.kt @@ -0,0 +1,115 @@ +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.foundation.layout.size +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 +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 + +/** + * A composable function that displays an individual GitHub notification item. + * + * Each notification displays the following: + * - **Icon**: Visual indicator representing the type (e.g., pull request, issue). + * - **Repository Info**: Information about the repository and reference number. + * - **Title**: Brief summary of the notification content. + * - **Description**: Additional detail (single-line preview). + * - **Time**: How long ago the event occurred. + * - **Unread Badge**: A small badge shown if the notification is unread. + * + * @param notificationUi The data model representing a GitHub notification. + * @param modifier An optional [Modifier] for customizing the layout. + */ +@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.Medium, + lineHeight = 20.sp, + fontSize = 18.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = notificationUi.description, + fontWeight = FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Column( + modifier = Modifier.padding(start = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = notificationUi.time) + + if (!notificationUi.isRead) { + Badge( + modifier = Modifier.size(8.dp), + ) + } + } + } +} + +/** + * A preview of [NotificationItem] using sample data to visualize UI behavior. + */ +@Preview(showBackground = true) +@Composable +private fun NotificationItemPreview() { + val notificationUi = NotificationUi( + id = "1", + iconResId = R.drawable.ic_pull, + repositoryInfo = "theMr17/github-notifier #1234", + 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", + ) + + 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..7f1d7e4 --- /dev/null +++ b/app/src/main/java/com/notifier/app/notification/presentation/model/NotificationUi.kt @@ -0,0 +1,77 @@ +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, + val isRead: Boolean, + val redirectUrl: String, +) + +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() + val repositoryInfo = "${repository.fullName} #${number}" + val title = subject.title + 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, + iconResId, + repositoryInfo, + title, + description, + time, + 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" +} 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..7d4eac5 --- /dev/null +++ b/app/src/main/res/drawable/ic_discussion.xml @@ -0,0 +1,10 @@ + + + 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..edcfafd --- /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..66b2ceb --- /dev/null +++ b/app/src/main/res/drawable/ic_pull.xml @@ -0,0 +1,10 @@ + + + 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 + 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") + } +}