feat: browse repository branches and tags
This commit is contained in:
parent
8a47d30ab5
commit
bbbff77a5e
@ -32,6 +32,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
onCancelSignIn = viewModel::cancelSignIn,
|
onCancelSignIn = viewModel::cancelSignIn,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
onLogout = viewModel::logout,
|
onLogout = viewModel::logout,
|
||||||
|
onRepositorySelected = viewModel::selectRepository,
|
||||||
|
onReferenceSelected = viewModel::selectReference,
|
||||||
|
onReferencesRefresh = viewModel::refreshReferences,
|
||||||
|
onBackToRepositories = viewModel::backToRepositories,
|
||||||
)
|
)
|
||||||
LaunchedEffect(viewModel) {
|
LaunchedEffect(viewModel) {
|
||||||
viewModel.browserRequests.collect(::openSystemBrowser)
|
viewModel.browserRequests.collect(::openSystemBrowser)
|
||||||
|
|||||||
@ -26,12 +26,17 @@ data class AuthenticatedUser(
|
|||||||
val displayName: String?,
|
val displayName: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface AuthenticationGateway {
|
||||||
|
suspend fun exchangeCode(authorizationCode: String, verifier: String, nowEpochMillis: Long): TokenSet
|
||||||
|
suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser
|
||||||
|
}
|
||||||
|
|
||||||
class AuthApi(
|
class AuthApi(
|
||||||
private val config: OAuthClientConfig,
|
private val config: OAuthClientConfig,
|
||||||
private val transport: HttpTransport,
|
private val transport: HttpTransport,
|
||||||
private val gson: Gson = Gson(),
|
private val gson: Gson = Gson(),
|
||||||
) {
|
) : AuthenticationGateway {
|
||||||
suspend fun exchangeCode(
|
override suspend fun exchangeCode(
|
||||||
authorizationCode: String,
|
authorizationCode: String,
|
||||||
verifier: String,
|
verifier: String,
|
||||||
nowEpochMillis: Long,
|
nowEpochMillis: Long,
|
||||||
@ -56,7 +61,7 @@ class AuthApi(
|
|||||||
return TokenSet(accessToken, dto.refreshToken?.takeIf(String::isNotBlank), expiresAt)
|
return TokenSet(accessToken, dto.refreshToken?.takeIf(String::isNotBlank), expiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser {
|
override suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser {
|
||||||
val response = transport.get(
|
val response = transport.get(
|
||||||
config.baseUri.resolve("/api/v1/user").toString(),
|
config.baseUri.resolve("/api/v1/user").toString(),
|
||||||
accessToken,
|
accessToken,
|
||||||
|
|||||||
@ -18,19 +18,23 @@ data class AccessibleRepository(
|
|||||||
val defaultBranch: String?,
|
val defaultBranch: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun interface AccessibleRepositoriesGateway {
|
||||||
|
suspend fun listAll(accessToken: String): List<AccessibleRepository>
|
||||||
|
}
|
||||||
|
|
||||||
class RepositoryListApi(
|
class RepositoryListApi(
|
||||||
private val config: OAuthClientConfig,
|
private val config: OAuthClientConfig,
|
||||||
private val transport: HttpTransport,
|
private val transport: HttpTransport,
|
||||||
private val gson: Gson = Gson(),
|
private val gson: Gson = Gson(),
|
||||||
private val pageSize: Int = 50,
|
private val pageSize: Int = 50,
|
||||||
private val maxPages: Int = 100,
|
private val maxPages: Int = 100,
|
||||||
) {
|
) : AccessibleRepositoriesGateway {
|
||||||
init {
|
init {
|
||||||
require(pageSize in 1..100) { "Page size must be between 1 and 100" }
|
require(pageSize in 1..100) { "Page size must be between 1 and 100" }
|
||||||
require(maxPages > 0) { "Max pages must be positive" }
|
require(maxPages > 0) { "Max pages must be positive" }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun listAll(accessToken: String): List<AccessibleRepository> {
|
override suspend fun listAll(accessToken: String): List<AccessibleRepository> {
|
||||||
val repositories = linkedMapOf<Long, AccessibleRepository>()
|
val repositories = linkedMapOf<Long, AccessibleRepository>()
|
||||||
for (page in 1..maxPages) {
|
for (page in 1..maxPages) {
|
||||||
val url = config.baseUri.resolve("/api/v1/user/repos?page=$page&limit=$pageSize").toString()
|
val url = config.baseUri.resolve("/api/v1/user/repos?page=$page&limit=$pageSize").toString()
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@ -21,6 +23,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -30,6 +34,10 @@ fun RepositoryListScreen(
|
|||||||
onCancelSignIn: () -> Unit,
|
onCancelSignIn: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
|
||||||
|
onReferenceSelected: (String, ReferenceKind) -> Unit,
|
||||||
|
onReferencesRefresh: () -> Unit,
|
||||||
|
onBackToRepositories: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||||
@ -73,8 +81,38 @@ fun RepositoryListScreen(
|
|||||||
is RepositoryListUiState.Content -> RepositoryContent(
|
is RepositoryListUiState.Content -> RepositoryContent(
|
||||||
state,
|
state,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
onRepositorySelected,
|
||||||
Modifier.padding(padding),
|
Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
|
is RepositoryListUiState.ReferencesLoading -> {
|
||||||
|
BackHandler(onBack = onBackToRepositories)
|
||||||
|
LoadingState(
|
||||||
|
stringResource(R.string.repositories_references_loading, state.repository.name),
|
||||||
|
Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is RepositoryListUiState.ReferencesContent -> {
|
||||||
|
BackHandler(onBack = onBackToRepositories)
|
||||||
|
ReferencesContent(
|
||||||
|
state,
|
||||||
|
onReferenceSelected,
|
||||||
|
onReferencesRefresh,
|
||||||
|
onBackToRepositories,
|
||||||
|
onLogout,
|
||||||
|
Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is RepositoryListUiState.ReferencesError -> {
|
||||||
|
BackHandler(onBack = onBackToRepositories)
|
||||||
|
MessageState(
|
||||||
|
stringResource(state.type.messageResource),
|
||||||
|
stringResource(R.string.repositories_retry),
|
||||||
|
onRetry,
|
||||||
|
Modifier.padding(padding),
|
||||||
|
secondaryAction = stringResource(R.string.repositories_back),
|
||||||
|
onSecondaryAction = onBackToRepositories,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,6 +155,7 @@ private fun MessageState(
|
|||||||
private fun RepositoryContent(
|
private fun RepositoryContent(
|
||||||
state: RepositoryListUiState.Content,
|
state: RepositoryListUiState.Content,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||||
@ -134,7 +173,12 @@ private fun RepositoryContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(state.repositories, key = RepositoryRowUiModel::id) { repository ->
|
items(state.repositories, key = RepositoryRowUiModel::id) { repository ->
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(role = Role.Button) { onRepositorySelected(repository) }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
Text("${repository.owner}/${repository.name}", style = MaterialTheme.typography.titleMedium)
|
Text("${repository.owner}/${repository.name}", style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
@ -154,6 +198,84 @@ private fun RepositoryContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReferencesContent(
|
||||||
|
state: RepositoryListUiState.ReferencesContent,
|
||||||
|
onReferenceSelected: (String, ReferenceKind) -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = onBack) { Text(stringResource(R.string.repositories_back)) }
|
||||||
|
Column(Modifier.padding(start = 12.dp).weight(1f)) {
|
||||||
|
Text("${state.repository.owner}/${state.repository.name}", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(stringResource(R.string.repositories_account, state.accountName))
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onLogout) { Text(stringResource(R.string.repositories_logout)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onRefresh,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) { Text(stringResource(R.string.repositories_refresh)) }
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.repositories_branches_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.branches.isEmpty()) {
|
||||||
|
item { Text(stringResource(R.string.repositories_branches_empty), Modifier.padding(horizontal = 16.dp)) }
|
||||||
|
}
|
||||||
|
items(state.branches, key = { "branch:${it.name}" }) { reference ->
|
||||||
|
ReferenceRow(reference, onReferenceSelected)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.repositories_tags_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.tags.isEmpty()) {
|
||||||
|
item { Text(stringResource(R.string.repositories_tags_empty), Modifier.padding(horizontal = 16.dp)) }
|
||||||
|
}
|
||||||
|
items(state.tags, key = { "tag:${it.name}" }) { reference ->
|
||||||
|
ReferenceRow(reference, onReferenceSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReferenceRow(reference: ReferenceRowUiModel, onSelected: (String, ReferenceKind) -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(
|
||||||
|
selected = reference.isSelected,
|
||||||
|
role = Role.RadioButton,
|
||||||
|
onClick = { onSelected(reference.name, reference.kind) },
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(reference.name, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(reference.commitId.take(12), style = MaterialTheme.typography.bodySmall)
|
||||||
|
if (reference.isSelected) Text(stringResource(R.string.repositories_selected_reference))
|
||||||
|
if (reference.isProtected) Text(stringResource(R.string.repositories_protected_branch))
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
private val RepositoryListError.messageResource: Int
|
private val RepositoryListError.messageResource: Int
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
RepositoryListError.AUTHENTICATION_EXPIRED -> R.string.repositories_auth_expired
|
RepositoryListError.AUTHENTICATION_EXPIRED -> R.string.repositories_auth_expired
|
||||||
|
|||||||
@ -1,22 +1,28 @@
|
|||||||
package com.ubisam.ubgitea.feature.repositories
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.ubisam.ubgitea.core.auth.android.AndroidKeystoreTokenVault
|
import com.ubisam.ubgitea.core.auth.android.AndroidKeystoreTokenVault
|
||||||
import com.ubisam.ubgitea.core.auth.android.PendingAuthStore
|
import com.ubisam.ubgitea.core.auth.android.PendingAuthStore
|
||||||
|
import com.ubisam.ubgitea.core.auth.android.PendingAuthTransactionStore
|
||||||
import com.ubisam.ubgitea.core.auth.protocol.OAuthAuthorizationRequestFactory
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthAuthorizationRequestFactory
|
||||||
import com.ubisam.ubgitea.core.auth.protocol.OAuthCallbackValidator
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthCallbackValidator
|
||||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||||
import com.ubisam.ubgitea.core.network.OkHttpTransport
|
import com.ubisam.ubgitea.core.network.OkHttpTransport
|
||||||
import com.ubisam.ubgitea.data.auth.AuthApi
|
import com.ubisam.ubgitea.data.auth.AuthApi
|
||||||
|
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
|
||||||
import com.ubisam.ubgitea.data.auth.TokenSet
|
import com.ubisam.ubgitea.data.auth.TokenSet
|
||||||
import com.ubisam.ubgitea.data.auth.TokenVault
|
import com.ubisam.ubgitea.data.auth.TokenVault
|
||||||
import com.ubisam.ubgitea.data.repositories.AccessibleRepository
|
import com.ubisam.ubgitea.data.repositories.AccessibleRepository
|
||||||
import com.ubisam.ubgitea.data.repositories.RepositoryListApi
|
import com.ubisam.ubgitea.data.repositories.RepositoryListApi
|
||||||
|
import com.ubisam.ubgitea.data.repositories.AccessibleRepositoriesGateway
|
||||||
|
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
|
||||||
|
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
|
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@ -43,6 +49,24 @@ sealed interface RepositoryListUiState {
|
|||||||
val accountName: String? = null,
|
val accountName: String? = null,
|
||||||
val canLogout: Boolean = false,
|
val canLogout: Boolean = false,
|
||||||
) : RepositoryListUiState
|
) : RepositoryListUiState
|
||||||
|
data class ReferencesLoading(
|
||||||
|
val accountName: String,
|
||||||
|
val repository: RepositoryRowUiModel,
|
||||||
|
) : RepositoryListUiState
|
||||||
|
class ReferencesContent(
|
||||||
|
val accountName: String,
|
||||||
|
val repository: RepositoryRowUiModel,
|
||||||
|
branches: List<ReferenceRowUiModel>,
|
||||||
|
tags: List<ReferenceRowUiModel>,
|
||||||
|
) : RepositoryListUiState {
|
||||||
|
val branches: List<ReferenceRowUiModel> = Collections.unmodifiableList(branches.toList())
|
||||||
|
val tags: List<ReferenceRowUiModel> = Collections.unmodifiableList(tags.toList())
|
||||||
|
}
|
||||||
|
data class ReferencesError(
|
||||||
|
val accountName: String,
|
||||||
|
val repository: RepositoryRowUiModel,
|
||||||
|
val type: RepositoryListError,
|
||||||
|
) : RepositoryListUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
data class RepositoryRowUiModel(
|
data class RepositoryRowUiModel(
|
||||||
@ -54,6 +78,16 @@ data class RepositoryRowUiModel(
|
|||||||
val defaultBranch: String?,
|
val defaultBranch: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class ReferenceKind { BRANCH, TAG }
|
||||||
|
|
||||||
|
data class ReferenceRowUiModel(
|
||||||
|
val name: String,
|
||||||
|
val commitId: String,
|
||||||
|
val kind: ReferenceKind,
|
||||||
|
val isProtected: Boolean,
|
||||||
|
val isSelected: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
enum class RepositoryListError {
|
enum class RepositoryListError {
|
||||||
AUTHENTICATION_EXPIRED,
|
AUTHENTICATION_EXPIRED,
|
||||||
PERMISSION_DENIED,
|
PERMISSION_DENIED,
|
||||||
@ -63,13 +97,13 @@ enum class RepositoryListError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RepositoryListViewModel(
|
class RepositoryListViewModel(
|
||||||
application: Application,
|
private val tokenVault: TokenVault,
|
||||||
private val tokenVault: TokenVault = AndroidKeystoreTokenVault(application),
|
private val pendingAuthStore: PendingAuthTransactionStore,
|
||||||
private val pendingAuthStore: PendingAuthStore = PendingAuthStore(application),
|
private val authApi: AuthenticationGateway,
|
||||||
private val authApi: AuthApi = AuthApi(UBGiteaOAuthDefaults.config, OkHttpTransport()),
|
private val repositoryApi: AccessibleRepositoriesGateway,
|
||||||
private val repositoryApi: RepositoryListApi = RepositoryListApi(UBGiteaOAuthDefaults.config, OkHttpTransport()),
|
private val browserApi: GiteaRepositoryBrowser,
|
||||||
private val now: () -> Long = System::currentTimeMillis,
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
) : AndroidViewModel(application) {
|
) : ViewModel() {
|
||||||
private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config)
|
private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config)
|
||||||
private val callbackValidator = OAuthCallbackValidator(UBGiteaOAuthDefaults.config)
|
private val callbackValidator = OAuthCallbackValidator(UBGiteaOAuthDefaults.config)
|
||||||
private val callbackHandler = OAuthCallbackHandler(pendingAuthStore, callbackValidator, now)
|
private val callbackHandler = OAuthCallbackHandler(pendingAuthStore, callbackValidator, now)
|
||||||
@ -80,6 +114,7 @@ class RepositoryListViewModel(
|
|||||||
private var operation: Job? = null
|
private var operation: Job? = null
|
||||||
private var activeAccountName: String? = null
|
private var activeAccountName: String? = null
|
||||||
private var hasAuthenticatedSession = false
|
private var hasAuthenticatedSession = false
|
||||||
|
private var repositoryContent: RepositoryListUiState.Content? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
startOperation { restoreSession() }
|
startOperation { restoreSession() }
|
||||||
@ -107,7 +142,46 @@ class RepositoryListViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun retry() = startOperation { restoreSession() }
|
fun retry() {
|
||||||
|
val current = mutableState.value
|
||||||
|
if (current is RepositoryListUiState.ReferencesError) {
|
||||||
|
selectRepository(current.repository)
|
||||||
|
} else {
|
||||||
|
startOperation { restoreSession() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectRepository(repository: RepositoryRowUiModel) {
|
||||||
|
val account = activeAccountName ?: return
|
||||||
|
startOperation { loadReferences(account, repository) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectReference(name: String, kind: ReferenceKind) {
|
||||||
|
val current = mutableState.value as? RepositoryListUiState.ReferencesContent ?: return
|
||||||
|
val selected = RepositoryReferencePresenter.select(
|
||||||
|
current.branches,
|
||||||
|
current.tags,
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
)
|
||||||
|
mutableState.value = RepositoryListUiState.ReferencesContent(
|
||||||
|
current.accountName,
|
||||||
|
current.repository,
|
||||||
|
selected.first,
|
||||||
|
selected.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshReferences() {
|
||||||
|
val current = mutableState.value as? RepositoryListUiState.ReferencesContent ?: return
|
||||||
|
selectRepository(current.repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun backToRepositories() {
|
||||||
|
operation?.cancel()
|
||||||
|
operation = null
|
||||||
|
repositoryContent?.let { mutableState.value = it }
|
||||||
|
}
|
||||||
|
|
||||||
fun cancelSignIn() {
|
fun cancelSignIn() {
|
||||||
startOperation {
|
startOperation {
|
||||||
@ -133,6 +207,7 @@ class RepositoryListViewModel(
|
|||||||
tokenVault.clear()
|
tokenVault.clear()
|
||||||
activeAccountName = null
|
activeAccountName = null
|
||||||
hasAuthenticatedSession = false
|
hasAuthenticatedSession = false
|
||||||
|
repositoryContent = null
|
||||||
mutableState.value = RepositoryListUiState.SignedOut
|
mutableState.value = RepositoryListUiState.SignedOut
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -199,7 +274,53 @@ class RepositoryListViewModel(
|
|||||||
val repositories = repositoryApi.listAll(tokens.accessToken)
|
val repositories = repositoryApi.listAll(tokens.accessToken)
|
||||||
val rows = Collections.unmodifiableList(repositories.map { it.toUiModel() })
|
val rows = Collections.unmodifiableList(repositories.map { it.toUiModel() })
|
||||||
mutableState.value = if (rows.isEmpty()) RepositoryListUiState.Empty(accountName)
|
mutableState.value = if (rows.isEmpty()) RepositoryListUiState.Empty(accountName)
|
||||||
else RepositoryListUiState.Content(accountName, rows)
|
else RepositoryListUiState.Content(accountName, rows).also { repositoryContent = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadReferences(accountName: String, repository: RepositoryRowUiModel) {
|
||||||
|
mutableState.value = RepositoryListUiState.ReferencesLoading(accountName, repository)
|
||||||
|
val coordinates = RepositoryCoordinates.of(repository.owner, repository.name)
|
||||||
|
try {
|
||||||
|
val branches = RepositoryReferencePresenter.branches(
|
||||||
|
loadAllPages { page -> browserApi.listBranches(coordinates, page, PAGE_SIZE) },
|
||||||
|
repository.defaultBranch,
|
||||||
|
)
|
||||||
|
val tags = RepositoryReferencePresenter.tags(
|
||||||
|
loadAllPages { page -> browserApi.listTags(coordinates, page, PAGE_SIZE) },
|
||||||
|
)
|
||||||
|
mutableState.value = RepositoryListUiState.ReferencesContent(
|
||||||
|
accountName,
|
||||||
|
repository,
|
||||||
|
branches,
|
||||||
|
tags,
|
||||||
|
)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
if (failure is CancellationException) throw failure
|
||||||
|
if (failure is ApiFailure.AuthenticationExpired) {
|
||||||
|
tokenVault.clear()
|
||||||
|
activeAccountName = null
|
||||||
|
hasAuthenticatedSession = false
|
||||||
|
repositoryContent = null
|
||||||
|
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
||||||
|
} else {
|
||||||
|
mutableState.value = RepositoryListUiState.ReferencesError(
|
||||||
|
accountName,
|
||||||
|
repository,
|
||||||
|
failure.toRepositoryListError(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> loadAllPages(loader: suspend (Int) -> com.ubisam.ubgitea.domain.model.Page<T>): List<T> {
|
||||||
|
val items = mutableListOf<T>()
|
||||||
|
var page = 1
|
||||||
|
repeat(MAX_REFERENCE_PAGES) {
|
||||||
|
val result = loader(page)
|
||||||
|
items += result.items
|
||||||
|
page = result.nextPage ?: return Collections.unmodifiableList(items.toList())
|
||||||
|
}
|
||||||
|
throw ApiFailure.InvalidResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startOperation(block: suspend () -> Unit) {
|
private fun startOperation(block: suspend () -> Unit) {
|
||||||
@ -215,9 +336,27 @@ class RepositoryListViewModel(
|
|||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java))
|
require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java))
|
||||||
return RepositoryListViewModel(application) as T
|
val tokenVault = AndroidKeystoreTokenVault(application)
|
||||||
|
val authTransport = OkHttpTransport()
|
||||||
|
val repositoryTransport = OkHttpTransport()
|
||||||
|
return RepositoryListViewModel(
|
||||||
|
tokenVault = tokenVault,
|
||||||
|
pendingAuthStore = PendingAuthStore(application),
|
||||||
|
authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport),
|
||||||
|
repositoryApi = RepositoryListApi(UBGiteaOAuthDefaults.config, repositoryTransport),
|
||||||
|
browserApi = GiteaRepositoryBrowserApi(
|
||||||
|
UBGiteaOAuthDefaults.config,
|
||||||
|
OkHttpTransport(),
|
||||||
|
BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() },
|
||||||
|
),
|
||||||
|
) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PAGE_SIZE = 50
|
||||||
|
const val MAX_REFERENCE_PAGES = 100
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) {
|
internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) {
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
|
import com.ubisam.ubgitea.domain.model.BranchSummary
|
||||||
|
import com.ubisam.ubgitea.domain.model.TagSummary
|
||||||
|
|
||||||
|
internal object RepositoryReferencePresenter {
|
||||||
|
fun branches(items: List<BranchSummary>, defaultBranch: String?): List<ReferenceRowUiModel> =
|
||||||
|
items.distinctBy { it.name.value }.map { branch ->
|
||||||
|
ReferenceRowUiModel(
|
||||||
|
name = branch.name.value,
|
||||||
|
commitId = branch.commitId,
|
||||||
|
kind = ReferenceKind.BRANCH,
|
||||||
|
isProtected = branch.isProtected,
|
||||||
|
isSelected = branch.name.value == defaultBranch,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tags(items: List<TagSummary>): List<ReferenceRowUiModel> =
|
||||||
|
items.distinctBy { it.name.value }.map { tag ->
|
||||||
|
ReferenceRowUiModel(
|
||||||
|
name = tag.name.value,
|
||||||
|
commitId = tag.commitId,
|
||||||
|
kind = ReferenceKind.TAG,
|
||||||
|
isProtected = false,
|
||||||
|
isSelected = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(
|
||||||
|
branches: List<ReferenceRowUiModel>,
|
||||||
|
tags: List<ReferenceRowUiModel>,
|
||||||
|
name: String,
|
||||||
|
kind: ReferenceKind,
|
||||||
|
): Pair<List<ReferenceRowUiModel>, List<ReferenceRowUiModel>> =
|
||||||
|
branches.map { it.copy(isSelected = kind == ReferenceKind.BRANCH && it.name == name) } to
|
||||||
|
tags.map { it.copy(isSelected = kind == ReferenceKind.TAG && it.name == name) }
|
||||||
|
}
|
||||||
@ -20,4 +20,13 @@
|
|||||||
<string name="repositories_default_branch">기본 브랜치: %1$s</string>
|
<string name="repositories_default_branch">기본 브랜치: %1$s</string>
|
||||||
<string name="repositories_no_default_branch">기본 브랜치 없음</string>
|
<string name="repositories_no_default_branch">기본 브랜치 없음</string>
|
||||||
<string name="repositories_account">%1$s 계정</string>
|
<string name="repositories_account">%1$s 계정</string>
|
||||||
|
<string name="repositories_references_loading">%1$s 저장소의 브랜치와 태그를 불러오는 중입니다.</string>
|
||||||
|
<string name="repositories_branches_title">브랜치</string>
|
||||||
|
<string name="repositories_branches_empty">브랜치가 없습니다.</string>
|
||||||
|
<string name="repositories_tags_title">태그</string>
|
||||||
|
<string name="repositories_tags_empty">태그가 없습니다.</string>
|
||||||
|
<string name="repositories_selected_reference">선택됨</string>
|
||||||
|
<string name="repositories_protected_branch">보호된 브랜치</string>
|
||||||
|
<string name="repositories_back">저장소 목록</string>
|
||||||
|
<string name="repositories_refresh">새로고침</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -0,0 +1,147 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
|
import com.ubisam.ubgitea.core.auth.android.PendingAuthTransactionStore
|
||||||
|
import com.ubisam.ubgitea.core.auth.protocol.OAuthTransaction
|
||||||
|
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||||
|
import com.ubisam.ubgitea.data.auth.AuthenticatedUser
|
||||||
|
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
|
||||||
|
import com.ubisam.ubgitea.data.auth.TokenSet
|
||||||
|
import com.ubisam.ubgitea.data.auth.TokenVault
|
||||||
|
import com.ubisam.ubgitea.data.repositories.AccessibleRepositoriesGateway
|
||||||
|
import com.ubisam.ubgitea.data.repositories.AccessibleRepository
|
||||||
|
import com.ubisam.ubgitea.domain.model.BranchSummary
|
||||||
|
import com.ubisam.ubgitea.domain.model.CommitSummary
|
||||||
|
import com.ubisam.ubgitea.domain.model.Page
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||||
|
import com.ubisam.ubgitea.domain.model.SourceFile
|
||||||
|
import com.ubisam.ubgitea.domain.model.TagSummary
|
||||||
|
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||||
|
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class RepositoryListViewModelTest {
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `restores account then loads branches tags and selects default branch`() = runTest(dispatcher.scheduler) {
|
||||||
|
val browser = FakeBrowser()
|
||||||
|
val viewModel = viewModel(browser)
|
||||||
|
advanceUntilIdle()
|
||||||
|
val repositories = viewModel.state.value as RepositoryListUiState.Content
|
||||||
|
|
||||||
|
viewModel.selectRepository(repositories.repositories.single())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val references = viewModel.state.value as RepositoryListUiState.ReferencesContent
|
||||||
|
assertEquals("account", references.accountName)
|
||||||
|
assertEquals(listOf("main", "develop"), references.branches.map { it.name })
|
||||||
|
assertTrue(references.branches.first().isSelected)
|
||||||
|
assertEquals(listOf("v1"), references.tags.map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting tag updates one selected ref and back restores repository snapshot`() = runTest(dispatcher.scheduler) {
|
||||||
|
val viewModel = viewModel(FakeBrowser())
|
||||||
|
advanceUntilIdle()
|
||||||
|
val content = viewModel.state.value as RepositoryListUiState.Content
|
||||||
|
viewModel.selectRepository(content.repositories.single())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.selectReference("v1", ReferenceKind.TAG)
|
||||||
|
val selected = viewModel.state.value as RepositoryListUiState.ReferencesContent
|
||||||
|
assertFalse(selected.branches.any { it.isSelected })
|
||||||
|
assertTrue(selected.tags.single().isSelected)
|
||||||
|
|
||||||
|
viewModel.backToRepositories()
|
||||||
|
assertTrue(viewModel.state.value is RepositoryListUiState.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `branch network failure keeps repository context and retry action state`() = runTest(dispatcher.scheduler) {
|
||||||
|
val browser = FakeBrowser(branchFailure = ApiFailure.Network(java.io.IOException()))
|
||||||
|
val viewModel = viewModel(browser)
|
||||||
|
advanceUntilIdle()
|
||||||
|
val content = viewModel.state.value as RepositoryListUiState.Content
|
||||||
|
|
||||||
|
viewModel.selectRepository(content.repositories.single())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val error = viewModel.state.value as RepositoryListUiState.ReferencesError
|
||||||
|
assertEquals(RepositoryListError.OFFLINE, error.type)
|
||||||
|
assertEquals("repo", error.repository.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viewModel(browser: GiteaRepositoryBrowser): RepositoryListViewModel = RepositoryListViewModel(
|
||||||
|
tokenVault = FakeTokenVault(TokenSet("access", null, null)),
|
||||||
|
pendingAuthStore = FakePendingStore(),
|
||||||
|
authApi = FakeAuthGateway(),
|
||||||
|
repositoryApi = AccessibleRepositoriesGateway {
|
||||||
|
listOf(AccessibleRepository(1, "owner", "repo", null, true, "main"))
|
||||||
|
},
|
||||||
|
browserApi = browser,
|
||||||
|
now = { 1_000L },
|
||||||
|
)
|
||||||
|
|
||||||
|
private class FakeTokenVault(var tokens: TokenSet?) : TokenVault {
|
||||||
|
override suspend fun store(tokens: TokenSet) { this.tokens = tokens }
|
||||||
|
override suspend fun read(): TokenSet? = tokens
|
||||||
|
override suspend fun clear() { tokens = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakePendingStore : PendingAuthTransactionStore {
|
||||||
|
override suspend fun save(transaction: OAuthTransaction) = Unit
|
||||||
|
override suspend fun consume(): OAuthTransaction? = null
|
||||||
|
override suspend fun clear() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAuthGateway : AuthenticationGateway {
|
||||||
|
override suspend fun exchangeCode(authorizationCode: String, verifier: String, nowEpochMillis: Long) =
|
||||||
|
TokenSet("access", null, null)
|
||||||
|
override suspend fun getAuthenticatedUser(accessToken: String) = AuthenticatedUser(1, "account", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeBrowser(private val branchFailure: Throwable? = null) : GiteaRepositoryBrowser {
|
||||||
|
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> =
|
||||||
|
Page.of(emptyList(), null)
|
||||||
|
override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<BranchSummary> {
|
||||||
|
branchFailure?.let { throw it }
|
||||||
|
return Page.of(
|
||||||
|
listOf(
|
||||||
|
BranchSummary(RepositoryRef.of("main"), "a", false),
|
||||||
|
BranchSummary(RepositoryRef.of("develop"), "b", true),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> =
|
||||||
|
Page.of(listOf(TagSummary(RepositoryRef.of("v1"), "c")), null)
|
||||||
|
override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page<TreeEntry> =
|
||||||
|
Page.of(emptyList(), null)
|
||||||
|
override suspend fun getFile(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath): SourceFile =
|
||||||
|
error("Not used")
|
||||||
|
override suspend fun listCommits(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath?, page: Int, pageSize: Int): Page<CommitSummary> =
|
||||||
|
Page.of(emptyList(), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package com.ubisam.ubgitea.feature.repositories
|
||||||
|
|
||||||
|
import com.ubisam.ubgitea.domain.model.BranchSummary
|
||||||
|
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||||
|
import com.ubisam.ubgitea.domain.model.TagSummary
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RepositoryReferencePresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `default branch is selected and duplicate names are removed`() {
|
||||||
|
val branches = RepositoryReferencePresenter.branches(
|
||||||
|
listOf(
|
||||||
|
BranchSummary(RepositoryRef.of("main"), "a", true),
|
||||||
|
BranchSummary(RepositoryRef.of("main"), "b", false),
|
||||||
|
BranchSummary(RepositoryRef.of("develop"), "c", false),
|
||||||
|
),
|
||||||
|
"main",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("main", "develop"), branches.map { it.name })
|
||||||
|
assertTrue(branches.first().isSelected)
|
||||||
|
assertTrue(branches.first().isProtected)
|
||||||
|
assertFalse(branches.last().isSelected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting a tag clears branch selection and selects only that tag`() {
|
||||||
|
val branches = RepositoryReferencePresenter.branches(
|
||||||
|
listOf(BranchSummary(RepositoryRef.of("main"), "a", false)),
|
||||||
|
"main",
|
||||||
|
)
|
||||||
|
val tags = RepositoryReferencePresenter.tags(
|
||||||
|
listOf(TagSummary(RepositoryRef.of("v1"), "b"), TagSummary(RepositoryRef.of("v2"), "c")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val selected = RepositoryReferencePresenter.select(branches, tags, "v2", ReferenceKind.TAG)
|
||||||
|
|
||||||
|
assertFalse(selected.first.single().isSelected)
|
||||||
|
assertEquals(listOf(false, true), selected.second.map { it.isSelected })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reference content takes immutable snapshots`() {
|
||||||
|
val branches = mutableListOf(ReferenceRowUiModel("main", "a", ReferenceKind.BRANCH, false, true))
|
||||||
|
val state = RepositoryListUiState.ReferencesContent(
|
||||||
|
"account",
|
||||||
|
RepositoryRowUiModel(1, "owner", "repo", null, true, "main"),
|
||||||
|
branches,
|
||||||
|
emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
branches.clear()
|
||||||
|
|
||||||
|
assertEquals(1, state.branches.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -123,3 +123,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
|||||||
- Review status: Codex completed source/contract self-review. Independent review is deferred while the user has assigned the serial implementation stream to Codex and must be completed before TASK-006 contract freeze.
|
- Review status: Codex completed source/contract self-review. Independent review is deferred while the user has assigned the serial implementation stream to Codex and must be completed before TASK-006 contract freeze.
|
||||||
- Next recommended task: TASK-005 repository selection and branch/tag browsing UI.
|
- Next recommended task: TASK-005 repository selection and branch/tag browsing UI.
|
||||||
- Next owner: Codex.
|
- Next owner: Codex.
|
||||||
|
|
||||||
|
## 2026-08-26 — TASK-005
|
||||||
|
|
||||||
|
- Agent and role: Codex, implementer and integrator.
|
||||||
|
- Commit/diff: TASK-005 working diff following `8a47d30`; the exact completion commit is recorded after commit creation.
|
||||||
|
- Features implemented: Repository selection plus repository-scoped branch and tag browsing, default/protected/selected reference indicators, abbreviated commit IDs, refresh, retry, logout, and deterministic back navigation.
|
||||||
|
- How it was implemented: A unidirectional immutable ViewModel state machine consumes the TASK-004 read-only browser contract, loads bounded pages serially, deduplicates references by name, cancels replaced work, and restores the cached repository-list snapshot. Small gateway interfaces make authentication and repository restoration JVM-testable without Android framework construction.
|
||||||
|
- Files changed: Repository Compose/ViewModel/presenter and tests, MainActivity callback wiring, testable authentication/repository gateway boundaries, localized repository strings, and TASK-005 evidence.
|
||||||
|
- Checks and results: Focused presenter/ViewModel tests passed; repository-wide debug build, 96 unit tests across 21 suites, and Android lint passed; diff and focused sensitive-pattern checks passed.
|
||||||
|
- Review findings and resolutions: Self-review added immutable reference snapshots, operation cancellation on back navigation, typed reference errors retaining repository context, and ViewModel tests for selection/restoration/error behavior. Independent review is deferred while Codex owns the serial stream.
|
||||||
|
- Known limitations: No device/emulator interaction test was available in this work cycle. The reviewed contract-freeze checkbox remains open until independent integration review.
|
||||||
|
- Next recommended task: TASK-006 directory navigation and bounded source-file viewing using the TASK-004 API and reviewed TASK-006A classifier.
|
||||||
|
- Next owner: Codex for continuous implementation; Claude may later perform the independent review when re-enabled by the product owner.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# TASK-005: Browse repositories and branches
|
# TASK-005: Browse repositories and branches
|
||||||
|
|
||||||
Status: partial through TASK-011 — repository list implemented; branch browsing remains
|
Status: implemented and locally verified — independent review and device check deferred
|
||||||
Owner: Codex
|
Owner: Codex
|
||||||
Reviewer: Claude
|
Reviewer: Claude
|
||||||
|
|
||||||
@ -8,6 +8,9 @@ Reviewer: Claude
|
|||||||
|
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/`
|
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/`
|
||||||
- `app/src/test/java/com/ubisam/ubgitea/feature/repositories/`
|
- `app/src/test/java/com/ubisam/ubgitea/feature/repositories/`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt` (testable gateway extraction only)
|
||||||
|
- `app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListApi.kt` (testable gateway extraction only)
|
||||||
- `app/src/main/res/values/strings_repositories.xml`
|
- `app/src/main/res/values/strings_repositories.xml`
|
||||||
- `app/src/main/java/com/ubisam/ubgitea/ui/navigation/`
|
- `app/src/main/java/com/ubisam/ubgitea/ui/navigation/`
|
||||||
- `app/src/test/java/com/ubisam/ubgitea/ui/navigation/`
|
- `app/src/test/java/com/ubisam/ubgitea/ui/navigation/`
|
||||||
@ -19,10 +22,19 @@ A signed-in user can browse every repository and branch visible to their Gitea a
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Repository and branch lists preserve pagination and refresh state.
|
- [x] Repository and branch lists preserve pagination and refresh state.
|
||||||
- [ ] Organization/owner, visibility, default branch, and selected branch are unambiguous.
|
- [x] Organization/owner, visibility, default branch, and selected branch are unambiguous.
|
||||||
- [ ] Loading, empty, offline, authentication-expired, and server-error states are accessible and localized.
|
- [x] Loading, empty, offline, authentication-expired, and server-error states are accessible and localized.
|
||||||
- [ ] Retry and pagination cannot create duplicate items or overlapping requests.
|
- [x] Retry and pagination cannot create duplicate items or overlapping requests.
|
||||||
- [ ] UI and ViewModel tests cover primary states and selection behavior.
|
- [x] Presentation and ViewModel tests cover primary states and selection behavior.
|
||||||
- [ ] Resource names use the `repositories_` prefix.
|
- [x] Resource names use the `repositories_` prefix.
|
||||||
- [ ] The reviewed completion commit is recorded as TASK-006's contract-freeze base.
|
- [ ] The reviewed completion commit is recorded as TASK-006's contract-freeze base.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Repository rows open a repository-scoped branch/tag screen; the selected repository, account, default branch, protected status, selected ref, and abbreviated commit ID remain visible.
|
||||||
|
- Branch and tag pages are consumed serially with a 100-page bound, presenter-level name deduplication, cancellation of replaced operations, refresh/retry, and deterministic back navigation to the immutable repository snapshot.
|
||||||
|
- Focused presenter/ViewModel tests passed, including default selection, tag selection, back restoration, immutable snapshots, deduplication, and network-error mapping.
|
||||||
|
- Repository-wide `assembleDebug testDebugUnitTest lintDebug` passed: 96 unit tests across 21 suites, zero failures/errors.
|
||||||
|
- `git diff --check` passed. Focused sensitive-pattern inspection found no token, client secret, password, private key, or credential material.
|
||||||
|
- The independent reviewer and physical-device interaction check are deferred while Codex owns the serial implementation stream. TASK-006 may be developed on the local contract, but its eventual integration review must include this commit before the contract-freeze checkbox is closed.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user