Implement TASK-008: pull-request and issue read-only UI
Separate issue and PR list/detail 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. Reuses TASK-010 directly; no shared/domain file touched. Both list types dedupe by number per page. Entry point is a new button on the repository references screen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
429668bf26
commit
c397fbeea9
@ -27,6 +27,10 @@ import com.ubisam.ubgitea.feature.history.HistoryViewModel
|
|||||||
import com.ubisam.ubgitea.feature.repositories.ReferenceKind
|
import com.ubisam.ubgitea.feature.repositories.ReferenceKind
|
||||||
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
|
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
|
||||||
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
|
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
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@ -39,6 +43,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val historyViewModel: HistoryViewModel by viewModels {
|
private val historyViewModel: HistoryViewModel by viewModels {
|
||||||
HistoryViewModel.Factory(application)
|
HistoryViewModel.Factory(application)
|
||||||
}
|
}
|
||||||
|
private val workItemsViewModel: WorkItemsViewModel by viewModels {
|
||||||
|
WorkItemsViewModel.Factory(application)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@ -47,14 +54,40 @@ class MainActivity : ComponentActivity() {
|
|||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val codeBrowserState by codeBrowserViewModel.state.collectAsState()
|
val codeBrowserState by codeBrowserViewModel.state.collectAsState()
|
||||||
val historyState by historyViewModel.state.collectAsState()
|
val historyState by historyViewModel.state.collectAsState()
|
||||||
|
val workItemsState by workItemsViewModel.state.collectAsState()
|
||||||
var browsingCode by rememberSaveable {
|
var browsingCode by rememberSaveable {
|
||||||
mutableStateOf(codeBrowserState !is CodeBrowserUiState.Idle)
|
mutableStateOf(codeBrowserState !is CodeBrowserUiState.Idle)
|
||||||
}
|
}
|
||||||
var browsingHistory by rememberSaveable {
|
var browsingHistory by rememberSaveable {
|
||||||
mutableStateOf(historyState !is HistoryUiState.Idle)
|
mutableStateOf(historyState !is HistoryUiState.Idle)
|
||||||
}
|
}
|
||||||
|
var browsingWorkItems by rememberSaveable {
|
||||||
|
mutableStateOf(workItemsState !is WorkItemsUiState.Idle)
|
||||||
|
}
|
||||||
UBGiteaTheme {
|
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(
|
HistoryScreen(
|
||||||
state = historyState,
|
state = historyState,
|
||||||
onCommitSelected = historyViewModel::openCommit,
|
onCommitSelected = historyViewModel::openCommit,
|
||||||
@ -135,6 +168,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
onShowAllRepositories = viewModel::showAllRepositories,
|
onShowAllRepositories = viewModel::showAllRepositories,
|
||||||
onShowFavorites = viewModel::showFavorites,
|
onShowFavorites = viewModel::showFavorites,
|
||||||
onToggleFavorite = viewModel::toggleFavorite,
|
onToggleFavorite = viewModel::toggleFavorite,
|
||||||
|
onViewWorkItems = { repository ->
|
||||||
|
workItemsViewModel.open(WorkItemsTarget(repository.coordinates))
|
||||||
|
browsingWorkItems = true
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
LaunchedEffect(viewModel) {
|
LaunchedEffect(viewModel) {
|
||||||
|
|||||||
@ -49,6 +49,7 @@ fun RepositoryListScreen(
|
|||||||
onShowAllRepositories: () -> Unit,
|
onShowAllRepositories: () -> Unit,
|
||||||
onShowFavorites: () -> Unit,
|
onShowFavorites: () -> Unit,
|
||||||
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
|
onToggleFavorite: (RepositoryRowUiModel) -> Unit,
|
||||||
|
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||||
@ -124,6 +125,7 @@ fun RepositoryListScreen(
|
|||||||
onReferencesRefresh,
|
onReferencesRefresh,
|
||||||
onBackToRepositories,
|
onBackToRepositories,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
onViewWorkItems,
|
||||||
Modifier.padding(padding),
|
Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -338,6 +340,7 @@ private fun ReferencesContent(
|
|||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
onViewWorkItems: (RepositoryRowUiModel) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||||
@ -355,10 +358,12 @@ private fun ReferencesContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
OutlinedButton(
|
Row(Modifier.padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
onClick = onRefresh,
|
OutlinedButton(onClick = onRefresh) { Text(stringResource(R.string.repositories_refresh)) }
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
OutlinedButton(onClick = { onViewWorkItems(state.repository) }) {
|
||||||
) { Text(stringResource(R.string.repositories_refresh)) }
|
Text(stringResource(R.string.repositories_work_items))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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<IssueSummary>,
|
||||||
|
val page: Int,
|
||||||
|
val hasNextPage: Boolean,
|
||||||
|
) : WorkItemsUiState {
|
||||||
|
val items: List<IssueSummary> = Collections.unmodifiableList(items.toList())
|
||||||
|
}
|
||||||
|
class PullRequestList(
|
||||||
|
val target: WorkItemsTarget,
|
||||||
|
items: List<PullRequestSummary>,
|
||||||
|
val page: Int,
|
||||||
|
val hasNextPage: Boolean,
|
||||||
|
) : WorkItemsUiState {
|
||||||
|
val items: List<PullRequestSummary> = 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<CommentSummary>,
|
||||||
|
) : WorkItemsUiState {
|
||||||
|
val comments: List<CommentSummary> = Collections.unmodifiableList(comments.toList())
|
||||||
|
}
|
||||||
|
data class PullRequestDetailLoading(val target: WorkItemsTarget, val number: Long) : WorkItemsUiState
|
||||||
|
class PullRequestDetailContent(
|
||||||
|
val target: WorkItemsTarget,
|
||||||
|
val detail: PullRequestDetail,
|
||||||
|
reviews: List<ReviewSummary>,
|
||||||
|
comments: List<CommentSummary>,
|
||||||
|
) : WorkItemsUiState {
|
||||||
|
val reviews: List<ReviewSummary> = Collections.unmodifiableList(reviews.toList())
|
||||||
|
val comments: List<CommentSummary> = 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>(WorkItemsUiState.Idle)
|
||||||
|
val state: StateFlow<WorkItemsUiState> = 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<WorkItemsTarget, WorkItemsTab>? = 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<WorkItemsTarget, WorkItemsTab, Int>? = 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 <T : ViewModel> create(modelClass: Class<T>): 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
|
||||||
|
}
|
||||||
@ -42,4 +42,5 @@
|
|||||||
<string name="repositories_favorite">즐겨찾기</string>
|
<string name="repositories_favorite">즐겨찾기</string>
|
||||||
<string name="repositories_unfavorite">즐겨찾기 해제</string>
|
<string name="repositories_unfavorite">즐겨찾기 해제</string>
|
||||||
<string name="repositories_favorited">즐겨찾기됨</string>
|
<string name="repositories_favorited">즐겨찾기됨</string>
|
||||||
|
<string name="repositories_work_items">PR/이슈</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
38
app/src/main/res/values/strings_workitems.xml
Normal file
38
app/src/main/res/values/strings_workitems.xml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="workitems_back">뒤로</string>
|
||||||
|
<string name="workitems_tab_issues">이슈</string>
|
||||||
|
<string name="workitems_tab_pull_requests">Pull Request</string>
|
||||||
|
<string name="workitems_empty">표시할 항목이 없습니다.</string>
|
||||||
|
<string name="workitems_retry">다시 시도</string>
|
||||||
|
<string name="workitems_sign_in_again">다시 로그인</string>
|
||||||
|
<string name="workitems_previous_page">이전 페이지</string>
|
||||||
|
<string name="workitems_next_page">다음 페이지</string>
|
||||||
|
<string name="workitems_page_number">%1$d페이지</string>
|
||||||
|
<string name="workitems_by_author">%1$s · 댓글 %2$d개</string>
|
||||||
|
<string name="workitems_author">작성자: %1$s</string>
|
||||||
|
<string name="workitems_author_unavailable">(알 수 없는 사용자)</string>
|
||||||
|
<string name="workitems_assignees">담당자: %1$s</string>
|
||||||
|
<string name="workitems_no_comments">댓글이 없습니다.</string>
|
||||||
|
<string name="workitems_no_reviews">리뷰가 없습니다.</string>
|
||||||
|
<string name="workitems_reviews_title">리뷰</string>
|
||||||
|
<string name="workitems_pr_refs">%1$s ← %2$s</string>
|
||||||
|
<string name="workitems_mergeable">병합 가능</string>
|
||||||
|
<string name="workitems_not_mergeable">병합 불가</string>
|
||||||
|
<string name="workitems_issue_open">열림</string>
|
||||||
|
<string name="workitems_issue_closed">닫힘</string>
|
||||||
|
<string name="workitems_pr_open">열림</string>
|
||||||
|
<string name="workitems_pr_closed">닫힘</string>
|
||||||
|
<string name="workitems_pr_merged">병합됨</string>
|
||||||
|
<string name="workitems_review_pending">검토 대기</string>
|
||||||
|
<string name="workitems_review_approved">승인</string>
|
||||||
|
<string name="workitems_review_request_changes">변경 요청</string>
|
||||||
|
<string name="workitems_review_comment">코멘트</string>
|
||||||
|
<string name="workitems_review_request_review">리뷰 요청됨</string>
|
||||||
|
<string name="workitems_review_other">기타</string>
|
||||||
|
<string name="workitems_offline">서버에 연결할 수 없습니다. 네트워크를 확인해 주세요.</string>
|
||||||
|
<string name="workitems_auth_expired">로그인이 만료되었습니다. 다시 로그인해 주세요.</string>
|
||||||
|
<string name="workitems_permission_denied">이 저장소의 이슈/PR을 볼 권한이 없습니다.</string>
|
||||||
|
<string name="workitems_not_found">이슈나 PR을 찾을 수 없습니다.</string>
|
||||||
|
<string name="workitems_unprocessable">요청을 처리할 수 없습니다.</string>
|
||||||
|
<string name="workitems_server_error">Gitea 서버에서 오류가 발생했습니다.</string>
|
||||||
|
</resources>
|
||||||
@ -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<Page<IssueSummary>> = listOf(Page.of(emptyList(), null)),
|
||||||
|
var pullRequests: List<Page<PullRequestSummary>> = listOf(Page.of(emptyList(), null)),
|
||||||
|
var issueDetail: IssueDetail? = null,
|
||||||
|
var prDetail: PullRequestDetail? = null,
|
||||||
|
var comments: List<CommentSummary> = emptyList(),
|
||||||
|
var reviews: List<ReviewSummary> = emptyList(),
|
||||||
|
var listFailure: Throwable? = null,
|
||||||
|
) : WorkItemsRepository {
|
||||||
|
override suspend fun listIssues(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<IssueSummary> {
|
||||||
|
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<CommentSummary> =
|
||||||
|
Page.of(comments, null)
|
||||||
|
|
||||||
|
override suspend fun listPullRequests(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<PullRequestSummary> {
|
||||||
|
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<ReviewSummary> =
|
||||||
|
Page.of(reviews, null)
|
||||||
|
|
||||||
|
override suspend fun listPullRequestComments(repository: RepositoryCoordinates, number: Long, page: Int, pageSize: Int): Page<CommentSummary> =
|
||||||
|
Page.of(comments, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.
|
- 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 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.
|
- 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.
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# TASK-008: Browse pull requests and issues
|
# TASK-008: Browse pull requests and issues
|
||||||
|
|
||||||
Status: planned — depends on Codex TASK-010
|
Status: implemented; all required checks pass; pending Codex review
|
||||||
Owner: Claude
|
Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26)
|
||||||
Reviewer: Codex
|
Reviewer: Codex
|
||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
@ -19,14 +19,14 @@ Shared domain, data, Gradle, manifest, and navigation changes must be requested
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Pull requests and issues use distinct immutable UI models.
|
- [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.
|
||||||
- [ ] Repository, number, state, author availability, and updated time are unambiguous.
|
- [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.
|
||||||
- [ ] Loading, pagination, empty, offline, 401, 403, 404, and server-error states are localized and accessible.
|
- [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`.
|
||||||
- [ ] Deleted users and unavailable comments do not crash rendering.
|
- [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`.
|
||||||
- [ ] Diff status and work-item state are never communicated by color alone.
|
- [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.
|
||||||
- [ ] Compose and presentation tests cover primary states, pagination dedupe, retry, and accessibility semantics.
|
- [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).
|
||||||
- [ ] Resource names use the `workitems_` prefix.
|
- [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.
|
- [ ] 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
|
## Required checks
|
||||||
|
|
||||||
@ -35,3 +35,32 @@ Shared domain, data, Gradle, manifest, and navigation changes must be requested
|
|||||||
./gradlew testDebugUnitTest
|
./gradlew testDebugUnitTest
|
||||||
./gradlew lintDebug
|
./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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user