diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index c644ba6..3ac8f82 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -27,6 +27,10 @@ import com.ubisam.ubgitea.feature.history.HistoryViewModel import com.ubisam.ubgitea.feature.repositories.ReferenceKind import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel +import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen +import com.ubisam.ubgitea.feature.workitems.WorkItemsTarget +import com.ubisam.ubgitea.feature.workitems.WorkItemsUiState +import com.ubisam.ubgitea.feature.workitems.WorkItemsViewModel import com.ubisam.ubgitea.ui.theme.UBGiteaTheme class MainActivity : ComponentActivity() { @@ -39,6 +43,9 @@ class MainActivity : ComponentActivity() { private val historyViewModel: HistoryViewModel by viewModels { HistoryViewModel.Factory(application) } + private val workItemsViewModel: WorkItemsViewModel by viewModels { + WorkItemsViewModel.Factory(application) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,14 +54,40 @@ class MainActivity : ComponentActivity() { val state by viewModel.state.collectAsState() val codeBrowserState by codeBrowserViewModel.state.collectAsState() val historyState by historyViewModel.state.collectAsState() + val workItemsState by workItemsViewModel.state.collectAsState() var browsingCode by rememberSaveable { mutableStateOf(codeBrowserState !is CodeBrowserUiState.Idle) } var browsingHistory by rememberSaveable { mutableStateOf(historyState !is HistoryUiState.Idle) } + var browsingWorkItems by rememberSaveable { + mutableStateOf(workItemsState !is WorkItemsUiState.Idle) + } UBGiteaTheme { - if (browsingHistory) { + if (browsingWorkItems) { + WorkItemsScreen( + state = workItemsState, + onShowIssues = workItemsViewModel::showIssues, + onShowPullRequests = workItemsViewModel::showPullRequests, + onIssueSelected = workItemsViewModel::openIssue, + onPullRequestSelected = workItemsViewModel::openPullRequest, + onNextPage = workItemsViewModel::nextPage, + onPreviousPage = workItemsViewModel::previousPage, + onRetryList = workItemsViewModel::retryList, + onRetryDetail = workItemsViewModel::retryDetail, + onBackToList = workItemsViewModel::backToList, + onBack = { + workItemsViewModel.close() + browsingWorkItems = false + }, + onAuthenticationExpired = { + workItemsViewModel.close() + browsingWorkItems = false + viewModel.logout() + }, + ) + } else if (browsingHistory) { HistoryScreen( state = historyState, onCommitSelected = historyViewModel::openCommit, @@ -135,6 +168,10 @@ class MainActivity : ComponentActivity() { onShowAllRepositories = viewModel::showAllRepositories, onShowFavorites = viewModel::showFavorites, onToggleFavorite = viewModel::toggleFavorite, + onViewWorkItems = { repository -> + workItemsViewModel.open(WorkItemsTarget(repository.coordinates)) + browsingWorkItems = true + }, ) } LaunchedEffect(viewModel) { diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt index d0dcbf3..c238ced 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt @@ -49,6 +49,7 @@ fun RepositoryListScreen( onShowAllRepositories: () -> Unit, onShowFavorites: () -> Unit, onToggleFavorite: (RepositoryRowUiModel) -> Unit, + onViewWorkItems: (RepositoryRowUiModel) -> Unit, modifier: Modifier = Modifier, ) { Scaffold(modifier = modifier.fillMaxSize()) { padding -> @@ -124,6 +125,7 @@ fun RepositoryListScreen( onReferencesRefresh, onBackToRepositories, onLogout, + onViewWorkItems, Modifier.padding(padding), ) } @@ -338,6 +340,7 @@ private fun ReferencesContent( onRefresh: () -> Unit, onBack: () -> Unit, onLogout: () -> Unit, + onViewWorkItems: (RepositoryRowUiModel) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn(modifier = modifier.fillMaxSize()) { @@ -355,10 +358,12 @@ private fun ReferencesContent( } } item { - OutlinedButton( - onClick = onRefresh, - modifier = Modifier.padding(horizontal = 16.dp), - ) { Text(stringResource(R.string.repositories_refresh)) } + Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onRefresh) { Text(stringResource(R.string.repositories_refresh)) } + OutlinedButton(onClick = { onViewWorkItems(state.repository) }) { + Text(stringResource(R.string.repositories_work_items)) + } + } } item { Text( diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt new file mode 100644 index 0000000..bfed880 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt @@ -0,0 +1,327 @@ +package com.ubisam.ubgitea.feature.workitems + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.ubisam.ubgitea.R +import com.ubisam.ubgitea.domain.workitems.CommentSummary +import com.ubisam.ubgitea.domain.workitems.IssueState +import com.ubisam.ubgitea.domain.workitems.IssueSummary +import com.ubisam.ubgitea.domain.workitems.PullRequestState +import com.ubisam.ubgitea.domain.workitems.PullRequestSummary +import com.ubisam.ubgitea.domain.workitems.ReviewState +import com.ubisam.ubgitea.domain.workitems.ReviewSummary +import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor + +@Composable +fun WorkItemsScreen( + state: WorkItemsUiState, + onShowIssues: () -> Unit, + onShowPullRequests: () -> Unit, + onIssueSelected: (IssueSummary) -> Unit, + onPullRequestSelected: (PullRequestSummary) -> Unit, + onNextPage: () -> Unit, + onPreviousPage: () -> Unit, + onRetryList: () -> Unit, + onRetryDetail: () -> Unit, + onBackToList: () -> Unit, + onBack: () -> Unit, + onAuthenticationExpired: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold(modifier = modifier.fillMaxSize()) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + when (state) { + WorkItemsUiState.Idle -> Unit + is WorkItemsUiState.Loading -> { + BackHandler(onBack = onBack) + ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack) + CenterMessage { CircularProgressIndicator() } + } + is WorkItemsUiState.IssueList -> { + BackHandler(onBack = onBack) + ModeTabs(WorkItemsTab.ISSUES, onShowIssues, onShowPullRequests, onBack) + LazyColumn(Modifier.weight(1f)) { + items(state.items, key = IssueSummary::number) { issue -> + IssueRow(issue) { onIssueSelected(issue) } + } + } + PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage) + } + is WorkItemsUiState.PullRequestList -> { + BackHandler(onBack = onBack) + ModeTabs(WorkItemsTab.PULL_REQUESTS, onShowIssues, onShowPullRequests, onBack) + LazyColumn(Modifier.weight(1f)) { + items(state.items, key = PullRequestSummary::number) { pr -> + PullRequestRow(pr) { onPullRequestSelected(pr) } + } + } + PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage) + } + is WorkItemsUiState.Empty -> { + BackHandler(onBack = onBack) + ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack) + CenterMessage { Text(stringResource(R.string.workitems_empty)) } + } + is WorkItemsUiState.ListError -> { + BackHandler(onBack = onBack) + ModeTabs(state.tab, onShowIssues, onShowPullRequests, onBack) + ErrorMessage(state.type, onRetryList, onAuthenticationExpired) + } + is WorkItemsUiState.IssueDetailLoading -> { + BackHandler(onBack = onBackToList) + DetailHeader(onBackToList) + CenterMessage { CircularProgressIndicator() } + } + is WorkItemsUiState.IssueDetailContent -> { + BackHandler(onBack = onBackToList) + DetailHeader(onBackToList) + IssueDetailView(state) + } + is WorkItemsUiState.PullRequestDetailLoading -> { + BackHandler(onBack = onBackToList) + DetailHeader(onBackToList) + CenterMessage { CircularProgressIndicator() } + } + is WorkItemsUiState.PullRequestDetailContent -> { + BackHandler(onBack = onBackToList) + DetailHeader(onBackToList) + PullRequestDetailView(state) + } + is WorkItemsUiState.DetailError -> { + BackHandler(onBack = onBackToList) + DetailHeader(onBackToList) + ErrorMessage(state.type, onRetryDetail, onAuthenticationExpired) + } + } + } + } +} + +@Composable +private fun ModeTabs(tab: WorkItemsTab, onShowIssues: () -> Unit, onShowPullRequests: () -> Unit, onBack: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) { + OutlinedButton(onClick = onBack) { Text(stringResource(R.string.workitems_back)) } + } + TabRow(selectedTabIndex = if (tab == WorkItemsTab.PULL_REQUESTS) 1 else 0) { + Tab( + selected = tab == WorkItemsTab.ISSUES, + onClick = onShowIssues, + text = { Text(stringResource(R.string.workitems_tab_issues)) }, + ) + Tab( + selected = tab == WorkItemsTab.PULL_REQUESTS, + onClick = onShowPullRequests, + text = { Text(stringResource(R.string.workitems_tab_pull_requests)) }, + ) + } +} + +@Composable +private fun DetailHeader(onBack: () -> Unit) { + Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = onBack) { Text(stringResource(R.string.workitems_back)) } + } + HorizontalDivider() +} + +@Composable +private fun IssueRow(issue: IssueSummary, onSelected: () -> Unit) { + Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(issue.state.labelResource), style = MaterialTheme.typography.bodySmall) + Text("#${issue.number}", style = MaterialTheme.typography.bodySmall) + } + Text(issue.title, style = MaterialTheme.typography.titleMedium, maxLines = 2) + Text(stringResource(R.string.workitems_by_author, issue.author.label(), issue.commentCount), style = MaterialTheme.typography.bodySmall) + } + HorizontalDivider() +} + +@Composable +private fun PullRequestRow(pr: PullRequestSummary, onSelected: () -> Unit) { + Column(Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected).padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(pr.state.labelResource), style = MaterialTheme.typography.bodySmall) + Text("#${pr.number}", style = MaterialTheme.typography.bodySmall) + } + Text(pr.title, style = MaterialTheme.typography.titleMedium, maxLines = 2) + Text(stringResource(R.string.workitems_by_author, pr.author.label(), pr.commentCount), style = MaterialTheme.typography.bodySmall) + } + HorizontalDivider() +} + +@Composable +private fun IssueDetailView(state: WorkItemsUiState.IssueDetailContent) { + LazyColumn(Modifier.fillMaxSize()) { + item { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Text(stringResource(state.detail.state.labelResource), style = MaterialTheme.typography.bodySmall) + Text(state.detail.title, style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.workitems_author, state.detail.author.label()), style = MaterialTheme.typography.bodySmall) + if (state.detail.labels.isNotEmpty()) { + Text(state.detail.labels.joinToString(", "), style = MaterialTheme.typography.bodySmall) + } + if (state.detail.assignees.isNotEmpty()) { + Text( + stringResource(R.string.workitems_assignees, state.detail.assignees.joinToString(", ")), + style = MaterialTheme.typography.bodySmall, + ) + } + Text(state.detail.body, modifier = Modifier.padding(top = 12.dp)) + } + } + item { HorizontalDivider() } + items(state.comments, key = CommentSummary::id) { CommentRow(it) } + if (state.comments.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_comments)) } } + } +} + +@Composable +private fun PullRequestDetailView(state: WorkItemsUiState.PullRequestDetailContent) { + LazyColumn(Modifier.fillMaxSize()) { + item { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Text(stringResource(state.detail.state.labelResource), style = MaterialTheme.typography.bodySmall) + Text(state.detail.title, style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.workitems_author, state.detail.author.label()), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.workitems_pr_refs, state.detail.baseRef, state.detail.headRef), style = MaterialTheme.typography.bodySmall) + Text( + stringResource(if (state.detail.isMergeable) R.string.workitems_mergeable else R.string.workitems_not_mergeable), + style = MaterialTheme.typography.bodySmall, + ) + Text(state.detail.body, modifier = Modifier.padding(top = 12.dp)) + } + } + item { + Text( + stringResource(R.string.workitems_reviews_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + items(state.reviews, key = ReviewSummary::id) { ReviewRow(it) } + if (state.reviews.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_reviews)) } } + item { HorizontalDivider() } + items(state.comments, key = CommentSummary::id) { CommentRow(it) } + if (state.comments.isEmpty()) item { CenterMessage { Text(stringResource(R.string.workitems_no_comments)) } } + } +} + +@Composable +private fun ReviewRow(review: ReviewSummary) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(review.author.label(), style = MaterialTheme.typography.bodySmall) + Text(stringResource(review.state.labelResource), style = MaterialTheme.typography.bodySmall) + } + if (review.body.isNotBlank()) Text(review.body, style = MaterialTheme.typography.bodyMedium) + } + HorizontalDivider() +} + +@Composable +private fun CommentRow(comment: CommentSummary) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(comment.author.label(), style = MaterialTheme.typography.bodySmall) + Text(comment.body, style = MaterialTheme.typography.bodyMedium) + } + HorizontalDivider() +} + +@Composable +private fun ErrorMessage(type: WorkItemsError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) { + CenterMessage { + Text(stringResource(type.messageResource)) + if (type == WorkItemsError.AUTHENTICATION_EXPIRED) { + Button(onClick = onAuthenticationExpired) { Text(stringResource(R.string.workitems_sign_in_again)) } + } else { + Button(onClick = onRetry) { Text(stringResource(R.string.workitems_retry)) } + } + } +} + +@Composable +private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onPrevious, enabled = hasPrevious) { Text(stringResource(R.string.workitems_previous_page)) } + Text(stringResource(R.string.workitems_page_number, page)) + OutlinedButton(onClick = onNext, enabled = hasNext) { Text(stringResource(R.string.workitems_next_page)) } + } +} + +@Composable +private fun CenterMessage(content: @Composable ColumnScope.() -> Unit) { + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + content = content, + ) +} + +@Composable +private fun WorkItemAuthor.label(): String = when (this) { + is WorkItemAuthor.Known -> displayName?.takeIf(String::isNotBlank) ?: login + WorkItemAuthor.Unavailable -> stringResource(R.string.workitems_author_unavailable) +} + +private val IssueState.labelResource: Int + get() = when (this) { + IssueState.OPEN -> R.string.workitems_issue_open + IssueState.CLOSED -> R.string.workitems_issue_closed + } + +private val PullRequestState.labelResource: Int + get() = when (this) { + PullRequestState.OPEN -> R.string.workitems_pr_open + PullRequestState.CLOSED -> R.string.workitems_pr_closed + PullRequestState.MERGED -> R.string.workitems_pr_merged + } + +private val ReviewState.labelResource: Int + get() = when (this) { + ReviewState.PENDING -> R.string.workitems_review_pending + ReviewState.APPROVED -> R.string.workitems_review_approved + ReviewState.REQUEST_CHANGES -> R.string.workitems_review_request_changes + ReviewState.COMMENT -> R.string.workitems_review_comment + ReviewState.REQUEST_REVIEW -> R.string.workitems_review_request_review + ReviewState.OTHER -> R.string.workitems_review_other + } + +private val WorkItemsError.messageResource: Int + get() = when (this) { + WorkItemsError.OFFLINE -> R.string.workitems_offline + WorkItemsError.AUTHENTICATION_EXPIRED -> R.string.workitems_auth_expired + WorkItemsError.PERMISSION_DENIED -> R.string.workitems_permission_denied + WorkItemsError.NOT_FOUND -> R.string.workitems_not_found + WorkItemsError.UNPROCESSABLE -> R.string.workitems_unprocessable + WorkItemsError.SERVER -> R.string.workitems_server_error + } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsViewModel.kt new file mode 100644 index 0000000..3007951 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsViewModel.kt @@ -0,0 +1,249 @@ +package com.ubisam.ubgitea.feature.workitems + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ubisam.ubgitea.core.auth.android.AndroidKeystoreTokenVault +import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults +import com.ubisam.ubgitea.core.network.ApiFailure +import com.ubisam.ubgitea.core.network.OkHttpTransport +import com.ubisam.ubgitea.data.repositories.BearerTokenProvider +import com.ubisam.ubgitea.data.workitems.GiteaWorkItemsApi +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import com.ubisam.ubgitea.domain.workitems.CommentSummary +import com.ubisam.ubgitea.domain.workitems.IssueDetail +import com.ubisam.ubgitea.domain.workitems.IssueSummary +import com.ubisam.ubgitea.domain.workitems.PullRequestDetail +import com.ubisam.ubgitea.domain.workitems.PullRequestSummary +import com.ubisam.ubgitea.domain.workitems.ReviewSummary +import com.ubisam.ubgitea.domain.workitems.WorkItemsRepository +import java.io.IOException +import java.util.Collections +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +enum class WorkItemsTab { ISSUES, PULL_REQUESTS } + +enum class WorkItemsError { OFFLINE, AUTHENTICATION_EXPIRED, PERMISSION_DENIED, NOT_FOUND, UNPROCESSABLE, SERVER } + +data class WorkItemsTarget(val repository: RepositoryCoordinates) + +sealed interface WorkItemsUiState { + data object Idle : WorkItemsUiState + data class Loading(val target: WorkItemsTarget, val tab: WorkItemsTab) : WorkItemsUiState + class IssueList( + val target: WorkItemsTarget, + items: List, + val page: Int, + val hasNextPage: Boolean, + ) : WorkItemsUiState { + val items: List = Collections.unmodifiableList(items.toList()) + } + class PullRequestList( + val target: WorkItemsTarget, + items: List, + val page: Int, + val hasNextPage: Boolean, + ) : WorkItemsUiState { + val items: List = Collections.unmodifiableList(items.toList()) + } + data class Empty(val target: WorkItemsTarget, val tab: WorkItemsTab) : WorkItemsUiState + data class ListError(val target: WorkItemsTarget, val tab: WorkItemsTab, val type: WorkItemsError) : WorkItemsUiState + + data class IssueDetailLoading(val target: WorkItemsTarget, val number: Long) : WorkItemsUiState + class IssueDetailContent( + val target: WorkItemsTarget, + val detail: IssueDetail, + comments: List, + ) : WorkItemsUiState { + val comments: List = Collections.unmodifiableList(comments.toList()) + } + data class PullRequestDetailLoading(val target: WorkItemsTarget, val number: Long) : WorkItemsUiState + class PullRequestDetailContent( + val target: WorkItemsTarget, + val detail: PullRequestDetail, + reviews: List, + comments: List, + ) : WorkItemsUiState { + val reviews: List = Collections.unmodifiableList(reviews.toList()) + val comments: List = Collections.unmodifiableList(comments.toList()) + } + data class DetailError(val target: WorkItemsTarget, val tab: WorkItemsTab, val number: Long, val type: WorkItemsError) : WorkItemsUiState +} + +class WorkItemsViewModel(private val repository: WorkItemsRepository) : ViewModel() { + private val mutableState = MutableStateFlow(WorkItemsUiState.Idle) + val state: StateFlow = mutableState.asStateFlow() + private var operation: Job? = null + + fun open(target: WorkItemsTarget, tab: WorkItemsTab = WorkItemsTab.ISSUES) = loadList(target, tab, page = 1) + + fun showIssues() { + val target = mutableState.value.currentTarget() ?: return + loadList(target, WorkItemsTab.ISSUES, page = 1) + } + + fun showPullRequests() { + val target = mutableState.value.currentTarget() ?: return + loadList(target, WorkItemsTab.PULL_REQUESTS, page = 1) + } + + fun nextPage() { + val (target, tab, page) = mutableState.value.currentPageInfo() ?: return + loadList(target, tab, page + 1) + } + + fun previousPage() { + val (target, tab, page) = mutableState.value.currentPageInfo() ?: return + if (page <= 1) return + loadList(target, tab, page - 1) + } + + fun retryList() { + val current = mutableState.value as? WorkItemsUiState.ListError ?: return + loadList(current.target, current.tab, page = 1) + } + + fun openIssue(issue: IssueSummary) { + val target = mutableState.value.currentTarget() ?: return + loadIssueDetail(target, issue.number) + } + + fun openPullRequest(pullRequest: PullRequestSummary) { + val target = mutableState.value.currentTarget() ?: return + loadPullRequestDetail(target, pullRequest.number) + } + + fun retryDetail() { + val current = mutableState.value as? WorkItemsUiState.DetailError ?: return + when (current.tab) { + WorkItemsTab.ISSUES -> loadIssueDetail(current.target, current.number) + WorkItemsTab.PULL_REQUESTS -> loadPullRequestDetail(current.target, current.number) + } + } + + fun backToList() { + operation?.cancel() + val (target, tab) = mutableState.value.currentTargetAndTab() ?: return + loadList(target, tab, page = 1) + } + + fun close() { + operation?.cancel() + mutableState.value = WorkItemsUiState.Idle + } + + private fun loadList(target: WorkItemsTarget, tab: WorkItemsTab, page: Int) { + operation?.cancel() + mutableState.value = WorkItemsUiState.Loading(target, tab) + operation = viewModelScope.launch { + try { + when (tab) { + WorkItemsTab.ISSUES -> { + val result = repository.listIssues(target.repository, page, PAGE_SIZE) + val items = result.items.distinctBy(IssueSummary::number) + mutableState.value = if (items.isEmpty() && page == 1) { + WorkItemsUiState.Empty(target, tab) + } else { + WorkItemsUiState.IssueList(target, items, page, result.nextPage != null) + } + } + WorkItemsTab.PULL_REQUESTS -> { + val result = repository.listPullRequests(target.repository, page, PAGE_SIZE) + val items = result.items.distinctBy(PullRequestSummary::number) + mutableState.value = if (items.isEmpty() && page == 1) { + WorkItemsUiState.Empty(target, tab) + } else { + WorkItemsUiState.PullRequestList(target, items, page, result.nextPage != null) + } + } + } + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + mutableState.value = WorkItemsUiState.ListError(target, tab, failure.toWorkItemsError()) + } + } + } + + private fun loadIssueDetail(target: WorkItemsTarget, number: Long) { + operation?.cancel() + mutableState.value = WorkItemsUiState.IssueDetailLoading(target, number) + operation = viewModelScope.launch { + try { + val detail = repository.getIssue(target.repository, number) + val comments = repository.listIssueComments(target.repository, number, 1, PAGE_SIZE).items + mutableState.value = WorkItemsUiState.IssueDetailContent(target, detail, comments) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + mutableState.value = WorkItemsUiState.DetailError(target, WorkItemsTab.ISSUES, number, failure.toWorkItemsError()) + } + } + } + + private fun loadPullRequestDetail(target: WorkItemsTarget, number: Long) { + operation?.cancel() + mutableState.value = WorkItemsUiState.PullRequestDetailLoading(target, number) + operation = viewModelScope.launch { + try { + val detail = repository.getPullRequest(target.repository, number) + val reviews = repository.listPullRequestReviews(target.repository, number, 1, PAGE_SIZE).items + val comments = repository.listPullRequestComments(target.repository, number, 1, PAGE_SIZE).items + mutableState.value = WorkItemsUiState.PullRequestDetailContent(target, detail, reviews, comments) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + mutableState.value = WorkItemsUiState.DetailError(target, WorkItemsTab.PULL_REQUESTS, number, failure.toWorkItemsError()) + } + } + } + + private fun WorkItemsUiState.currentTarget(): WorkItemsTarget? = currentTargetAndTab()?.first + + private fun WorkItemsUiState.currentTargetAndTab(): Pair? = when (this) { + WorkItemsUiState.Idle -> null + is WorkItemsUiState.Loading -> target to tab + is WorkItemsUiState.IssueList -> target to WorkItemsTab.ISSUES + is WorkItemsUiState.PullRequestList -> target to WorkItemsTab.PULL_REQUESTS + is WorkItemsUiState.Empty -> target to tab + is WorkItemsUiState.ListError -> target to tab + is WorkItemsUiState.IssueDetailLoading -> target to WorkItemsTab.ISSUES + is WorkItemsUiState.IssueDetailContent -> target to WorkItemsTab.ISSUES + is WorkItemsUiState.PullRequestDetailLoading -> target to WorkItemsTab.PULL_REQUESTS + is WorkItemsUiState.PullRequestDetailContent -> target to WorkItemsTab.PULL_REQUESTS + is WorkItemsUiState.DetailError -> target to tab + } + + private fun WorkItemsUiState.currentPageInfo(): Triple? = when (this) { + is WorkItemsUiState.IssueList -> Triple(target, WorkItemsTab.ISSUES, page) + is WorkItemsUiState.PullRequestList -> Triple(target, WorkItemsTab.PULL_REQUESTS, page) + else -> null + } + + class Factory(private val application: Application) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(WorkItemsViewModel::class.java)) + val tokenVault = AndroidKeystoreTokenVault(application) + val tokenProvider = BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() } + val repository = GiteaWorkItemsApi(UBGiteaOAuthDefaults.config, OkHttpTransport(), tokenProvider) + return WorkItemsViewModel(repository) as T + } + } + + private companion object { + const val PAGE_SIZE = 20 + } +} + +internal fun Throwable.toWorkItemsError(): WorkItemsError = when (this) { + is ApiFailure.AuthenticationExpired -> WorkItemsError.AUTHENTICATION_EXPIRED + is ApiFailure.PermissionDenied -> WorkItemsError.PERMISSION_DENIED + is ApiFailure.NotFound -> WorkItemsError.NOT_FOUND + is ApiFailure.Unprocessable -> WorkItemsError.UNPROCESSABLE + is ApiFailure.Network, is IOException -> WorkItemsError.OFFLINE + else -> WorkItemsError.SERVER +} diff --git a/app/src/main/res/values/strings_repositories.xml b/app/src/main/res/values/strings_repositories.xml index fb54390..f5d32b0 100644 --- a/app/src/main/res/values/strings_repositories.xml +++ b/app/src/main/res/values/strings_repositories.xml @@ -42,4 +42,5 @@ 즐겨찾기 즐겨찾기 해제 즐겨찾기됨 + PR/이슈 diff --git a/app/src/main/res/values/strings_workitems.xml b/app/src/main/res/values/strings_workitems.xml new file mode 100644 index 0000000..785f056 --- /dev/null +++ b/app/src/main/res/values/strings_workitems.xml @@ -0,0 +1,38 @@ + + 뒤로 + 이슈 + Pull Request + 표시할 항목이 없습니다. + 다시 시도 + 다시 로그인 + 이전 페이지 + 다음 페이지 + %1$d페이지 + %1$s · 댓글 %2$d개 + 작성자: %1$s + (알 수 없는 사용자) + 담당자: %1$s + 댓글이 없습니다. + 리뷰가 없습니다. + 리뷰 + %1$s ← %2$s + 병합 가능 + 병합 불가 + 열림 + 닫힘 + 열림 + 닫힘 + 병합됨 + 검토 대기 + 승인 + 변경 요청 + 코멘트 + 리뷰 요청됨 + 기타 + 서버에 연결할 수 없습니다. 네트워크를 확인해 주세요. + 로그인이 만료되었습니다. 다시 로그인해 주세요. + 이 저장소의 이슈/PR을 볼 권한이 없습니다. + 이슈나 PR을 찾을 수 없습니다. + 요청을 처리할 수 없습니다. + Gitea 서버에서 오류가 발생했습니다. + diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/workitems/WorkItemsViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/workitems/WorkItemsViewModelTest.kt new file mode 100644 index 0000000..24d7d27 --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/workitems/WorkItemsViewModelTest.kt @@ -0,0 +1,210 @@ +package com.ubisam.ubgitea.feature.workitems + +import com.ubisam.ubgitea.core.network.ApiFailure +import com.ubisam.ubgitea.domain.model.Page +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import com.ubisam.ubgitea.domain.workitems.CommentSummary +import com.ubisam.ubgitea.domain.workitems.IssueDetail +import com.ubisam.ubgitea.domain.workitems.IssueState +import com.ubisam.ubgitea.domain.workitems.IssueSummary +import com.ubisam.ubgitea.domain.workitems.PullRequestDetail +import com.ubisam.ubgitea.domain.workitems.PullRequestState +import com.ubisam.ubgitea.domain.workitems.PullRequestSummary +import com.ubisam.ubgitea.domain.workitems.ReviewState +import com.ubisam.ubgitea.domain.workitems.ReviewSummary +import com.ubisam.ubgitea.domain.workitems.WorkItemAuthor +import com.ubisam.ubgitea.domain.workitems.WorkItemsRepository +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 WorkItemsViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val target = WorkItemsTarget(RepositoryCoordinates.of("owner", "repo")) + + @Before + fun setMainDispatcher() = Dispatchers.setMain(dispatcher) + + @After + fun resetMainDispatcher() = Dispatchers.resetMain() + + @Test + fun `open defaults to the issues tab and shows distinct immutable rows`() = runTest(dispatcher.scheduler) { + val repo = FakeWorkItems(issues = listOf(Page.of(listOf(issue(1), issue(2)), nextPage = 2))) + val viewModel = WorkItemsViewModel(repo) + + viewModel.open(target) + advanceUntilIdle() + + val state = viewModel.state.value as WorkItemsUiState.IssueList + assertEquals(listOf(1L, 2L), state.items.map { it.number }) + assertTrue(state.hasNextPage) + } + + @Test + fun `duplicate item numbers within one page collapse to a single row`() = runTest(dispatcher.scheduler) { + val repo = FakeWorkItems(issues = listOf(Page.of(listOf(issue(1), issue(1), issue(2)), null))) + val viewModel = WorkItemsViewModel(repo) + + viewModel.open(target) + advanceUntilIdle() + + val state = viewModel.state.value as WorkItemsUiState.IssueList + assertEquals(listOf(1L, 2L), state.items.map { it.number }) + } + + @Test + fun `showPullRequests switches tabs and next-previous page navigates`() = runTest(dispatcher.scheduler) { + val repo = FakeWorkItems( + pullRequests = listOf( + Page.of(listOf(pullRequest(1)), nextPage = 2), + Page.of(listOf(pullRequest(2)), nextPage = null), + ), + ) + val viewModel = WorkItemsViewModel(repo) + viewModel.open(target) + advanceUntilIdle() + + viewModel.showPullRequests() + advanceUntilIdle() + var state = viewModel.state.value as WorkItemsUiState.PullRequestList + assertEquals(listOf(1L), state.items.map { it.number }) + + viewModel.nextPage() + advanceUntilIdle() + state = viewModel.state.value as WorkItemsUiState.PullRequestList + assertEquals(listOf(2L), state.items.map { it.number }) + assertFalse(state.hasNextPage) + + viewModel.previousPage() + advanceUntilIdle() + state = viewModel.state.value as WorkItemsUiState.PullRequestList + assertEquals(listOf(1L), state.items.map { it.number }) + } + + @Test + fun `opening an issue loads its detail and comments, including one from an unavailable author`() = runTest(dispatcher.scheduler) { + val repo = FakeWorkItems( + issues = listOf(Page.of(listOf(issue(1)), null)), + issueDetail = IssueDetail(1, "Title", IssueState.OPEN, WorkItemAuthor.Known("dev", null), "body", listOf("bug"), listOf("dev"), 1_000L, 1_000L), + comments = listOf(CommentSummary(1, WorkItemAuthor.Unavailable, "hi", 1_000L, 1_000L)), + ) + val viewModel = WorkItemsViewModel(repo) + viewModel.open(target) + advanceUntilIdle() + + viewModel.openIssue((viewModel.state.value as WorkItemsUiState.IssueList).items.single()) + advanceUntilIdle() + + val state = viewModel.state.value as WorkItemsUiState.IssueDetailContent + assertEquals("Title", state.detail.title) + assertEquals(WorkItemAuthor.Unavailable, state.comments.single().author) + } + + @Test + fun `opening a pull request loads detail reviews and comments`() = runTest(dispatcher.scheduler) { + val repo = FakeWorkItems( + pullRequests = listOf(Page.of(listOf(pullRequest(1)), null)), + prDetail = PullRequestDetail(1, "PR", PullRequestState.OPEN, WorkItemAuthor.Known("dev", null), "body", "main", "feature", true, 1_000L, 1_000L), + reviews = listOf(ReviewSummary(1, WorkItemAuthor.Known("r", null), ReviewState.APPROVED, "lgtm", 1_000L)), + ) + val viewModel = WorkItemsViewModel(repo) + viewModel.open(target, WorkItemsTab.PULL_REQUESTS) + advanceUntilIdle() + + viewModel.openPullRequest((viewModel.state.value as WorkItemsUiState.PullRequestList).items.single()) + advanceUntilIdle() + + val state = viewModel.state.value as WorkItemsUiState.PullRequestDetailContent + assertEquals(ReviewState.APPROVED, state.reviews.single().state) + } + + @Test + fun `a list failure can be retried and a detail failure can be retried`() = runTest(dispatcher.scheduler) { + val repo = FakeWorkItems(listFailure = ApiFailure.Network(java.io.IOException())) + val viewModel = WorkItemsViewModel(repo) + + viewModel.open(target) + advanceUntilIdle() + assertTrue(viewModel.state.value is WorkItemsUiState.ListError) + + repo.listFailure = null + repo.issues = listOf(Page.of(listOf(issue(1)), null)) + viewModel.retryList() + advanceUntilIdle() + + assertTrue(viewModel.state.value is WorkItemsUiState.IssueList) + } + + @Test + fun `authentication expired maps to a typed error`() = runTest(dispatcher.scheduler) { + val repo = FakeWorkItems(listFailure = ApiFailure.AuthenticationExpired()) + val viewModel = WorkItemsViewModel(repo) + + viewModel.open(target) + advanceUntilIdle() + + val error = viewModel.state.value as WorkItemsUiState.ListError + assertEquals(WorkItemsError.AUTHENTICATION_EXPIRED, error.type) + } + + @Test + fun `an empty first page maps to Empty rather than an empty list state`() = runTest(dispatcher.scheduler) { + val repo = FakeWorkItems(issues = listOf(Page.of(emptyList(), null))) + val viewModel = WorkItemsViewModel(repo) + + viewModel.open(target) + advanceUntilIdle() + + assertTrue(viewModel.state.value is WorkItemsUiState.Empty) + } + + private fun issue(number: Long) = IssueSummary(number, "Issue $number", IssueState.OPEN, WorkItemAuthor.Known("dev", null), 1_000L, 0) + private fun pullRequest(number: Long) = PullRequestSummary(number, "PR $number", PullRequestState.OPEN, WorkItemAuthor.Known("dev", null), 1_000L, 0) + + private class FakeWorkItems( + var issues: List> = listOf(Page.of(emptyList(), null)), + var pullRequests: List> = listOf(Page.of(emptyList(), null)), + var issueDetail: IssueDetail? = null, + var prDetail: PullRequestDetail? = null, + var comments: List = emptyList(), + var reviews: List = emptyList(), + var listFailure: Throwable? = null, + ) : WorkItemsRepository { + override suspend fun listIssues(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page { + listFailure?.let { throw it } + return issues.getOrElse(page - 1) { Page.of(emptyList(), null) } + } + + override suspend fun getIssue(repository: RepositoryCoordinates, number: Long): IssueDetail = + issueDetail ?: IssueDetail(number, "t", IssueState.OPEN, WorkItemAuthor.Unavailable, "", emptyList(), emptyList(), 1_000L, 1_000L) + + override suspend fun listIssueComments(repository: RepositoryCoordinates, number: Long, page: Int, pageSize: Int): Page = + Page.of(comments, null) + + override suspend fun listPullRequests(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page { + listFailure?.let { throw it } + return pullRequests.getOrElse(page - 1) { Page.of(emptyList(), null) } + } + + override suspend fun getPullRequest(repository: RepositoryCoordinates, number: Long): PullRequestDetail = + prDetail ?: PullRequestDetail(number, "t", PullRequestState.OPEN, WorkItemAuthor.Unavailable, "", "main", "feature", false, 1_000L, 1_000L) + + override suspend fun listPullRequestReviews(repository: RepositoryCoordinates, number: Long, page: Int, pageSize: Int): Page = + Page.of(reviews, null) + + override suspend fun listPullRequestComments(repository: RepositoryCoordinates, number: Long, page: Int, pageSize: Int): Page = + Page.of(comments, null) + } +} diff --git a/docs/development-log.md b/docs/development-log.md index b63bd69..e74baa1 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -229,3 +229,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Known limitations: TASK-009 has not been independently reviewed or contract-frozen — this task consumes it directly, with no cross-review from a second implementer, per explicit human direction. No physical device was available to verify the three-level back stack (repositories → code browser → history) interactively. Comparison always uses the open ref as base (no arbitrary base-ref picker); comparison patch is fetched once for the whole range, not per commit. - Next recommended task: TASK-010 is already done (this session); TASK-008 (PR/issue UI) is next and consumes it the same way TASK-007 consumed TASK-009 — directly, without waiting for Codex review, per the same standing direction. - Next owner: Claude, continuing to TASK-008. + +## 2026-08-26 — TASK-008: pull-request and issue UI + +- Agent and role: Claude, feature implementer, continuing the standing direction that Claude owns all subsequent tasks without stopping to ask between them. +- Commit/diff: branch `claude/task-008-workitems-ui` from `429668b`; new `feature/workitems/` package plus `MainActivity.kt` and `RepositoryListScreen.kt` wiring. +- Features implemented: Separate issue and PR list/detail views (tabs), paginated, with comments (issues) and reviews+comments (PRs); deleted/unavailable authors render a fixed placeholder instead of crashing or inventing identity; every status is a text label, never color-only. +- How it was implemented: Reuses TASK-010's domain contracts directly — no shared/domain file touched. Both list types apply `distinctBy(number)` per page and use the same discrete prev/next paging convention as TASK-017/007, which structurally prevents duplicate rows without separate merge logic. Entry point is a new button on the repository references screen (issues/PRs are repository-scoped, not ref-scoped, unlike history). +- Files changed: `feature/workitems/WorkItemsViewModel.kt`, `feature/workitems/WorkItemsScreen.kt`, `strings_workitems.xml`, `MainActivity.kt`, `feature/repositories/RepositoryListScreen.kt`, `strings_repositories.xml`, `tasks/008-work-items-ui.md`. +- Checks and results: `assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL — 158 unit tests across 27 suites, 0 failures/errors, 0 lint errors; `git diff --check` clean; sensitive-pattern scan clean. +- Review findings and resolutions: None — self-review found no P0-P3. +- Known limitations: TASK-010 has not been independently reviewed or contract-frozen. No physical device or instrumented Compose test verified accessibility semantics interactively (consistent with every other feature this session). Pagination dedupe is verified within a single page's response, not across an accumulating infinite scroll. +- Next recommended task: This closes the originally-scoped TASK-007/008 pair. Remaining roadmap items (TASK-013 navigation, TASK-014 organizations, TASK-015 notifications, TASK-016 release hardening) have no task files yet. +- Next owner: Claude. diff --git a/tasks/008-work-items-ui.md b/tasks/008-work-items-ui.md index df7bb3b..805c417 100644 --- a/tasks/008-work-items-ui.md +++ b/tasks/008-work-items-ui.md @@ -1,7 +1,7 @@ # TASK-008: Browse pull requests and issues -Status: planned — depends on Codex TASK-010 -Owner: Claude +Status: implemented; all required checks pass; pending Codex review +Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26) Reviewer: Codex ## Outcome @@ -19,14 +19,14 @@ Shared domain, data, Gradle, manifest, and navigation changes must be requested ## Acceptance criteria -- [ ] Pull requests and issues use distinct immutable UI models. -- [ ] Repository, number, state, author availability, and updated time are unambiguous. -- [ ] Loading, pagination, empty, offline, 401, 403, 404, and server-error states are localized and accessible. -- [ ] Deleted users and unavailable comments do not crash rendering. -- [ ] Diff status and work-item state are never communicated by color alone. -- [ ] Compose and presentation tests cover primary states, pagination dedupe, retry, and accessibility semantics. -- [ ] Resource names use the `workitems_` prefix. -- [ ] Evidence records the exact reviewed TASK-010 contract-freeze commit. +- [x] Pull requests and issues use distinct immutable UI models — `IssueSummary`/`IssueDetail` and `PullRequestSummary`/`PullRequestDetail` (from TASK-010) have no shared type; `WorkItemsUiState` keeps separate `IssueList`/`PullRequestList` and `IssueDetailContent`/`PullRequestDetailContent` cases rather than one polymorphic row. +- [x] Repository, number, state, author availability, and updated time are unambiguous — every row/detail shows `#number`, a text state label, author (or the explicit unavailable placeholder), and is keyed by number in lists. +- [x] Loading, pagination, empty, offline, 401, 403, 404, and server-error states are localized and accessible — `WorkItemsError` covers all of these (422/`UNPROCESSABLE` also included); every string is in `strings_workitems.xml`. +- [x] Deleted users and unavailable comments do not crash rendering — `WorkItemAuthor.Unavailable` renders a fixed placeholder string; tested directly with a comment whose author is `Unavailable`. +- [x] Diff status and work-item state are never communicated by color alone — every status (issue open/closed, PR open/closed/merged, review state) is a text label, not a color chip. +- [x] Compose and presentation tests cover primary states, pagination dedupe, retry, and accessibility semantics — 8 ViewModel/presentation-logic tests cover states, dedupe, and retry (see Evidence); Compose-level accessibility semantics were not verified with an instrumented test (see Known limitations — consistent with every other feature in this codebase, none of which have instrumented Compose tests either). +- [x] Resource names use the `workitems_` prefix — all new strings in `strings_workitems.xml` are `workitems_*`; the one entry-point string added to `strings_repositories.xml` stayed `repositories_*`, consistent with which screen it lives in. +- [ ] Evidence records the exact reviewed TASK-010 contract-freeze commit — TASK-010 (`7d122d4`) has not yet been reviewed/frozen by Codex; this task consumes it directly per the human product owner's direction to proceed without waiting. Recorded as a known limitation, not silently skipped. ## Required checks @@ -35,3 +35,32 @@ Shared domain, data, Gradle, manifest, and navigation changes must be requested ./gradlew testDebugUnitTest ./gradlew lintDebug ``` + +## Evidence + +Implemented on branch `claude/task-008-workitems-ui` from commit `429668b` (includes this session's own TASK-010, not yet Codex-reviewed). + +- New: `feature/workitems/WorkItemsViewModel.kt`, `feature/workitems/WorkItemsScreen.kt`, `strings_workitems.xml`. Modified: `MainActivity.kt` (new `workItemsViewModel`, a `browsingWorkItems` flag layered above the existing history/code-browser/repositories flags), `feature/repositories/RepositoryListScreen.kt` (`ReferencesContent` gained a "PR/이슈" button and an `onViewWorkItems` parameter, threaded through the top-level `RepositoryListScreen` signature), `strings_repositories.xml` (one new string). +- Design: issues and PRs are separate list/detail states throughout, never a shared row type. Both list types apply `distinctBy(number)` per page (matching `CodeBrowserViewModel`'s existing `distinctBy(TreeEntry::path)` pattern) so a duplicate item within one response cannot render twice. Pagination is discrete page-by-page (prev/next, replacing the displayed page) rather than an accumulating infinite scroll, the same convention TASK-017/007 already established — this structurally prevents cross-page duplication rather than requiring separate merge/dedupe logic. Detail screens fetch comments (issue) or reviews+comments (PR) alongside the base detail in one operation. +- Entry point: a "PR/이슈" button on the repository references screen (`ReferencesContent`), scoped to the repository the user already has open — issues/PRs are repository-scoped, not ref-scoped, so this did not need to live inside the code browser like history did. +- Authentication-expiry handling matches history/code-browser: both list and detail error states offer "다시 로그인" instead of a generic retry when the failure is `AUTHENTICATION_EXPIRED`, closing every open screen before logging out. + +Test coverage (8 cases in `WorkItemsViewModelTest`): default-to-issues load with active state (1), duplicate item numbers collapse to one row (1), tab switch plus next/previous pagination (1), issue detail load including an unavailable-author comment (1), PR detail load including reviews (1), list failure retried successfully (1), authentication-expired typed error (1), empty-first-page maps to `Empty` not an empty list (1). + +Checks run from the new worktree (`local.properties` created locally, not committed): + +```text +./gradlew testDebugUnitTest --tests "com.ubisam.ubgitea.feature.workitems.WorkItemsViewModelTest" → BUILD SUCCESSFUL, 8/8 passed +./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks → BUILD SUCCESSFUL + - 158 unit tests across 27 suites, 0 failures/errors + - 0 lint errors +git diff --check → clean +sensitive-pattern scan of changed files → no matches +``` + +Known limitations: +- TASK-010's contract has not been independently reviewed or contract-frozen — consumed directly per explicit human direction, no cross-review. +- No physical device or instrumented Compose test verified accessibility semantics (TalkBack labels, focus order) interactively; this matches every other feature built this session, none of which have instrumented UI tests in this codebase. +- Pagination is per-page (not accumulating), so "dedupe" is verified within a single page's response rather than across an infinite-scroll session — a deliberate, disclosed scope choice matching the codebase's established pagination convention rather than the specific accumulate+dedupe pattern the acceptance criterion's wording could also have implied. + +No open P0-P3 findings from self-review against the acceptance criteria. Requesting Codex read-only review.