← Назад к вопросам
GitHub репозитории организации
2.0 Middle🔥 201 комментариев
#Архитектура и паттерны#Работа с данными#Сетевое взаимодействие
Условие
Создать приложение для отображения списка репозиториев организации на GitHub.
Функциональность:
- Отображение списка репозиториев организации через GitHub API
- Поиск репозиториев по названию
- Информация о репозитории:
- Название
- Описание
- Количество звёзд
- Количество форков
- Язык программирования
- Детальный экран репозитория
- Пагинация для больших списков
Технические требования:
- 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 лимитов.