diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f99f5d3..f6e838a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -166,6 +166,7 @@ Repositories Organizations + No organizations found Starred Projects diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/App.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/App.kt index 8ef94b0..2642f28 100755 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/App.kt @@ -10,6 +10,8 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import dev.therealashik.github.profile.ProfileScreen import dev.therealashik.github.profile.ProfileViewModel +import dev.therealashik.github.profile.OrganizationsScreen +import dev.therealashik.github.profile.OrganizationsViewModel import dev.therealashik.github.profile.StarredScreen import dev.therealashik.github.profile.StarredViewModel import dev.therealashik.github.repository.RepositoryListScreen @@ -48,7 +50,14 @@ fun App() { onBack = { navController.popBackStack() }, onNavigateToRepositories = { navController.navigate(Route.Repositories) }, onNavigateToStarred = { navController.navigate(Route.Starred) }, - onNavigateToSettings = { navController.navigate(Route.Settings) } + onNavigateToSettings = { navController.navigate(Route.Settings) }, + onNavigateToOrganizations = { navController.navigate(Route.Organizations) } + ) + } + composable { + OrganizationsScreen( + viewModel = OrganizationsViewModel(), + onBack = { navController.popBackStack() } ) } composable { diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/NavRoutes.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/NavRoutes.kt index a77eef1..ea97f2d 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/NavRoutes.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/NavRoutes.kt @@ -11,5 +11,6 @@ sealed interface Route { @Serializable data object NotificationOptions : Route @Serializable data object CodeOptions : Route @Serializable data object AddPat : Route + @Serializable data object Organizations : Route @Serializable data object Starred : Route } diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/OrganizationsScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/OrganizationsScreen.kt new file mode 100644 index 0000000..7cdbee4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/OrganizationsScreen.kt @@ -0,0 +1,155 @@ +package dev.therealashik.github.profile + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import coil3.compose.AsyncImage +import dev.therealashik.github.Dimens +import github.composeapp.generated.resources.Res +import github.composeapp.generated.resources.content_description_back +import github.composeapp.generated.resources.nav_organizations +import github.composeapp.generated.resources.orgs_empty_state +import github.composeapp.generated.resources.retry +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OrganizationsScreen( + viewModel: OrganizationsViewModel, + onBack: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.nav_organizations)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.content_description_back) + ) + } + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when (val state = uiState) { + is OrganizationsUiState.Loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + is OrganizationsUiState.Error -> { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(Dimens.SpacingMedium)) + Button(onClick = { viewModel.loadData() }) { + Text(stringResource(Res.string.retry)) + } + } + } + is OrganizationsUiState.Success -> { + if (state.orgs.isEmpty()) { + Text( + text = stringResource(Res.string.orgs_empty_state), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.align(Alignment.Center) + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(Dimens.SpacingMedium), + verticalArrangement = Arrangement.spacedBy(Dimens.SpacingSmall) + ) { + items(state.orgs) { org -> + OrgItem(org = org) + } + } + } + } + } + } + } +} + +@Composable +private fun OrgItem(org: OrgSummary) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(Dimens.SpacingMedium), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = org.avatarUrl, + contentDescription = null, + modifier = Modifier + .size(Dimens.IconSizeHuge) + .clip(CircleShape) + ) + Spacer(modifier = Modifier.width(Dimens.SpacingMedium)) + Column { + Text( + text = org.login, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (!org.description.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(Dimens.SpacingExtraSmall)) + Text( + text = org.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/OrganizationsUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/OrganizationsUiState.kt new file mode 100644 index 0000000..354162d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/OrganizationsUiState.kt @@ -0,0 +1,7 @@ +package dev.therealashik.github.profile + +sealed class OrganizationsUiState { + data object Loading : OrganizationsUiState() + data class Error(val message: String) : OrganizationsUiState() + data class Success(val orgs: List) : OrganizationsUiState() +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/OrganizationsViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/OrganizationsViewModel.kt new file mode 100644 index 0000000..ffe1779 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/OrganizationsViewModel.kt @@ -0,0 +1,47 @@ +package dev.therealashik.github.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.therealashik.github.data.GitHubApiClient +import dev.therealashik.github.data.createTokenStorage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class OrganizationsViewModel : ViewModel() { + private val apiClient = GitHubApiClient(createTokenStorage()) + private val _uiState = MutableStateFlow(OrganizationsUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadData() + } + + fun loadData() { + viewModelScope.launch { + _uiState.value = OrganizationsUiState.Loading + apiClient.getUserOrgs() + .onSuccess { orgs -> + _uiState.value = OrganizationsUiState.Success( + orgs.map { o -> + OrgSummary( + id = o.id, + login = o.login, + avatarUrl = o.avatarUrl, + description = o.description + ) + } + ) + } + .onFailure { error -> + _uiState.value = OrganizationsUiState.Error(error.message ?: "Unknown error") + } + } + } + + override fun onCleared() { + super.onCleared() + apiClient.close() + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileScreen.kt index cef6fdc..a2a60ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileScreen.kt @@ -35,7 +35,8 @@ fun ProfileScreen( onBack: () -> Unit, onNavigateToRepositories: () -> Unit, onNavigateToStarred: () -> Unit, - onNavigateToSettings: () -> Unit = {} + onNavigateToSettings: () -> Unit, + onNavigateToOrganizations: () -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -107,7 +108,8 @@ fun ProfileScreen( NavigationListSection( state = state, onNavigateToRepositories = onNavigateToRepositories, - onNavigateToStarred = onNavigateToStarred + onNavigateToStarred = onNavigateToStarred, + onNavigateToOrganizations = onNavigateToOrganizations ) } } @@ -352,7 +354,8 @@ private fun PopularReposSection(popularRepos: List) { private fun NavigationListSection( state: ProfileUiState.Success, onNavigateToRepositories: () -> Unit, - onNavigateToStarred: () -> Unit + onNavigateToStarred: () -> Unit, + onNavigateToOrganizations: () -> Unit ) { Column { NavigationItem( @@ -393,7 +396,7 @@ private fun NavigationListSection( }, label = stringResource(Res.string.nav_organizations), count = state.orgs.size, - onClick = { /* TODO */ } + onClick = onNavigateToOrganizations ) NavigationItem( icon = { diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileUiState.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileUiState.kt index 22d1fed..fdf3088 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileUiState.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileUiState.kt @@ -34,5 +34,6 @@ data class PopularRepo( data class OrgSummary( val id: Long, val login: String, - val avatarUrl: String + val avatarUrl: String, + val description: String? = null ) diff --git a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileViewModel.kt b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileViewModel.kt index b58b3fc..4ecbef4 100644 --- a/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/therealashik/github/profile/ProfileViewModel.kt @@ -63,7 +63,7 @@ class ProfileViewModel : ViewModel() { language = repo.language ) }, - orgs = orgs.map { OrgSummary(it.id, it.login, it.avatarUrl) }, + orgs = orgs.map { OrgSummary(it.id, it.login, it.avatarUrl, it.description) }, starredCount = starred.size, statusEmoji = status?.emoji, statusMessage = status?.message