From 52dafddb29710c2b2a85abd1eb6cf799becbddc4 Mon Sep 17 00:00:00 2001 From: Wing <44992537+wingio@users.noreply.github.com> Date: Mon, 2 Sep 2024 22:19:18 -0400 Subject: [PATCH] Make explore tab functional Currently just displays trending within a given period TODO: Offer the language and spoken language filtering options TODO: Button to view awesome lists --- .../TrendingRepository.fragment.graphql | 20 ++ .../gloom/gql/queries/Trending.query.graphql | 5 + .../gloom/api/repository/GraphQLRepository.kt | 5 + .../gloom/api/service/GraphQLService.kt | 9 + .../gloom/domain/manager/PreferenceManager.kt | 14 ++ .../gloom/ui/util/NumberFormatter.android.kt | 35 ++++ .../gloom/di/module/ViewModelModule.kt | 4 +- .../ui/component/filter/ChoiceInputChip.kt | 79 +++++++ .../gloom/ui/screen/explore/ExploreScreen.kt | 76 ++++++- .../explore/component/TrendingFeedFooter.kt | 63 ++++++ .../explore/component/TrendingFeedHeader.kt | 50 +++++ .../explore/component/TrendingRepoItem.kt | 192 ++++++++++++++++++ .../explore/viewmodel/ExploreViewModel.kt | 107 ++++++++++ .../gloom/ui/util/NumberFormatter.kt | 19 ++ .../moko-resources/base/strings.xml | 12 ++ .../gloom/ui/util/NumberFormatter.desktop.kt | 11 + 16 files changed, 696 insertions(+), 5 deletions(-) create mode 100644 api/src/commonMain/graphql/com/materiiapps/gloom/gql/fragments/TrendingRepository.fragment.graphql create mode 100644 api/src/commonMain/graphql/com/materiiapps/gloom/gql/queries/Trending.query.graphql create mode 100644 ui/src/androidMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.android.kt create mode 100644 ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/component/filter/ChoiceInputChip.kt create mode 100644 ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingFeedFooter.kt create mode 100644 ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingFeedHeader.kt create mode 100644 ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingRepoItem.kt create mode 100644 ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/viewmodel/ExploreViewModel.kt create mode 100644 ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.kt create mode 100644 ui/src/desktopMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.desktop.kt diff --git a/api/src/commonMain/graphql/com/materiiapps/gloom/gql/fragments/TrendingRepository.fragment.graphql b/api/src/commonMain/graphql/com/materiiapps/gloom/gql/fragments/TrendingRepository.fragment.graphql new file mode 100644 index 0000000..049e867 --- /dev/null +++ b/api/src/commonMain/graphql/com/materiiapps/gloom/gql/fragments/TrendingRepository.fragment.graphql @@ -0,0 +1,20 @@ +fragment TrendingRepository on Repository { + id + name + description + openGraphImageUrl + usesCustomOpenGraphImage + starsSince(period: $period) + contributorsCount + viewerHasStarred + stargazerCount + primaryLanguage { + color + name + } + owner { + __typename + avatarUrl + login + } +} \ No newline at end of file diff --git a/api/src/commonMain/graphql/com/materiiapps/gloom/gql/queries/Trending.query.graphql b/api/src/commonMain/graphql/com/materiiapps/gloom/gql/queries/Trending.query.graphql new file mode 100644 index 0000000..2e2744c --- /dev/null +++ b/api/src/commonMain/graphql/com/materiiapps/gloom/gql/queries/Trending.query.graphql @@ -0,0 +1,5 @@ +query Trending($period: TrendingPeriod!) { + trendingRepositories(period: $period, mobileSortOrder: true) { + ...TrendingRepository + } +} \ No newline at end of file diff --git a/api/src/commonMain/kotlin/com/materiiapps/gloom/api/repository/GraphQLRepository.kt b/api/src/commonMain/kotlin/com/materiiapps/gloom/api/repository/GraphQLRepository.kt index 384a215..182fd34 100644 --- a/api/src/commonMain/kotlin/com/materiiapps/gloom/api/repository/GraphQLRepository.kt +++ b/api/src/commonMain/kotlin/com/materiiapps/gloom/api/repository/GraphQLRepository.kt @@ -6,6 +6,7 @@ import com.materiiapps.gloom.api.util.transform import com.materiiapps.gloom.gql.type.IssueState import com.materiiapps.gloom.gql.type.PullRequestState import com.materiiapps.gloom.gql.type.ReactionContent +import com.materiiapps.gloom.gql.type.TrendingPeriod class GraphQLRepository( private val service: GraphQLService @@ -70,6 +71,10 @@ class GraphQLRepository( suspend fun getFeed(cursor: String? = null) = service.getFeed(cursor) + suspend fun getTrending(period: TrendingPeriod = TrendingPeriod.DAILY) = service.getTrending(period).transform { + it.trendingRepositories?.filterNotNull()?.map { it.trendingRepository } ?: emptyList() + } + suspend fun starRepo(id: String) = service.starRepo(id).transform { (it.addStar?.starrable?.viewerHasStarred ?: false) to (it.addStar?.starrable?.stargazers?.totalCount ?: 0) diff --git a/api/src/commonMain/kotlin/com/materiiapps/gloom/api/service/GraphQLService.kt b/api/src/commonMain/kotlin/com/materiiapps/gloom/api/service/GraphQLService.kt index fb7f5ff..2ef7a03 100644 --- a/api/src/commonMain/kotlin/com/materiiapps/gloom/api/service/GraphQLService.kt +++ b/api/src/commonMain/kotlin/com/materiiapps/gloom/api/service/GraphQLService.kt @@ -32,6 +32,7 @@ import com.materiiapps.gloom.gql.RepoReleasesQuery import com.materiiapps.gloom.gql.SponsoringQuery import com.materiiapps.gloom.gql.StarRepoMutation import com.materiiapps.gloom.gql.StarredReposQuery +import com.materiiapps.gloom.gql.TrendingQuery import com.materiiapps.gloom.gql.UnfollowUserMutation import com.materiiapps.gloom.gql.UnreactMutation import com.materiiapps.gloom.gql.UnstarRepoMutation @@ -39,6 +40,7 @@ import com.materiiapps.gloom.gql.UserProfileQuery import com.materiiapps.gloom.gql.type.IssueState import com.materiiapps.gloom.gql.type.PullRequestState import com.materiiapps.gloom.gql.type.ReactionContent +import com.materiiapps.gloom.gql.type.TrendingPeriod import io.ktor.http.HttpHeaders import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -188,6 +190,13 @@ class GraphQLService( .response() } + suspend fun getTrending(period: TrendingPeriod = TrendingPeriod.DAILY) = withContext(Dispatchers.IO) { + client + .query(TrendingQuery(period)) + .addToken() + .response() + } + suspend fun starRepo(id: String) = withContext(Dispatchers.IO) { client.mutation(StarRepoMutation(id)) .addToken() diff --git a/shared/src/commonMain/kotlin/com/materiiapps/gloom/domain/manager/PreferenceManager.kt b/shared/src/commonMain/kotlin/com/materiiapps/gloom/domain/manager/PreferenceManager.kt index e5051db..c07f482 100644 --- a/shared/src/commonMain/kotlin/com/materiiapps/gloom/domain/manager/PreferenceManager.kt +++ b/shared/src/commonMain/kotlin/com/materiiapps/gloom/domain/manager/PreferenceManager.kt @@ -19,6 +19,8 @@ class PreferenceManager(provider: SettingsProvider) : var orgAvatarShape by enumPreference("org_avatar_shape", AvatarShape.RoundedCorner) var orgAvatarRadius by intPreference("org_avatar_radius", 31) + var trendingPeriod by enumPreference("trending_period", Defaults.TRENDING_PERIOD) + init { if (userAvatarRadius > 50) userAvatarRadius = 50 if (userAvatarRadius < 0) userAvatarRadius = 0 @@ -26,6 +28,12 @@ class PreferenceManager(provider: SettingsProvider) : if (orgAvatarRadius < 0) orgAvatarRadius = 0 } + companion object Defaults { + + val TRENDING_PERIOD = TrendingPeriodPreference.DAILY + + } + } enum class Theme { @@ -38,4 +46,10 @@ enum class AvatarShape { Circle, RoundedCorner, Squircle +} + +enum class TrendingPeriodPreference { + DAILY, + WEEKLY, + MONTHLY } \ No newline at end of file diff --git a/ui/src/androidMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.android.kt b/ui/src/androidMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.android.kt new file mode 100644 index 0000000..749f85c --- /dev/null +++ b/ui/src/androidMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.android.kt @@ -0,0 +1,35 @@ +package com.materiiapps.gloom.ui.util + +import android.icu.number.Notation +import android.icu.text.CompactDecimalFormat +import android.os.Build +import java.text.DecimalFormat +import java.util.Locale +import android.icu.number.NumberFormatter as AndroidNumberFormatter + +actual object NumberFormatter { + + actual fun compact(count: Int): String { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { + AndroidNumberFormatter + .withLocale(Locale.getDefault()) + .notation(Notation.compactShort()) + .format(count) + .toString() + } + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> { + CompactDecimalFormat + .getInstance( + Locale.getDefault(), + CompactDecimalFormat.CompactStyle.SHORT + ) + .format(count) + } + + else -> DecimalFormat().format(count) + } + } + +} \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/com/materiiapps/gloom/di/module/ViewModelModule.kt b/ui/src/commonMain/kotlin/com/materiiapps/gloom/di/module/ViewModelModule.kt index 1a8ef43..c1bfa0d 100644 --- a/ui/src/commonMain/kotlin/com/materiiapps/gloom/di/module/ViewModelModule.kt +++ b/ui/src/commonMain/kotlin/com/materiiapps/gloom/di/module/ViewModelModule.kt @@ -1,6 +1,7 @@ package com.materiiapps.gloom.di.module import com.materiiapps.gloom.ui.screen.auth.viewmodel.LandingViewModel +import com.materiiapps.gloom.ui.screen.explore.viewmodel.ExploreViewModel import com.materiiapps.gloom.ui.screen.explorer.viewmodel.DirectoryListingViewModel import com.materiiapps.gloom.ui.screen.explorer.viewmodel.FileViewerViewModel import com.materiiapps.gloom.ui.screen.home.viewmodel.HomeViewModel @@ -12,12 +13,12 @@ import com.materiiapps.gloom.ui.screen.profile.viewmodel.FollowersViewModel import com.materiiapps.gloom.ui.screen.profile.viewmodel.FollowingViewModel import com.materiiapps.gloom.ui.screen.profile.viewmodel.ProfileViewModel import com.materiiapps.gloom.ui.screen.repo.viewmodel.LicenseViewModel -import com.materiiapps.gloom.ui.screen.repo.viewmodel.RepoViewModel import com.materiiapps.gloom.ui.screen.repo.viewmodel.RepoCodeViewModel import com.materiiapps.gloom.ui.screen.repo.viewmodel.RepoDetailsViewModel import com.materiiapps.gloom.ui.screen.repo.viewmodel.RepoIssuesViewModel import com.materiiapps.gloom.ui.screen.repo.viewmodel.RepoPullRequestsViewModel import com.materiiapps.gloom.ui.screen.repo.viewmodel.RepoReleasesViewModel +import com.materiiapps.gloom.ui.screen.repo.viewmodel.RepoViewModel import com.materiiapps.gloom.ui.screen.settings.viewmodel.AccountSettingsViewModel import com.materiiapps.gloom.ui.screen.settings.viewmodel.AppearanceSettingsViewModel import com.materiiapps.gloom.ui.screen.settings.viewmodel.SettingsViewModel @@ -38,6 +39,7 @@ fun viewModelModule() = module { factoryOf(::AppearanceSettingsViewModel) factoryOf(::AccountSettingsViewModel) factoryOf(::HomeViewModel) + factoryOf(::ExploreViewModel) factoryOf(::RepoViewModel) factoryOf(::RepoDetailsViewModel) diff --git a/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/component/filter/ChoiceInputChip.kt b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/component/filter/ChoiceInputChip.kt new file mode 100644 index 0000000..432be15 --- /dev/null +++ b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/component/filter/ChoiceInputChip.kt @@ -0,0 +1,79 @@ +package com.materiiapps.gloom.ui.component.filter + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import kotlin.enums.enumEntries + +/** + * An [InputChip] used to make a single selection from a set + * of options using an enum. + * + * Clicking the chip will open a dropdown containing all the entries + * contained in the provided enum [E], once the user selects an item [onChoiceSelected] + * will be called and the dropdown dismissed. + * + * @param E The enum used to supply the choices + * + * @param defaultValue The default value, if the current choice differs from this value then + * the chip will be marked as selected + * @param currentValue The currently chosen item + * @param onChoiceSelected Called when the user makes their choice + * @param modifier The [Modifier] for this chip + * @param label Factory function used to get a localized label for the enum entry, defaults to the enum entries name. + * It is not reccommended to override the text style or add anything more than a [Text] component. + */ +@Composable +inline fun > ChoiceInputChip( + defaultValue: E, + currentValue: E, + crossinline onChoiceSelected: (E) -> Unit, + modifier: Modifier = Modifier, + crossinline label: @Composable (E) -> Unit = { Text(it.name) } +) { + var dropdownVisible by remember { mutableStateOf(false) } + + InputChip( + onClick = { dropdownVisible = true }, + selected = currentValue != defaultValue, + label = { label(currentValue) }, + trailingIcon = { + Icon( + imageVector = Icons.Outlined.ExpandMore, + contentDescription = null + ) + }, + modifier = modifier + ) + + Box { + DropdownMenu( + expanded = dropdownVisible, + onDismissRequest = { dropdownVisible = false }, + offset = DpOffset(0.dp, 24.dp) + ) { + enumEntries().forEach { entry -> + DropdownMenuItem( + text = { label(entry) }, + onClick = { + dropdownVisible = false + if (currentValue != entry) onChoiceSelected(entry) + } + ) + } + } + } +} \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/ExploreScreen.kt b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/ExploreScreen.kt index e20c182..ba76714 100644 --- a/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/ExploreScreen.kt +++ b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/ExploreScreen.kt @@ -1,5 +1,9 @@ package com.materiiapps.gloom.ui.screen.explore +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.outlined.Explore @@ -7,15 +11,31 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import cafe.adriel.voyager.koin.getScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.LocalTabNavigator import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.TabOptions import com.materiiapps.gloom.Res +import com.materiiapps.gloom.ui.screen.explore.component.TrendingFeedFooter +import com.materiiapps.gloom.ui.screen.explore.component.TrendingFeedHeader +import com.materiiapps.gloom.ui.screen.explore.component.TrendingRepoItem +import com.materiiapps.gloom.ui.screen.explore.viewmodel.ExploreViewModel +import com.materiiapps.gloom.ui.screen.profile.ProfileScreen +import com.materiiapps.gloom.ui.screen.repo.RepoScreen +import com.materiiapps.gloom.ui.util.navigate import dev.icerock.moko.resources.compose.stringResource class ExploreScreen : Tab { + override val options: TabOptions @Composable get() { val navigator = LocalTabNavigator.current @@ -31,21 +51,69 @@ class ExploreScreen : Tab { override fun Content() = Screen() @Composable + @OptIn(ExperimentalMaterial3Api::class) private fun Screen() { + val viewModel: ExploreViewModel = getScreenModel() + val navigator = LocalNavigator.currentOrThrow + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Scaffold( - topBar = { TopBar() } - ) { + topBar = { TopBar(scrollBehavior) } + ) { pv -> + PullToRefreshBox( + isRefreshing = viewModel.isLoading, + onRefresh = { viewModel.loadTrending() }, + modifier = Modifier + .padding(pv) + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + item("header") { + TrendingFeedHeader( + currentTrendingPeriod = viewModel.trendingPeriod, + onTrendingPeriodChange = { newPeriod -> viewModel.updateTrendingPeriod(newPeriod) } + ) + } + + items( + viewModel.trendingRepos, + key = { it.id } + ) { trendingRepo -> + TrendingRepoItem( + trendingRepository = trendingRepo, + trendingPeriod = viewModel.trendingPeriod, + starToggleEnabled = !viewModel.repoStarsPending.contains(trendingRepo.id), + onClick = { navigator.navigate(RepoScreen(trendingRepo.owner.login, trendingRepo.name)) }, + onOwnerClick = { navigator.navigate(ProfileScreen(trendingRepo.owner.login)) }, + onStarClick = { viewModel.starRepo(trendingRepo.id) }, + onUnstarClick = { viewModel.unstarRepo(trendingRepo.id) } + ) + } + if (viewModel.trendingRepos.isNotEmpty()) { + item("footer") { + TrendingFeedFooter() + } + } + } + } } } @Composable @OptIn(ExperimentalMaterial3Api::class) - private fun TopBar() { + private fun TopBar( + scrollBehavior: TopAppBarScrollBehavior + ) { LargeTopAppBar( title = { Text(text = options.title) - } + }, + scrollBehavior = scrollBehavior ) } + } \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingFeedFooter.kt b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingFeedFooter.kt new file mode 100644 index 0000000..603b1b5 --- /dev/null +++ b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingFeedFooter.kt @@ -0,0 +1,63 @@ +package com.materiiapps.gloom.ui.screen.explore.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.TravelExplore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.materiiapps.gloom.Res +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun TrendingFeedFooter( + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .padding(top = 16.dp) + .shadow(12.dp) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(32.dp) + ) { + Icon( + imageVector = Icons.Outlined.TravelExplore, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(45.dp) + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp) + ) { + Text( + text = stringResource(Res.strings.msg_trending_footer_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.strings.msg_trending_footer_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + } + } +} \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingFeedHeader.kt b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingFeedHeader.kt new file mode 100644 index 0000000..e140846 --- /dev/null +++ b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingFeedHeader.kt @@ -0,0 +1,50 @@ +package com.materiiapps.gloom.ui.screen.explore.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.materiiapps.gloom.Res +import com.materiiapps.gloom.domain.manager.PreferenceManager +import com.materiiapps.gloom.domain.manager.TrendingPeriodPreference +import com.materiiapps.gloom.ui.component.filter.ChoiceInputChip +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun TrendingFeedHeader( + currentTrendingPeriod: TrendingPeriodPreference, + onTrendingPeriodChange: (TrendingPeriodPreference) -> Unit, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(Res.strings.noun_trending), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + + ChoiceInputChip( + defaultValue = PreferenceManager.TRENDING_PERIOD, + currentValue = currentTrendingPeriod, + onChoiceSelected = onTrendingPeriodChange, + label = { period -> + Text( + stringResource( + when (period) { + TrendingPeriodPreference.MONTHLY -> Res.strings.label_trending_monthly + TrendingPeriodPreference.WEEKLY -> Res.strings.label_trending_weekly + TrendingPeriodPreference.DAILY -> Res.strings.label_trending_daily + } + ) + ) + } + ) + } +} \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingRepoItem.kt b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingRepoItem.kt new file mode 100644 index 0000000..4e6161f --- /dev/null +++ b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/component/TrendingRepoItem.kt @@ -0,0 +1,192 @@ +package com.materiiapps.gloom.ui.screen.explore.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.StarBorder +import androidx.compose.material.icons.outlined.StarOutline +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalIconToggleButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import com.materiiapps.gloom.Res +import com.materiiapps.gloom.api.dto.user.User +import com.materiiapps.gloom.domain.manager.TrendingPeriodPreference +import com.materiiapps.gloom.gql.fragment.TrendingRepository +import com.materiiapps.gloom.ui.component.Avatar +import com.materiiapps.gloom.ui.theme.colors +import com.materiiapps.gloom.ui.util.NumberFormatter +import com.materiiapps.gloom.ui.util.parsedColor +import com.seiko.imageloader.rememberImagePainter +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun TrendingRepoItem( + trendingRepository: TrendingRepository, + trendingPeriod: TrendingPeriodPreference, + starToggleEnabled: Boolean, + onClick: () -> Unit, + onOwnerClick: () -> Unit, + onStarClick: () -> Unit, + onUnstarClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + ElevatedCard( + onClick = onClick + ) { + if (trendingRepository.usesCustomOpenGraphImage) { + Image( + painter = rememberImagePainter(trendingRepository.openGraphImageUrl), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier + .aspectRatio(2f) + .fillMaxWidth() + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar( + url = trendingRepository.owner.avatarUrl, + contentDescription = null, + type = User.Type.fromTypeName(trendingRepository.owner.__typename), + modifier = Modifier + .clickable(onClick = onOwnerClick) + .size(44.dp) + ) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = trendingRepository.owner.login, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.clickable(onClick = onOwnerClick) + ) + + Text( + text = trendingRepository.name, + style = MaterialTheme.typography.titleMedium + ) + } + + FilledTonalIconToggleButton( + checked = trendingRepository.viewerHasStarred, + onCheckedChange = { shouldStar -> + if (shouldStar) onStarClick() else onUnstarClick() + }, + colors = IconButtonDefaults.filledTonalIconToggleButtonColors( + checkedContentColor = MaterialTheme.colors.statusYellow, + checkedContainerColor = MaterialTheme.colors.statusYellow.copy(alpha = 0.2f) + ), + enabled = starToggleEnabled + ) { + Icon( + imageVector = if (trendingRepository.viewerHasStarred) Icons.Filled.Star else Icons.Outlined.StarOutline, + contentDescription = stringResource(if (trendingRepository.viewerHasStarred) Res.strings.action_unstar else Res.strings.action_star), + ) + } + } + + trendingRepository.description?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current.copy(alpha = 0.8f) + ) + } + + LabeledIcon( + icon = Icons.Filled.Star, + iconTint = MaterialTheme.colors.statusYellow, + label = stringResource( + when (trendingPeriod) { + TrendingPeriodPreference.MONTHLY -> Res.strings.label_stars_month + TrendingPeriodPreference.WEEKLY -> Res.strings.label_stars_week + TrendingPeriodPreference.DAILY -> Res.strings.label_stars_day + }, + NumberFormatter.compact(trendingRepository.starsSince) + ) + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + LabeledIcon( + icon = Icons.Outlined.StarBorder, + label = NumberFormatter.compact(trendingRepository.stargazerCount) + ) + + trendingRepository.primaryLanguage?.let { (color, name) -> + LabeledIcon( + icon = Icons.Filled.Circle, + iconTint = color?.parsedColor ?: Color.Black, + label = name + ) + } + } + } + } + } +} + +// TODO: Use everywhere +@Composable +private fun LabeledIcon( + icon: ImageVector, + iconTint: Color = LocalContentColor.current, + label: String +) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = iconTint + ) + + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = LocalContentColor.current.copy(alpha = 0.6f) + ) + } +} \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/viewmodel/ExploreViewModel.kt b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/viewmodel/ExploreViewModel.kt new file mode 100644 index 0000000..358b2f6 --- /dev/null +++ b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/screen/explore/viewmodel/ExploreViewModel.kt @@ -0,0 +1,107 @@ +package com.materiiapps.gloom.ui.screen.explore.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.materiiapps.gloom.api.repository.GraphQLRepository +import com.materiiapps.gloom.api.util.fold +import com.materiiapps.gloom.api.util.ifSuccessful +import com.materiiapps.gloom.domain.manager.PreferenceManager +import com.materiiapps.gloom.domain.manager.TrendingPeriodPreference +import com.materiiapps.gloom.gql.fragment.TrendingRepository +import com.materiiapps.gloom.gql.type.TrendingPeriod +import kotlinx.coroutines.launch + +class ExploreViewModel( + private val graphQLRepository: GraphQLRepository, + private val preferenceManager: PreferenceManager +): ScreenModel { + + val trendingRepos = mutableStateListOf() + + val repoStarsPending = mutableStateListOf() + + var isLoading by mutableStateOf(false) + private set + + var trendingPeriod by mutableStateOf(preferenceManager.trendingPeriod) + private set + + init { + loadTrending() + } + + // PUBLIC + + fun loadTrending() { + screenModelScope.launch { + isLoading = true + graphQLRepository.getTrending(trendingPeriod.toApi()).ifSuccessful { trending -> + trendingRepos.clear() + trendingRepos.addAll(trending) + } + isLoading = false + } + } + + fun updateTrendingPeriod(newPeriod: TrendingPeriodPreference) { + trendingPeriod = newPeriod + preferenceManager.trendingPeriod = newPeriod + trendingRepos.clear() + loadTrending() + } + + fun starRepo(id: String) { + screenModelScope.launch { + repoStarsPending.add(id) + editRepo(id) { it.copy(viewerHasStarred = true)} + graphQLRepository.starRepo(id).fold( + onSuccess = { _, _ -> }, + onError = { + editRepo(id) { it.copy(viewerHasStarred = false)} + }, + onFailure = { + editRepo(id) { it.copy(viewerHasStarred = false)} + } + ) + repoStarsPending.remove(id) + } + } + + fun unstarRepo(id: String) { + screenModelScope.launch { + repoStarsPending.add(id) + editRepo(id) { it.copy(viewerHasStarred = false)} + graphQLRepository.unstarRepo(id).fold( + onSuccess = { _, _ -> }, + onError = { + editRepo(id) { it.copy(viewerHasStarred = true)} + }, + onFailure = { + editRepo(id) { it.copy(viewerHasStarred = true)} + } + ) + repoStarsPending.remove(id) + } + } + + // PRIVATE + + private fun editRepo(id: String, block: (TrendingRepository) -> TrendingRepository) { + val targetIndex = trendingRepos.indexOfFirst { it.id == id } + val target = trendingRepos[targetIndex] + trendingRepos[targetIndex] = block(target) + } + + private fun TrendingPeriodPreference.toApi(): TrendingPeriod { + return when (this) { + TrendingPeriodPreference.MONTHLY -> TrendingPeriod.MONTHLY + TrendingPeriodPreference.WEEKLY -> TrendingPeriod.WEEKLY + TrendingPeriodPreference.DAILY -> TrendingPeriod.DAILY + } + } + +} \ No newline at end of file diff --git a/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.kt b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.kt new file mode 100644 index 0000000..2979ff7 --- /dev/null +++ b/ui/src/commonMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.kt @@ -0,0 +1,19 @@ +package com.materiiapps.gloom.ui.util + +expect object NumberFormatter { + + /** + * Formats the given [count] into a localized compact format + * + * Ex. + * + * 1102 -> 1.1K + * + * 12352210 -> 12.4M + * + * @param count The number to make compact + * @return A compact version of a number + */ + fun compact(count: Int): String + +} \ No newline at end of file diff --git a/ui/src/commonMain/moko-resources/base/strings.xml b/ui/src/commonMain/moko-resources/base/strings.xml index 1580f0d..19e564b 100644 --- a/ui/src/commonMain/moko-resources/base/strings.xml +++ b/ui/src/commonMain/moko-resources/base/strings.xml @@ -71,6 +71,7 @@ More Less License + Trending Forked from %s §name§%1$s§ §text§created a repository§ @@ -178,6 +179,9 @@ Downloading %1$s... Download completed + You\'ve reached the end! + Check back later to find new trending repositories + Checks failed Checks Reviews @@ -201,6 +205,14 @@ Archived by owner + %1$s today + %1$s this week + %1$s this month + + Today + This week + This month + Now %1$ds %1$dm diff --git a/ui/src/desktopMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.desktop.kt b/ui/src/desktopMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.desktop.kt new file mode 100644 index 0000000..c8b406f --- /dev/null +++ b/ui/src/desktopMain/kotlin/com/materiiapps/gloom/ui/util/NumberFormatter.desktop.kt @@ -0,0 +1,11 @@ +package com.materiiapps.gloom.ui.util + +import java.text.NumberFormat + +actual object NumberFormatter { + + actual fun compact(count: Int): String { + return NumberFormat.getCompactNumberInstance().format(count) + } + +} \ No newline at end of file