-
Notifications
You must be signed in to change notification settings - Fork 0
Feature: Add OrganizationsScreen to Profile #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 -> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is highly recommended to provide a unique key for items in a
Suggested change
|
||||||
| 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, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
| 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 | ||||||
| ) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<OrgSummary>) : OrganizationsUiState() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Creating a new |
||
| private val _uiState = MutableStateFlow<OrganizationsUiState>(OrganizationsUiState.Loading) | ||
| val uiState: StateFlow<OrganizationsUiState> = _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() | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instantiating the
ViewModeldirectly inside thecomposableblock is problematic. This causes a new instance to be created on every recomposition of the navigation destination. Furthermore, because this instance is not managed by aViewModelStore, itsonCleared()method will never be called, leading to resource leaks (e.g., theapiClientand itsHttpClientwill remain open). You should use a proper ViewModel provider likeviewModel()from theandroidx.lifecycle:lifecycle-viewmodel-composelibrary to ensure the instance is correctly scoped to the backstack entry and its lifecycle is managed.