diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d9b77f4..eb0844e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -289,4 +289,8 @@ %1$d followers ยท %2$d following GitHub Logo GitHub + + + contributed to %1$s + published a release diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt index c95a375..0c1ef75 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/data/GitHubApiClient.kt @@ -74,6 +74,43 @@ data class NotificationRepo( @SerialName("full_name") val fullName: String ) +@Serializable +data class SearchResult(val items: List) + +@Serializable +data class GitHubEvent( + val id: String, + val type: String, + val actor: GitHubActor, + val repo: EventRepo, + val payload: EventPayload? = null, + @SerialName("created_at") val createdAt: String +) + +@Serializable +data class GitHubActor(val login: String, @SerialName("avatar_url") val avatarUrl: String) + +@Serializable +data class EventRepo(val name: String) + +@Serializable +data class EventPayload( + val action: String? = null, + @SerialName("pull_request") val pullRequest: EventPullRequest? = null, + val release: EventRelease? = null +) + +@Serializable +data class EventPullRequest( + val title: String, val body: String? = null, val state: String, val head: EventHead +) + +@Serializable +data class EventHead(val ref: String) + +@Serializable +data class EventRelease(val name: String? = null, @SerialName("tag_name") val tagName: String) + class GitHubApiClient(private val tokenStorage: TokenStorage) { private val client = HttpClient { @@ -134,5 +171,22 @@ class GitHubApiClient(private val tokenStorage: TokenStorage) { ) } + suspend fun getTrendingRepos(): Result> = runCatching { + client.get("https://api.github.com/search/repositories") { + withAuth() + parameter("q", "stars:>1000") + parameter("sort", "stars") + parameter("order", "desc") + parameter("per_page", 10) + }.body() + } + + suspend fun getReceivedEvents(username: String, perPage: Int = 20): Result> = runCatching { + client.get("https://api.github.com/users/${username}/received_events") { + withAuth() + parameter("per_page", perPage) + }.body() + } + fun close() = client.close() } diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreScreen.kt index 2a3aec8..a2a32e1 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreScreen.kt @@ -1,7 +1,9 @@ package dev.therealashik.github.explore +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,21 +16,25 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.LocalFireDepartment import androidx.compose.material.icons.outlined.ThumbUp -import androidx.compose.material.icons.outlined.ChatBubbleOutline -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -48,6 +54,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.viewmodel.compose.viewModel import dev.therealashik.github.theme.Dimensions import github.composeapp.generated.resources.Res @@ -58,6 +65,7 @@ import github.composeapp.generated.resources.explore_title import github.composeapp.generated.resources.filter_activity import github.composeapp.generated.resources.read_more import github.composeapp.generated.resources.trending_repos +import github.composeapp.generated.resources.retry import org.jetbrains.compose.resources.stringResource @Composable @@ -67,31 +75,70 @@ fun ExploreScreen(viewModel: ExploreViewModel = viewModel { ExploreViewModel() } Scaffold( topBar = { ExploreTopBar() } ) { innerPadding -> - LazyColumn( + Box( modifier = Modifier .fillMaxSize() .padding(innerPadding) .background(MaterialTheme.colorScheme.background) ) { - item { - DiscoverSection() - } - item { - HorizontalDivider( - modifier = Modifier.padding(vertical = Dimensions.PaddingSmall), - color = MaterialTheme.colorScheme.surfaceVariant - ) - } - item { - ActivitySectionHeader() - } - items( - items = uiState.activityFeed, - key = { it.id } - ) { item -> - when (item) { - is ContributionItem -> ContributionCard(item) - is ReleaseItem -> ReleaseCard(item) + when (val state = uiState) { + is ExploreUiState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.primary + ) + } + is ExploreUiState.Error -> { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Dimensions.SpacingMedium) + ) { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge + ) + Button(onClick = { viewModel.loadData() }) { + Text(stringResource(Res.string.retry)) + } + } + } + is ExploreUiState.Success -> { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item { + DiscoverSection() + } + item { + HorizontalDivider( + modifier = Modifier.padding(vertical = Dimensions.PaddingSmall), + color = MaterialTheme.colorScheme.surfaceVariant + ) + } + item { + TrendingReposSection(state.trendingRepos) + } + item { + HorizontalDivider( + modifier = Modifier.padding(vertical = Dimensions.PaddingSmall), + color = MaterialTheme.colorScheme.surfaceVariant + ) + } + item { + ActivitySectionHeader() + } + items( + items = state.activityFeed, + key = { it.id } + ) { item -> + when (item) { + is ContributionItem -> ContributionCard(item) + is ReleaseItem -> ReleaseCard(item) + } + } + } } } } @@ -105,12 +152,13 @@ private fun ExploreTopBar() { title = { Text( text = stringResource(Res.string.explore_title), - style = MaterialTheme.typography.headlineLarge, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground ) ) } @@ -182,6 +230,101 @@ private fun DiscoverSection() { } } +@Composable +private fun TrendingReposSection(repos: List) { + Column(modifier = Modifier.padding(vertical = Dimensions.PaddingMedium)) { + Text( + text = stringResource(Res.string.trending_repos), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = Dimensions.PaddingMedium, end = Dimensions.PaddingMedium, bottom = Dimensions.PaddingMedium) + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(Dimensions.SpacingMedium), + modifier = Modifier.fillMaxWidth().padding(horizontal = Dimensions.PaddingMedium) + ) { + items(repos, key = { it.id }) { repo -> + TrendingRepoCard(repo) + } + } + } +} + +@Composable +private fun TrendingRepoCard(repo: TrendingRepoItem) { + Card( + modifier = Modifier + .width(Dimensions.TrendingRepoCardWidth) + .height(Dimensions.TrendingRepoCardHeight), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(Dimensions.PaddingMedium) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(Dimensions.PaddingMedium) + ) { + Text( + text = repo.fullName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(Dimensions.SpacingSmall)) + Text( + text = repo.description ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.height(Dimensions.SpacingSmall)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimensions.SpacingMedium) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(Dimensions.IconSizeSmall) + ) + Spacer(modifier = Modifier.width(Dimensions.SpacingExtraSmall)) + Text( + text = repo.stars.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (repo.language != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(Dimensions.IndicatorDotSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondary) + ) + Spacer(modifier = Modifier.width(Dimensions.SpacingExtraSmall)) + Text( + text = repo.language, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + @Composable private fun ActivitySectionHeader() { Row( @@ -201,7 +344,7 @@ private fun ActivitySectionHeader() { Icon( imageVector = Icons.Outlined.FilterList, contentDescription = stringResource(Res.string.filter_activity), - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.primary ) } } @@ -211,10 +354,14 @@ private fun ActivitySectionHeader() { private fun ContributionCard(item: ContributionItem) { var expanded by remember { mutableStateOf(false) } - Column(modifier = Modifier.padding(Dimensions.PaddingMedium)) { + Column( + modifier = Modifier + .clickable { expanded = !expanded } + .padding(Dimensions.PaddingMedium) + ) { // Header Row( - modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }, + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Box( @@ -233,20 +380,20 @@ private fun ContributionCard(item: ContributionItem) { } Spacer(modifier = Modifier.width(Dimensions.SpacingSmall)) Text( - text = stringResource(item.username), + text = item.username, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.width(Dimensions.SpacingExtraSmall)) Text( - text = stringResource(item.actionText), + text = item.actionText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.weight(1f)) Text( - text = stringResource(item.timestamp), + text = item.timestamp, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -266,13 +413,13 @@ private fun ContributionCard(item: ContributionItem) { ) { Column { Text( - text = stringResource(item.repoPath), + text = item.repoPath, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(Dimensions.SpacingExtraSmall)) Text( - text = stringResource(item.prTitle), + text = item.prTitle, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface @@ -289,7 +436,7 @@ private fun ContributionCard(item: ContributionItem) { .padding(horizontal = Dimensions.PaddingSmall, vertical = Dimensions.PaddingExtraSmall) ) { Text( - text = stringResource(item.statusText), + text = item.statusText, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onTertiaryContainer ) @@ -301,7 +448,7 @@ private fun ContributionCard(item: ContributionItem) { .padding(horizontal = Dimensions.PaddingSmall, vertical = Dimensions.PaddingExtraSmall) ) { Text( - text = stringResource(item.branchName), + text = item.branchName, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -309,7 +456,7 @@ private fun ContributionCard(item: ContributionItem) { } Spacer(modifier = Modifier.height(Dimensions.SpacingSmall)) Text( - text = stringResource(item.bodyPreview), + text = item.bodyPreview, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -389,20 +536,20 @@ private fun ReleaseCard(item: ReleaseItem) { } Spacer(modifier = Modifier.width(Dimensions.SpacingSmall)) Text( - text = stringResource(item.botName), + text = item.botName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.width(Dimensions.SpacingExtraSmall)) Text( - text = stringResource(item.actionText), + text = item.actionText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.weight(1f)) Text( - text = stringResource(item.timestamp), + text = item.timestamp, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -427,7 +574,7 @@ private fun ReleaseCard(item: ReleaseItem) { contentAlignment = Alignment.Center ) { Text( - text = stringResource(item.releaseTitle), + text = item.releaseTitle, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreUiState.kt index 488bb8b..2d18750 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreUiState.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreUiState.kt @@ -1,31 +1,42 @@ package dev.therealashik.github.explore -import org.jetbrains.compose.resources.StringResource - sealed class ActivityItem { abstract val id: String } data class ContributionItem( override val id: String, - val username: StringResource, - val actionText: StringResource, - val timestamp: StringResource, - val repoPath: StringResource, - val prTitle: StringResource, - val statusText: StringResource, - val branchName: StringResource, - val bodyPreview: StringResource + val username: String, + val actionText: String, + val timestamp: String, + val repoPath: String, + val prTitle: String, + val statusText: String, + val branchName: String, + val bodyPreview: String ) : ActivityItem() data class ReleaseItem( override val id: String, - val botName: StringResource, - val actionText: StringResource, - val timestamp: StringResource, - val releaseTitle: StringResource + val botName: String, + val actionText: String, + val timestamp: String, + val releaseTitle: String ) : ActivityItem() -data class ExploreUiState( - val activityFeed: List = emptyList() +data class TrendingRepoItem( + val id: String, + val fullName: String, + val description: String?, + val stars: Int, + val language: String? ) + +sealed class ExploreUiState { + data object Loading : ExploreUiState() + data class Error(val message: String) : ExploreUiState() + data class Success( + val trendingRepos: List = emptyList(), + val activityFeed: List = emptyList() + ) : ExploreUiState() +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreViewModel.kt index c8dedf9..a8202fa 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/explore/ExploreViewModel.kt @@ -1,53 +1,105 @@ package dev.therealashik.github.explore import androidx.lifecycle.ViewModel -import github.composeapp.generated.resources.Res -import github.composeapp.generated.resources.explore_mock_user_1 -import github.composeapp.generated.resources.explore_mock_action_1 -import github.composeapp.generated.resources.explore_mock_time_1 -import github.composeapp.generated.resources.explore_mock_repo_1 -import github.composeapp.generated.resources.explore_mock_pr_title_1 -import github.composeapp.generated.resources.explore_mock_status_merged -import github.composeapp.generated.resources.explore_mock_branch_1 -import github.composeapp.generated.resources.explore_mock_pr_body_1 -import github.composeapp.generated.resources.explore_mock_bot -import github.composeapp.generated.resources.explore_mock_action_release -import github.composeapp.generated.resources.explore_mock_time_release -import github.composeapp.generated.resources.explore_mock_release_title +import androidx.lifecycle.viewModelScope +import dev.therealashik.github.data.GitHubApiClient +import dev.therealashik.github.data.createTokenStorage +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.explore_action_contributed +import github.composeapp.generated.resources.explore_action_published +import org.jetbrains.compose.resources.getString class ExploreViewModel : ViewModel() { - private val _uiState = MutableStateFlow(ExploreUiState()) + private val apiClient = GitHubApiClient(createTokenStorage()) + + private val _uiState = MutableStateFlow(ExploreUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() init { - loadMocks() + loadData() } - private fun loadMocks() { - val mocks = listOf( - ContributionItem( - id = "1", - username = Res.string.explore_mock_user_1, - actionText = Res.string.explore_mock_action_1, - timestamp = Res.string.explore_mock_time_1, - repoPath = Res.string.explore_mock_repo_1, - prTitle = Res.string.explore_mock_pr_title_1, - statusText = Res.string.explore_mock_status_merged, - branchName = Res.string.explore_mock_branch_1, - bodyPreview = Res.string.explore_mock_pr_body_1 - ), - ReleaseItem( - id = "2", - botName = Res.string.explore_mock_bot, - actionText = Res.string.explore_mock_action_release, - timestamp = Res.string.explore_mock_time_release, - releaseTitle = Res.string.explore_mock_release_title + fun loadData() { + viewModelScope.launch { + _uiState.value = ExploreUiState.Loading + + val userResult = apiClient.getAuthenticatedUser() + if (userResult.isFailure) { + _uiState.value = ExploreUiState.Error(userResult.exceptionOrNull()?.message ?: "Failed to get user") + return@launch + } + val user = userResult.getOrThrow() + + val trendingDeferred = async { apiClient.getTrendingRepos() } + val eventsDeferred = async { apiClient.getReceivedEvents(user.login) } + + val trendingResult = trendingDeferred.await() + val eventsResult = eventsDeferred.await() + + if (trendingResult.isFailure && eventsResult.isFailure) { + _uiState.value = ExploreUiState.Error(trendingResult.exceptionOrNull()?.message ?: "Failed to load explore data") + return@launch + } + + val trendingRepos = trendingResult.getOrDefault(dev.therealashik.github.data.SearchResult(emptyList())).items.map { repo -> + TrendingRepoItem( + id = repo.id.toString(), + fullName = repo.fullName, + description = repo.description, + stars = repo.stars, + language = repo.language + ) + } + + val activityFeed = eventsResult.getOrDefault(emptyList()).mapNotNull { event -> + when (event.type) { + "PullRequestEvent" -> { + val pr = event.payload?.pullRequest + if (pr != null) { + ContributionItem( + id = event.id, + username = event.actor.login, + actionText = getString(Res.string.explore_action_contributed, event.repo.name), + timestamp = event.createdAt, + repoPath = event.repo.name, + prTitle = pr.title, + statusText = pr.state, + branchName = pr.head.ref, + bodyPreview = pr.body?.take(120) ?: "" + ) + } else null + } + "ReleaseEvent" -> { + val release = event.payload?.release + if (release != null) { + ReleaseItem( + id = event.id, + botName = event.actor.login, + actionText = getString(Res.string.explore_action_published), + timestamp = event.createdAt, + releaseTitle = release.name ?: release.tagName + ) + } else null + } + else -> null + } + } + + _uiState.value = ExploreUiState.Success( + trendingRepos = trendingRepos, + activityFeed = activityFeed ) - ) - _uiState.value = ExploreUiState(activityFeed = mocks) + } + } + + override fun onCleared() { + super.onCleared() + apiClient.close() } } diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/theme/Dimensions.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/theme/Dimensions.kt index 2923d94..7f8c884 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/theme/Dimensions.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/theme/Dimensions.kt @@ -28,4 +28,7 @@ object Dimensions { val Zero = 0.dp val ReleaseBannerHeight = 100.dp + + val TrendingRepoCardWidth = 280.dp + val TrendingRepoCardHeight = 140.dp }