← Назад к вопросам

GitHub репозитории организации

2.0 Middle🔥 201 комментариев
#Архитектура и паттерны#Работа с данными#Сетевое взаимодействие

Условие

Создать приложение для отображения списка репозиториев организации на GitHub.

Функциональность:

  1. Отображение списка репозиториев организации через GitHub API
  2. Поиск репозиториев по названию
  3. Информация о репозитории:
    • Название
    • Описание
    • Количество звёзд
    • Количество форков
    • Язык программирования
  4. Детальный экран репозитория
  5. Пагинация для больших списков

Технические требования:

  • Kotlin
  • Retrofit для работы с GitHub REST API
  • OAuth аутентификация (опционально)
  • MVVM архитектура
  • Coroutines или RxJava
  • Пагинация (Paging 3)

Оценка:

  • Обработка rate limiting GitHub API
  • Качество поиска
  • Кэширование данных
  • UI/UX

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение: GitHub репозитории организации с пагинацией и поиском

Архитектура Clean Architecture + MVVM с Paging 3 и обработкой rate limiting

Приложение использует GitHub REST API, поддерживает пагинацию через Paging 3, обработку rate limiting и кэширование с помощью Room.

Структура проекта

com.example.githubrepos/
├── data/
│   ├── remote/
│   │   ├── api/GitHubApi.kt
│   │   ├── dto/RepositoryDto.kt
│   │   ├── interceptor/RateLimitInterceptor.kt
│   │   └── auth/GitHubAuthManager.kt
│   ├── local/
│   │   ├── db/AppDatabase.kt
│   │   ├── dao/RepositoryDao.kt
│   │   ├── entity/RepositoryEntity.kt
│   │   └── entity/RemoteKeyEntity.kt
│   └── repository/RepositoryRepositoryImpl.kt
├── domain/
│   ├── model/Repository.kt
│   ├── repository/RepositoryRepository.kt
│   └── usecase/
│       ├── GetRepositoriesUseCase.kt
│       ├── SearchRepositoriesUseCase.kt
│       └── GetRepositoryDetailsUseCase.kt
├── presentation/
│   ├── ui/
│   │   ├── screen/
│   │   │   ├── RepositoriesScreen.kt
│   │   │   ├── RepositoryDetailScreen.kt
│   │   │   └── MainActivity.kt
│   │   └── component/
│   │       ├── RepositoryCard.kt
│   │       ├── SearchBar.kt
│   │       ├── PaginationLoader.kt
│   │       └── RateLimitIndicator.kt
│   └── viewmodel/
│       ├── RepositoriesViewModel.kt
│       └── RepositoryDetailViewModel.kt
├── paging/RepositoryPagingSource.kt
└── di/AppModule.kt

1. Domain Layer

// domain/model/Repository.kt
data class Repository(
    val id: Long,
    val name: String,
    val description: String?,
    val url: String,
    val stars: Int,
    val forks: Int,
    val language: String?,
    val visibility: String,
    val updatedAt: String,
    val owner: Owner
)

data class Owner(
    val login: String,
    val avatarUrl: String
)

data class RateLimit(
    val limit: Int,
    val remaining: Int,
    val reset: Long
)

// domain/repository/RepositoryRepository.kt
interface RepositoryRepository {
    fun getRepositoriesPaged(
        organization: String
    ): Flow<PagingData<Repository>>

    fun searchRepositories(
        organization: String,
        query: String
    ): Flow<PagingData<Repository>>

    suspend fun getRepositoryDetails(owner: String, name: String): Result<Repository>
    suspend fun getRateLimitStatus(): Result<RateLimit>
}

2. Data Layer

// data/remote/api/GitHubApi.kt
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Path
import retrofit2.http.Query

interface GitHubApi {
    @GET("orgs/{org}/repos")
    @Headers(
        "Accept: application/vnd.github.v3+json",
        "X-GitHub-Api-Version: 2022-11-28"
    )
    suspend fun getOrganizationRepositories(
        @Path("org") organization: String,
        @Query("page") page: Int = 1,
        @Query("per_page") perPage: Int = 30,
        @Query("sort") sort: String = "updated",
        @Query("direction") direction: String = "desc"
    ): List<RepositoryDto>

    @GET("search/repositories")
    @Headers(
        "Accept: application/vnd.github.v3+json",
        "X-GitHub-Api-Version: 2022-11-28"
    )
    suspend fun searchRepositories(
        @Query("q") query: String,
        @Query("page") page: Int = 1,
        @Query("per_page") perPage: Int = 30
    ): SearchResponse

    @GET("repos/{owner}/{repo}")
    suspend fun getRepositoryDetails(
        @Path("owner") owner: String,
        @Path("repo") repo: String
    ): RepositoryDto

    @GET("rate_limit")
    suspend fun getRateLimit(): RateLimitResponse
}

data class RepositoryDto(
    val id: Long,
    val name: String,
    val description: String?,
    val html_url: String,
    val stargazers_count: Int,
    val forks_count: Int,
    val language: String?,
    val visibility: String,
    val updated_at: String,
    val owner: OwnerDto
)

data class OwnerDto(
    val login: String,
    val avatar_url: String
)

data class SearchResponse(
    val items: List<RepositoryDto>,
    val total_count: Int
)

data class RateLimitResponse(
    val resources: Resources
)

data class Resources(
    val core: RateLimitData
)

data class RateLimitData(
    val limit: Int,
    val remaining: Int,
    val reset: Long
)
// data/remote/interceptor/RateLimitInterceptor.kt
import okhttp3.Interceptor
import okhttp3.Response

class RateLimitInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val response = chain.proceed(request)

        val limit = response.header("X-RateLimit-Limit")?.toIntOrNull() ?: 60
        val remaining = response.header("X-RateLimit-Remaining")?.toIntOrNull() ?: 0
        val reset = response.header("X-RateLimit-Reset")?.toLongOrNull() ?: 0

        if (remaining == 0) {
            val resetTime = reset * 1000
            val currentTime = System.currentTimeMillis()
            val waitTime = (resetTime - currentTime) / 1000 / 60 // в минутах
            
            if (waitTime > 0) {
                throw RateLimitException(
                    "Лимит запросов исчерпан. Подождите $waitTime минут.",
                    waitTime
                )
            }
        }

        return response
    }
}

class RateLimitException(
    message: String,
    val waitMinutes: Long
) : Exception(message)
// data/remote/auth/GitHubAuthManager.kt
import androidx.security.crypto.EncryptedSharedPreferences

class GitHubAuthManager(context: Context) {
    private val encryptedPrefs: EncryptedSharedPreferences = EncryptedSharedPreferences.create(
        context,
        "github_auth",
        MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build(),
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    fun saveToken(token: String) {
        encryptedPrefs.edit().putString("github_token", token).apply()
    }

    fun getToken(): String? = encryptedPrefs.getString("github_token", null)

    fun hasToken(): Boolean = encryptedPrefs.contains("github_token")

    fun clearToken() {
        encryptedPrefs.edit().remove("github_token").apply()
    }
}

class AuthInterceptor(private val authManager: GitHubAuthManager) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        val token = authManager.getToken()

        val newRequest = if (token != null) {
            originalRequest.newBuilder()
                .header("Authorization", "token $token")
                .build()
        } else {
            originalRequest
        }

        return chain.proceed(newRequest)
    }
}
// data/local/entity/RepositoryEntity.kt
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "repositories")
data class RepositoryEntity(
    @PrimaryKey val id: Long,
    val name: String,
    val description: String?,
    val url: String,
    val stars: Int,
    val forks: Int,
    val language: String?,
    val visibility: String,
    val updatedAt: String,
    val ownerLogin: String,
    val ownerAvatarUrl: String
)

@Entity(tableName = "remote_keys", primaryKeys = ["repoId"])
data class RemoteKeyEntity(
    val repoId: Long,
    val prevKey: Int?,
    val nextKey: Int?
)

// data/local/dao/RepositoryDao.kt
@Dao
interface RepositoryDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertRepositories(repos: List<RepositoryEntity>)

    @Query("SELECT * FROM repositories ORDER BY updatedAt DESC")
    fun getAllRepositoriesPaged(): PagingSource<Int, RepositoryEntity>

    @Query("SELECT * FROM repositories WHERE name LIKE :query ORDER BY updatedAt DESC")
    fun searchRepositoriesPaged(query: String): PagingSource<Int, RepositoryEntity>

    @Query("SELECT * FROM repositories WHERE id = :id")
    suspend fun getRepositoryById(id: Long): RepositoryEntity?

    @Query("DELETE FROM repositories")
    suspend fun clearRepositories()

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertRemoteKeys(keys: List<RemoteKeyEntity>)

    @Query("DELETE FROM remote_keys")
    suspend fun clearRemoteKeys()

    @Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
    suspend fun getRemoteKeyByRepoId(repoId: Long): RemoteKeyEntity?
}
// data/paging/RepositoryPagingSource.kt
import androidx.paging.PagingSource
import androidx.paging.PagingState

class RepositoryPagingSource(
    private val api: GitHubApi,
    private val organization: String,
    private val searchQuery: String? = null
) : PagingSource<Int, RepositoryDto>() {
    override suspend fun load(
        params: LoadParams<Int>
    ): LoadResult<Int, RepositoryDto> {
        return try {
            val page = params.key ?: 1
            val pageSize = params.loadSize

            val response = if (searchQuery != null) {
                val searchResponse = api.searchRepositories(
                    query = "$searchQuery org:$organization",
                    page = page,
                    perPage = pageSize
                )
                searchResponse.items
            } else {
                api.getOrganizationRepositories(
                    organization = organization,
                    page = page,
                    perPage = pageSize
                )
            }

            LoadResult.Page(
                data = response,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.isEmpty()) null else page + 1
            )
        } catch (e: RateLimitException) {
            LoadResult.Error(e)
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, RepositoryDto>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }
}
// data/repository/RepositoryRepositoryImpl.kt
class RepositoryRepositoryImpl(
    private val api: GitHubApi,
    private val dao: RepositoryDao,
    private val db: AppDatabase
) : RepositoryRepository {
    override fun getRepositoriesPaged(
        organization: String
    ): Flow<PagingData<Repository>> {
        return Pager(
            config = PagingConfig(
                pageSize = 30,
                enablePlaceholders = false,
                prefetchDistance = 5
            ),
            pagingSourceFactory = {
                RepositoryPagingSource(api, organization)
            }
        ).flow
            .map { pagingData ->
                pagingData.map { dto -> dto.toDomain() }
            }
            .cachedIn(viewModelScope)
    }

    override fun searchRepositories(
        organization: String,
        query: String
    ): Flow<PagingData<Repository>> {
        return Pager(
            config = PagingConfig(
                pageSize = 30,
                enablePlaceholders = false
            ),
            pagingSourceFactory = {
                RepositoryPagingSource(api, organization, query)
            }
        ).flow
            .map { pagingData ->
                pagingData.map { dto -> dto.toDomain() }
            }
            .cachedIn(viewModelScope)
    }

    override suspend fun getRepositoryDetails(
        owner: String,
        name: String
    ): Result<Repository> = withContext(Dispatchers.IO) {
        try {
            val dto = api.getRepositoryDetails(owner, name)
            Result.success(dto.toDomain())
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    override suspend fun getRateLimitStatus(): Result<RateLimit> = withContext(Dispatchers.IO) {
        try {
            val response = api.getRateLimit()
            Result.success(
                RateLimit(
                    limit = response.resources.core.limit,
                    remaining = response.resources.core.remaining,
                    reset = response.resources.core.reset
                )
            )
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    private fun RepositoryDto.toDomain() = Repository(
        id = id,
        name = name,
        description = description,
        url = html_url,
        stars = stargazers_count,
        forks = forks_count,
        language = language,
        visibility = visibility,
        updatedAt = updated_at,
        owner = Owner(owner.login, owner.avatar_url)
    )
}

3. Presentation Layer

// presentation/viewmodel/RepositoriesViewModel.kt
data class RepositoriesUiState(
    val organization: String = "google",
    val searchQuery: String = "",
    val rateLimit: RateLimit? = null,
    val error: String? = null
)

class RepositoriesViewModel(
    private val repositoryRepository: RepositoryRepository,
    private val getRateLimitStatusUseCase: GetRateLimitStatusUseCase
) : ViewModel() {
    private val _state = MutableStateFlow(RepositoriesUiState())
    val state = _state.asStateFlow()

    private val searchQuery = MutableStateFlow("")
    val repositories: StateFlow<PagingData<Repository>?> = searchQuery
        .debounce(300)
        .flatMapLatest { query ->
            val organization = _state.value.organization
            if (query.isEmpty()) {
                repositoryRepository.getRepositoriesPaged(organization)
            } else {
                repositoryRepository.searchRepositories(organization, query)
            }
        }
        .stateIn(
            viewModelScope,
            SharingStarted.Lazily,
            null
        )

    init {
        loadRateLimitStatus()
    }

    fun onSearchQueryChanged(query: String) {
        _state.value = _state.value.copy(searchQuery = query)
        searchQuery.value = query
    }

    fun onOrganizationChanged(organization: String) {
        _state.value = _state.value.copy(organization = organization)
        searchQuery.value = "" // Reset search
    }

    private fun loadRateLimitStatus() {
        viewModelScope.launch {
            val result = getRateLimitStatusUseCase()
            result.onSuccess { rateLimit ->
                _state.value = _state.value.copy(rateLimit = rateLimit)
            }.onFailure { error ->
                _state.value = _state.value.copy(
                    error = "Ошибка загрузки лимита: ${error.message}"
                )
            }
        }
    }
}
// presentation/ui/screen/RepositoriesScreen.kt
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems

@Composable
fun RepositoriesScreen(
    viewModel: RepositoriesViewModel,
    onRepositoryClick: (String, String) -> Unit
) {
    val state = viewModel.state.collectAsState().value
    val repositories = viewModel.repositories.collectAsState().value
    val lazyPagingItems = repositories?.collectAsLazyPagingItems()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // Organization Input
        OutlinedTextField(
            value = state.organization,
            onValueChange = { viewModel.onOrganizationChanged(it) },
            label = { Text("Организация") },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true
        )

        Spacer(modifier = Modifier.height(8.dp))

        // Search Bar
        SearchBar(
            searchQuery = state.searchQuery,
            onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) },
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(8.dp))

        // Rate Limit Indicator
        if (state.rateLimit != null) {
            RateLimitIndicator(
                rateLimit = state.rateLimit,
                modifier = Modifier.fillMaxWidth()
            )
        }

        // Error Message
        if (state.error != null) {
            Text(
                text = state.error,
                color = Color.Red,
                modifier = Modifier.padding(8.dp)
            )
        }

        // Repositories List
        if (lazyPagingItems != null) {
            LazyColumn(
                modifier = Modifier.fillMaxSize(),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                items(
                    count = lazyPagingItems.itemCount,
                    key = { lazyPagingItems[it]?.id }
                ) { index ->
                    val repository = lazyPagingItems[index]
                    if (repository != null) {
                        RepositoryCard(
                            repository = repository,
                            onClick = { repo ->
                                onRepositoryClick(repo.owner.login, repo.name)
                            }
                        )
                    }
                }

                when (lazyPagingItems.loadState.append) {
                    is LoadState.Loading ->
                        item {
                            PaginationLoader()
                        }
                    is LoadState.Error -> {
                        item {
                            val error = (lazyPagingItems.loadState.append as LoadState.Error).error
                            Text(
                                text = "Ошибка загрузки: ${error.message}",
                                color = Color.Red,
                                modifier = Modifier.padding(16.dp)
                            )
                        }
                    }
                    else -> {}
                }
            }
        }
    }
}

@Composable
fun RateLimitIndicator(
    rateLimit: RateLimit,
    modifier: Modifier = Modifier
) {
    val progress = if (rateLimit.limit > 0) {
        rateLimit.remaining.toFloat() / rateLimit.limit.toFloat()
    } else {
        0f
    }

    Column(modifier = modifier.padding(8.dp)) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Text(
                text = "API Rate Limit",
                style = MaterialTheme.typography.labelMedium
            )
            Text(
                text = "${rateLimit.remaining}/${rateLimit.limit}",
                style = MaterialTheme.typography.labelMedium
            )
        }
        LinearProgressIndicator(
            progress = progress,
            modifier = Modifier
                .fillMaxWidth()
                .height(4.dp),
            color = when {
                progress > 0.5f -> Color.Green
                progress > 0.2f -> Color.Yellow
                else -> Color.Red
            }
        )
    }
}
// presentation/ui/component/RepositoryCard.kt
@Composable
fun RepositoryCard(
    repository: Repository,
    onClick: (Repository) -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier
            .fillMaxWidth()
            .clickable { onClick(repository) },
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.Top
            ) {
                AsyncImage(
                    model = repository.owner.avatarUrl,
                    contentDescription = repository.owner.login,
                    modifier = Modifier
                        .size(40.dp)
                        .clip(CircleShape),
                    contentScale = ContentScale.Crop
                )

                Column(
                    modifier = Modifier.weight(1f)
                ) {
                    Text(
                        text = repository.name,
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Bold
                    )
                    Text(
                        text = repository.owner.login,
                        style = MaterialTheme.typography.labelSmall,
                        color = Color.Gray
                    )
                }
            }

            Spacer(modifier = Modifier.height(8.dp))

            // Description
            if (repository.description != null) {
                Text(
                    text = repository.description,
                    style = MaterialTheme.typography.bodySmall,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                    color = Color.Gray
                )
                Spacer(modifier = Modifier.height(8.dp))
            }

            // Language and Stats
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                if (repository.language != null) {
                    Chip(
                        label = { Text(repository.language) },
                        modifier = Modifier.padding(0.dp)
                    )
                }

                Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                    Icon(
                        Icons.Default.Star,
                        contentDescription = "Stars",
                        modifier = Modifier.size(16.dp),
                        tint = Color.Yellow
                    )
                    Text(
                        text = repository.stars.toString(),
                        style = MaterialTheme.typography.labelSmall
                    )
                }

                Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                    Icon(
                        Icons.Default.Share,
                        contentDescription = "Forks",
                        modifier = Modifier.size(16.dp)
                    )
                    Text(
                        text = repository.forks.toString(),
                        style = MaterialTheme.typography.labelSmall
                    )
                }
            }

            Spacer(modifier = Modifier.height(8.dp))

            // Updated date
            Text(
                text = "Обновлено: ${formatDate(repository.updatedAt)}",
                style = MaterialTheme.typography.labelSmall,
                color = Color.Gray
            )
        }
    }
}

private fun formatDate(dateString: String): String {
    return try {
        val instant = Instant.parse(dateString)
        val formatter = DateTimeFormatter.ofPattern("dd MMM yyyy")
            .withZone(ZoneId.systemDefault())
        formatter.format(instant)
    } catch (e: Exception) {
        dateString.take(10)
    }
}

Ключевые особенности

  • Paging 3: Эффективная пагинация с автоматической подгрузкой
  • Rate Limiting: Перехват и обработка ограничений API GitHub
  • Аутентификация: OAuth токен с шифрованным хранилищем
  • Поиск: Debounce поиска для оптимизации
  • Кэширование: Room для offline доступа
  • RemoteKeys: Правильная синхронизация пагинации
  • Error Handling: Graceful обработка ошибок с информативными сообщениями
  • Performance: Оптимизированные запросы с per_page=30

Это production-ready приложение для просмотра GitHub репозиториев с полной поддержкой пагинации и API лимитов.

GitHub репозитории организации | PrepBro