choiminyeong f14be3ac90 Add TASK-015: read-only Gitea notifications
Signed-in users can view notifications (unread-only or all), paginated,
and jump from an issue/PR notification to that repository's work items.
No mark-read/unread/pinned write endpoint is exposed, matching the
existing single-write-scope posture from ADR-004.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:55:19 +09:00

243 lines
12 KiB
Kotlin

package com.ubisam.ubgitea
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserScreen
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
import com.ubisam.ubgitea.feature.history.HistoryScreen
import com.ubisam.ubgitea.feature.history.HistoryTarget
import com.ubisam.ubgitea.feature.history.HistoryViewModel
import com.ubisam.ubgitea.feature.notifications.NotificationsScreen
import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel
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.WorkItemsViewModel
import com.ubisam.ubgitea.ui.navigation.Route
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
class MainActivity : ComponentActivity() {
private val viewModel: RepositoryListViewModel by viewModels {
RepositoryListViewModel.Factory(application)
}
private val codeBrowserViewModel: CodeBrowserViewModel by viewModels {
CodeBrowserViewModel.Factory(application)
}
private val historyViewModel: HistoryViewModel by viewModels {
HistoryViewModel.Factory(application)
}
private val workItemsViewModel: WorkItemsViewModel by viewModels {
WorkItemsViewModel.Factory(application)
}
private val notificationsViewModel: NotificationsViewModel by viewModels {
NotificationsViewModel.Factory(application)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val state by viewModel.state.collectAsState()
val codeBrowserState by codeBrowserViewModel.state.collectAsState()
val historyState by historyViewModel.state.collectAsState()
val workItemsState by workItemsViewModel.state.collectAsState()
val notificationsState by notificationsViewModel.state.collectAsState()
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
mutableStateOf(listOf<Route>(Route.Repositories))
}
fun push(route: Route) { routeStack = routeStack + route }
fun popToRepositories() { routeStack = listOf(Route.Repositories) }
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Repositories) } }
val currentRoute = routeStack.last()
LaunchedEffect(currentRoute) {
when (val route = currentRoute) {
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
is Route.CodeBrowser -> codeBrowserViewModel.open(
CodeBrowserTarget(
repository = RepositoryCoordinates.of(route.owner, route.name),
ref = RepositoryRef.of(route.ref),
kind = CodeReferenceKind.valueOf(route.refKind),
),
)
is Route.History -> historyViewModel.open(
HistoryTarget(RepositoryCoordinates.of(route.owner, route.name), RepositoryRef.of(route.ref)),
)
is Route.WorkItems -> workItemsViewModel.open(
WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name)),
)
Route.Notifications -> notificationsViewModel.open()
}
}
fun authenticationExpired() {
codeBrowserViewModel.close()
historyViewModel.close()
workItemsViewModel.close()
notificationsViewModel.close()
popToRepositories()
viewModel.logout()
}
UBGiteaTheme {
when (currentRoute) {
Route.Notifications -> NotificationsScreen(
state = notificationsState,
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
onShowAll = notificationsViewModel::showAll,
onNotificationSelected = { notification ->
push(Route.WorkItems(notification.repositoryOwner, notification.repositoryName))
},
onNextPage = notificationsViewModel::nextPage,
onPreviousPage = notificationsViewModel::previousPage,
onRetry = notificationsViewModel::retry,
onBack = {
notificationsViewModel.close()
pop()
},
onAuthenticationExpired = ::authenticationExpired,
)
is Route.WorkItems -> 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()
pop()
},
onAuthenticationExpired = ::authenticationExpired,
)
is Route.History -> HistoryScreen(
state = historyState,
onCommitSelected = historyViewModel::openCommit,
onNextPage = historyViewModel::nextPage,
onPreviousPage = historyViewModel::previousPage,
onRetryList = historyViewModel::retryList,
onRetryDetail = historyViewModel::retryDetail,
onRetryPatch = historyViewModel::retryPatch,
onBackToList = historyViewModel::backToList,
onBack = {
historyViewModel.close()
pop()
},
onCompare = historyViewModel::compareAgainstCurrentRef,
onCompareNextPage = historyViewModel::compareNextPage,
onComparePreviousPage = historyViewModel::comparePreviousPage,
onRetryCompare = historyViewModel::retryCompare,
onLoadComparePatch = historyViewModel::loadComparePatch,
onAuthenticationExpired = ::authenticationExpired,
)
is Route.CodeBrowser -> CodeBrowserScreen(
state = codeBrowserState,
onEntrySelected = codeBrowserViewModel::openEntry,
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
onRetry = codeBrowserViewModel::retry,
onBack = {
if (!codeBrowserViewModel.navigateUp()) {
codeBrowserViewModel.close()
pop()
}
},
onAuthenticationExpired = ::authenticationExpired,
onViewHistory = { target ->
push(Route.History(target.repository.owner, target.repository.name, target.ref.value))
},
)
Route.Repositories -> RepositoryListScreen(
state = state,
onSignIn = viewModel::signIn,
onCancelSignIn = viewModel::cancelSignIn,
onRetry = viewModel::retry,
onLogout = viewModel::logout,
onRepositorySelected = viewModel::selectRepository,
onReferenceSelected = { repository, name, kind ->
viewModel.selectReference(name, kind)
push(
Route.CodeBrowser(
owner = repository.coordinates.owner,
name = repository.coordinates.name,
refKind = if (kind == ReferenceKind.BRANCH) {
CodeReferenceKind.BRANCH.name
} else {
CodeReferenceKind.TAG.name
},
ref = name,
),
)
},
onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories,
onNextPage = viewModel::nextPage,
onPreviousPage = viewModel::previousPage,
onRefreshRepositories = viewModel::refreshRepositories,
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
onShowAllRepositories = viewModel::showAllRepositories,
onShowFavorites = viewModel::showFavorites,
onToggleFavorite = viewModel::toggleFavorite,
onViewWorkItems = { repository ->
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
},
onSelectOrganization = viewModel::selectOrganization,
onViewNotifications = { push(Route.Notifications) },
)
}
LaunchedEffect(viewModel) {
viewModel.browserRequests.collect(::openSystemBrowser)
}
}
}
consumeCallback(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
consumeCallback(intent)
}
private fun openSystemBrowser(uri: String) {
try {
startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
},
)
} catch (_: ActivityNotFoundException) {
viewModel.browserLaunchFailed()
}
}
private fun consumeCallback(intent: Intent?) {
val callback = intent?.dataString ?: return
setIntent(Intent(intent).setData(null))
viewModel.handleCallback(callback)
}
}