choiminyeong a140b56aec TASK-075: chip border, quiet short loads, exit question, back to top
A selected filter chip is outlined; the loading cup waits 450 ms before it
shows; Back on 홈 asks before leaving; long lists offer a button back to the
top; AnimatedContent no longer animates its own size, which made sideways
moves drift diagonally.

Agent: main (opus), implementer (sonnet) for Chips.kt and ExitConfirmDialog.kt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:29:41 +09:00

793 lines
44 KiB
Kotlin

package com.ubisam.ubgitea
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
import com.ubisam.ubgitea.core.settings.ThemeMode
import com.ubisam.ubgitea.core.update.AppUpdateViewModel
import com.ubisam.ubgitea.core.update.InstallStep
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryPath
import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.domain.notifications.NotificationSubjectType
import com.ubisam.ubgitea.feature.branchdetail.BranchDetailScreen
import com.ubisam.ubgitea.feature.branchdetail.isReadingFullScreen
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState
import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences
import com.ubisam.ubgitea.feature.compare.CodeTabStrip
import com.ubisam.ubgitea.feature.compare.CodeWorkbench
import com.ubisam.ubgitea.feature.compare.CodeWorkbenchViewModel
import com.ubisam.ubgitea.feature.compare.CompareScreen
import com.ubisam.ubgitea.feature.compare.CompareUiState
import com.ubisam.ubgitea.feature.compare.LocalCodeWorkbench
import com.ubisam.ubgitea.feature.compare.SavedCodeTab
import com.ubisam.ubgitea.feature.compare.WorkbenchMessage
import com.ubisam.ubgitea.feature.history.HistoryTarget
import com.ubisam.ubgitea.feature.history.HistoryViewModel
import com.ubisam.ubgitea.feature.home.HomeDestination
import com.ubisam.ubgitea.feature.home.HomeScreen
import com.ubisam.ubgitea.feature.home.HomeViewModel
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.RepositoryListUiState
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
import com.ubisam.ubgitea.feature.repositories.accountNameOrNull
import com.ubisam.ubgitea.feature.settings.SettingsScreen
import com.ubisam.ubgitea.feature.settings.SettingsViewModel
import com.ubisam.ubgitea.feature.work.WorkScreen
import com.ubisam.ubgitea.feature.work.WorkViewModel
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.components.ExitConfirmDialog
import com.ubisam.ubgitea.ui.components.NoResize
import com.ubisam.ubgitea.ui.components.SplashOverlay
import com.ubisam.ubgitea.ui.components.TabPager
import com.ubisam.ubgitea.ui.navigation.Route
import com.ubisam.ubgitea.ui.navigation.RouteStackSaver
import com.ubisam.ubgitea.ui.shell.AppShell
import com.ubisam.ubgitea.ui.shell.AppTab
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)
}
private val settingsViewModel: SettingsViewModel by viewModels {
SettingsViewModel.Factory(application)
}
private val homeViewModel: HomeViewModel by viewModels {
HomeViewModel.Factory(application)
}
private val workViewModel: WorkViewModel by viewModels {
WorkViewModel.Factory(application)
}
private val workbenchViewModel: CodeWorkbenchViewModel by viewModels {
CodeWorkbenchViewModel.Factory(application)
}
private val allowInstalls =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (packageManager.canRequestPackageInstalls()) updateViewModel.installUpdate()
}
private val updateViewModel: AppUpdateViewModel by viewModels {
AppUpdateViewModel.Factory(application)
}
// TASK-036: no-op if already granted or already permanently denied -- the OS itself decides
// whether to actually show a prompt, this call is safe to make on every launch. Must be
// registered before STARTED (a property initializer, not inside onCreate's body after super).
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result observed indirectly via FavoritePushNotifier's own permission check at post-time */ }
// TASK-036's notification deep link: set by consumeFavoritePushTarget from plain (non-Compose)
// Activity code (onCreate/onNewIntent), read by a LaunchedEffect inside setContent that pushes
// it onto routeStack once available -- a Compose MutableState works fine as a regular class
// property for exactly this reason, bridging Activity-lifecycle callbacks into composition.
private var pendingFavoritePushRoute: Route.BranchDetail? by mutableStateOf(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
FavoritePushScheduler.schedule(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
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()
val unreadNotifications by notificationsViewModel.unreadCount.collectAsState()
val settingsState by settingsViewModel.state.collectAsState()
val homeState by homeViewModel.state.collectAsState()
val workState by workViewModel.state.collectAsState()
val workbenchState by workbenchViewModel.state.collectAsState()
val updateState by updateViewModel.state.collectAsState()
// TASK-066: whether a newer build is published, once per launch.
LaunchedEffect(Unit) { updateViewModel.checkAtLaunch() }
var routeStack by rememberSaveable(stateSaver = RouteStackSaver) {
mutableStateOf(listOf<Route>(Route.Home))
}
fun push(route: Route) { routeStack = routeStack + route }
fun pop() { routeStack = routeStack.dropLast(1).ifEmpty { listOf(Route.Home) } }
fun selectTab(tab: AppTab) { routeStack = listOf(tab.route) }
val currentRoute = routeStack.last()
val currentTab = routeStack.firstNotNullOfOrNull { it.tab } ?: AppTab.HOME
// The repository list's ViewModel owns sign-in, so its state says whether there is a
// session. While it is merely loading, whatever was true before still is.
var sessionReady by rememberSaveable { mutableStateOf(false) }
var accountName by rememberSaveable { mutableStateOf<String?>(null) }
LaunchedEffect(state) {
state.sessionReadyOrNull()?.let { sessionReady = it }
if (state is RepositoryListUiState.SignedOut) accountName = null
state.accountNameOrNull()?.let { accountName = it }
}
// TASK-036: tapping a favorite-push notification. Pushed on top of whatever's
// currently showing rather than resetting the stack -- consistent with how every other
// in-app navigation here only ever pushes, never replaces.
LaunchedEffect(pendingFavoritePushRoute) {
pendingFavoritePushRoute?.let { route ->
push(route)
pendingFavoritePushRoute = null
}
}
// The route shown before this one: coming back from a comparison leaves the code as it was.
val lastRoute = remember { arrayOfNulls<Route>(1) }
LaunchedEffect(currentRoute, sessionReady) {
val previous = lastRoute[0]
lastRoute[0] = currentRoute
if (!sessionReady) return@LaunchedEffect
if (previous == Route.Compare && currentRoute is Route.BranchDetail) return@LaunchedEffect
if (currentRoute.isTab) notificationsViewModel.refreshUnreadCount()
when (val route = currentRoute) {
Route.Home -> homeViewModel.open()
Route.Repositories -> Unit // RepositoryListViewModel restores its own session on creation.
Route.Work -> workViewModel.open()
is Route.BranchDetail -> {
val repository = RepositoryCoordinates.of(route.owner, route.name)
val ref = RepositoryRef.of(route.ref)
val target = CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind))
if (route.path != null) {
codeBrowserViewModel.openFile(target, RepositoryPath.of(route.path))
} else {
codeBrowserViewModel.open(target)
}
historyViewModel.open(HistoryTarget(repository, ref))
}
is Route.WorkItems -> {
val target = WorkItemsTarget(RepositoryCoordinates.of(route.owner, route.name))
if (route.number != null) {
workItemsViewModel.openItem(target, route.number, route.isPullRequest)
} else {
workItemsViewModel.open(target)
}
}
Route.Notifications -> notificationsViewModel.open()
Route.Settings -> Unit // SettingsViewModel reads its (synchronous) store at construction.
Route.Compare -> Unit // CodeWorkbenchViewModel already holds what is compared.
}
}
// TASK-065: a comparison that has started is shown on its own screen; one that is gone
// (closed, or lost with the process) takes that screen away.
val comparing = workbenchState.compare != CompareUiState.None
LaunchedEffect(comparing) {
if (comparing && currentRoute != Route.Compare) push(Route.Compare)
}
LaunchedEffect(currentRoute, comparing) {
if (currentRoute == Route.Compare && !comparing) pop()
}
val context = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(workbenchState.message) {
val message = workbenchState.message ?: return@LaunchedEffect
val text = when (message) {
WorkbenchMessage.TABS_FULL -> context.getString(R.string.workbench_tabs_full, SavedCodeTab.MAX_TABS)
WorkbenchMessage.FILE_UNAVAILABLE -> context.getString(R.string.compare_file_unavailable)
WorkbenchMessage.FILE_NOT_TEXT -> context.getString(R.string.compare_file_not_text)
WorkbenchMessage.SAME_FILE -> context.getString(R.string.compare_same_file)
}
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
workbenchViewModel.messageShown()
}
fun closeFeatures() {
codeBrowserViewModel.close()
historyViewModel.close()
workItemsViewModel.close()
notificationsViewModel.close()
notificationsViewModel.clearUnreadCount()
homeViewModel.close()
workViewModel.close()
}
fun signOut() {
closeFeatures()
selectTab(AppTab.HOME)
viewModel.logout()
}
fun leaveBranchDetail() {
codeBrowserViewModel.close()
historyViewModel.close()
pop()
}
var confirmExit by remember { mutableStateOf(false) }
val instanceHost = UBGiteaOAuthDefaults.config.baseUri.host.orEmpty()
val codeViewerPreferences = CodeViewerPreferences(settingsState.codeWordWrapEnabled, settingsState.codeFontSizeSp)
val showNavigation = sessionReady && when (currentRoute) {
is Route.BranchDetail -> !isReadingFullScreen(codeBrowserState, historyState)
Route.Settings, Route.Compare -> false
else -> true
}
// A saved tab: the file in place when this screen already shows its repository and
// ref, otherwise this screen on that file in place of the code being read.
fun openTab(tab: SavedCodeTab) {
val route = currentRoute
val target = CodeBrowserTarget(
RepositoryCoordinates.of(tab.owner, tab.name),
RepositoryRef.of(tab.ref),
CodeReferenceKind.valueOf(tab.refKind),
)
if (route is Route.BranchDetail && route.owner == tab.owner && route.name == tab.name &&
route.refKind == tab.refKind && route.ref == tab.ref
) {
codeBrowserViewModel.openFile(target, RepositoryPath.of(tab.path))
return
}
val next = Route.BranchDetail(tab.owner, tab.name, tab.refKind, tab.ref, tab.path)
if (route is Route.BranchDetail) {
codeBrowserViewModel.close()
historyViewModel.close()
routeStack = routeStack.dropLast(1) + next
} else {
push(next)
}
}
val shownFile = codeBrowserState as? CodeBrowserUiState.File
val currentCodeTab = (currentRoute as? Route.BranchDetail)?.let { route ->
shownFile?.takeIf { it.target.repository == RepositoryCoordinates.of(route.owner, route.name) }?.let {
SavedCodeTab(route.owner, route.name, it.target.kind.name, it.target.ref.value, it.path.value)
}
}
val workbench = if (sessionReady) {
CodeWorkbench(
tabs = workbenchState.tabs,
pending = workbenchState.pending,
fetching = workbenchState.fetching,
onToggleTab = workbenchViewModel::toggleTab,
onPin = workbenchViewModel::pin,
onCompareWith = workbenchViewModel::compareWith,
onClearPin = workbenchViewModel::clearPin,
onUseCommitFile = { commitId, shortId, path, side ->
(currentRoute as? Route.BranchDetail)?.let { route ->
workbenchViewModel.useCommitFile(RepositoryCoordinates.of(route.owner, route.name), commitId, shortId, path, side)
}
},
)
} else {
null
}
val showStrip = sessionReady && currentRoute != Route.Settings && currentRoute != Route.Compare &&
(workbenchState.tabs.isNotEmpty() || workbenchState.pending != null || workbenchState.fetching)
val darkTheme = when (settingsState.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
// The status and navigation bar icons have to contrast with the app's own theme, which
// the setting can make different from the system's.
DisposableEffect(darkTheme) {
val style = if (darkTheme) {
SystemBarStyle.dark(Color.TRANSPARENT)
} else {
SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)
}
enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style)
onDispose {}
}
UBGiteaTheme(darkTheme = darkTheme) {
CompositionLocalProvider(LocalCodeWorkbench provides workbench) {
AppShell(
selectedTab = currentTab,
showNavigation = showNavigation,
unreadNotifications = unreadNotifications,
onTabSelected = { tab ->
// The Repositories tab pressed again steps back toward the list: from a
// repository's code to its branches' list page, from there to the first page.
val reselected = tab == AppTab.REPOSITORIES && currentTab == AppTab.REPOSITORIES
if (currentRoute is Route.BranchDetail) {
codeBrowserViewModel.close()
historyViewModel.close()
}
if (currentRoute is Route.WorkItems) workItemsViewModel.close()
when {
!reselected -> selectTab(tab)
currentRoute == Route.Repositories -> viewModel.reselect()
else -> {
if (currentRoute == Route.Compare) workbenchViewModel.closeCompare()
selectTab(tab)
viewModel.backToRepositories()
}
}
},
strip = if (showStrip) {
{
CodeTabStrip(
tabs = workbenchState.tabs,
current = currentCodeTab,
pending = workbenchState.pending,
fetching = workbenchState.fetching,
onOpen = ::openTab,
onClose = workbenchViewModel::removeTab,
onClearPin = workbenchViewModel::clearPin,
)
}
} else {
null
},
) { isWide ->
// Back on any tab but Home goes Home; back on Home asks before leaving the
// app, since it is easily pressed once too often.
BackHandler(enabled = sessionReady && currentRoute.isTab) {
if (currentRoute == Route.Home) confirmExit = true else selectTab(AppTab.HOME)
}
val repositoryList: @Composable () -> Unit = {
RepositoryListScreen(
state = state,
instanceHost = instanceHost,
onSignIn = viewModel::signIn,
onCancelSignIn = viewModel::cancelSignIn,
onRetry = viewModel::retry,
onLogout = ::signOut,
onRepositorySelected = viewModel::selectRepository,
onReferenceSelected = { repository, name, kind ->
viewModel.selectReference(name, kind)
val refKind = if (kind == ReferenceKind.BRANCH) CodeReferenceKind.BRANCH else CodeReferenceKind.TAG
push(
Route.BranchDetail(
owner = repository.coordinates.owner,
name = repository.coordinates.name,
refKind = refKind.name,
ref = name,
),
)
},
onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories,
onNextPage = viewModel::nextPage,
onPreviousPage = viewModel::previousPage,
onRefreshRepositories = viewModel::refreshRepositories,
onRetryLastPushedBranch = viewModel::retryRowLastPushedBranch,
onToggleFavorite = viewModel::toggleFavorite,
onShowAllRepositories = viewModel::showAllRepositories,
onShowFavorites = viewModel::showFavorites,
onViewWorkItems = { repository ->
push(Route.WorkItems(repository.coordinates.owner, repository.coordinates.name))
},
onSelectOrganization = viewModel::selectOrganization,
onViewSettings = { push(Route.Settings) },
)
}
// Entering a screen slides it in from the side it was opened towards. Keyed by
// the kind of screen, so moving between code tabs or pages of one screen does
// not replay it. The four tabs are one kind: between them the pager below glides.
val stackDepth = routeStack.size
val shownDepth = remember { intArrayOf(stackDepth) }
val shownReady = remember { booleanArrayOf(sessionReady) }
SideEffect {
shownDepth[0] = stackDepth
shownReady[0] = sessionReady
}
AnimatedContent(
targetState = currentRoute.takeIf { sessionReady } ?: Route.Repositories,
modifier = Modifier.fillMaxSize(),
transitionSpec = {
if (!shownReady[0]) {
// The session has just been found: the first screen takes the
// waiting one's place where it stands. Nothing was navigated, so
// nothing slides.
fadeIn(tween(200)).togetherWith(fadeOut(tween(90))).using(NoResize)
} else {
val direction = (stackDepth - shownDepth[0]).coerceIn(-1, 1)
(fadeIn(tween(200, delayMillis = 50)) + slideInHorizontally(tween(260)) { it / 10 * direction })
.togetherWith(fadeOut(tween(90)))
.using(NoResize)
}
},
contentKey = { if (it.isTab) AppTab::class else it::class },
label = "route",
) { shown ->
val screen: @Composable (Route) -> Unit = { route ->
Box(Modifier.fillMaxSize()) {
when (route) {
Route.Home -> HomeScreen(
state = homeState,
accountName = accountName,
instanceHost = instanceHost,
isWide = isWide,
onNavigate = { destination ->
when (destination) {
HomeDestination.Repositories -> selectTab(AppTab.REPOSITORIES)
is HomeDestination.Work -> {
workViewModel.open(destination.scope, destination.type)
selectTab(AppTab.WORK)
}
is HomeDestination.Branch -> push(
Route.BranchDetail(
owner = destination.owner,
name = destination.name,
refKind = (if (destination.isTag) CodeReferenceKind.TAG else CodeReferenceKind.BRANCH).name,
ref = destination.ref,
),
)
is HomeDestination.WorkItem -> push(
Route.WorkItems(destination.owner, destination.name, destination.number, destination.isPullRequest),
)
}
},
onSelectSource = homeViewModel::selectSource,
onSelectFilter = homeViewModel::selectFilter,
onSetTodayOnly = homeViewModel::setTodayOnly,
onShowToday = homeViewModel::showToday,
onLoadMore = homeViewModel::loadMore,
onRefresh = homeViewModel::refresh,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onAuthenticationExpired = ::signOut,
)
Route.Work -> WorkScreen(
state = workState,
accountName = accountName,
instanceHost = instanceHost,
onSelectScope = workViewModel::selectScope,
onSelectState = workViewModel::selectState,
onSelectType = workViewModel::selectType,
onItemSelected = { hit ->
push(Route.WorkItems(hit.repository.owner, hit.repository.name, hit.number, hit.isPullRequest))
},
onNextPage = workViewModel::nextPage,
onPreviousPage = workViewModel::previousPage,
onRefresh = workViewModel::refresh,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
onAuthenticationExpired = ::signOut,
)
Route.Settings -> SettingsScreen(
state = settingsState,
onSetReactiveAuthRetryEnabled = settingsViewModel::setReactiveAuthRetryEnabled,
onSetFavoritePushNotificationsEnabled = settingsViewModel::setFavoritePushNotificationsEnabled,
accountName = accountName,
instanceHost = instanceHost,
onSetCodeWordWrapEnabled = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSizeSp = settingsViewModel::setCodeFontSizeSp,
onSetThemeMode = settingsViewModel::setThemeMode,
onLogout = ::signOut,
onBack = { pop() },
update = updateState,
onCheckUpdate = updateViewModel::checkNow,
onInstallUpdate = ::startUpdate,
)
Route.Notifications -> NotificationsScreen(
state = notificationsState,
onShowUnreadOnly = notificationsViewModel::showUnreadOnly,
onShowAll = notificationsViewModel::showAll,
onNotificationSelected = { notification ->
push(
Route.WorkItems(
owner = notification.repositoryOwner,
name = notification.repositoryName,
number = notification.subjectNumber,
isPullRequest = notification.subjectType == NotificationSubjectType.PULL_REQUEST,
),
)
},
onNextPage = notificationsViewModel::nextPage,
onPreviousPage = notificationsViewModel::previousPage,
onRetry = notificationsViewModel::retry,
onBack = { selectTab(AppTab.HOME) },
onAuthenticationExpired = ::signOut,
accountName = accountName,
instanceHost = instanceHost,
onViewSettings = { push(Route.Settings) },
onLogout = ::signOut,
)
is Route.WorkItems -> {
val leave = {
workItemsViewModel.close()
pop()
}
WorkItemsScreen(
state = workItemsState,
onShowIssues = workItemsViewModel::showIssues,
onShowPullRequests = workItemsViewModel::showPullRequests,
onIssueSelected = workItemsViewModel::openIssue,
onPullRequestSelected = workItemsViewModel::openPullRequest,
onNextPage = workItemsViewModel::nextPage,
onPreviousPage = workItemsViewModel::previousPage,
onGoToPage = workItemsViewModel::goToPage,
onRetryList = workItemsViewModel::retryList,
onRetryDetail = workItemsViewModel::retryDetail,
// An item opened directly came from a list elsewhere (a search, the
// timeline, a notification), so back returns there, not to this
// repository's own list.
onBackToList = if (route.number != null) leave else workItemsViewModel::backToList,
onBack = leave,
onAuthenticationExpired = ::signOut,
)
}
is Route.BranchDetail -> BranchDetailScreen(
owner = route.owner,
name = route.name,
ref = route.ref,
codeBrowserState = codeBrowserState,
codeViewerPreferences = codeViewerPreferences,
onEntrySelected = codeBrowserViewModel::openEntry,
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
onRetryCodeBrowser = codeBrowserViewModel::retry,
onCodeBrowserBack = { if (!codeBrowserViewModel.navigateUp()) leaveBranchDetail() },
onNavigateImage = codeBrowserViewModel::navigateImage,
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
onOpenSibling = codeBrowserViewModel::openSibling,
onToggleFolder = codeBrowserViewModel::toggleFolder,
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
historyState = historyState,
onCommitSelected = historyViewModel::openCommit,
onHistoryNextPage = historyViewModel::nextPage,
onHistoryPreviousPage = historyViewModel::previousPage,
onRetryHistoryList = historyViewModel::retryList,
onRetryHistoryDetail = historyViewModel::retryDetail,
onRetryPatch = historyViewModel::retryPatch,
onFileSelected = historyViewModel::openFileDiff,
onShowAdjacentFile = historyViewModel::openAdjacentFileDiff,
onClearFileSelection = historyViewModel::clearFileSelection,
onHistoryBackToList = historyViewModel::backToList,
onHistoryBack = ::leaveBranchDetail,
onViewWorkItems = { push(Route.WorkItems(route.owner, route.name)) },
onClose = ::leaveBranchDetail,
onAuthenticationExpired = ::signOut,
)
Route.Repositories -> repositoryList()
Route.Compare -> CompareScreen(
state = workbenchState.compare,
fontSizeSp = settingsState.codeFontSizeSp,
onSwap = workbenchViewModel::swapSides,
onClose = workbenchViewModel::closeCompare,
)
}
}
}
// Tabs sit side by side: going from one to another passes the ones between.
val shownTab = shown.tab
if (shownTab != null && sessionReady) {
TabPager(AppTab.entries, shownTab) { screen(it.route) }
} else {
screen(shown)
}
}
}
}
LaunchedEffect(viewModel) {
viewModel.browserRequests.collect(::openAuthorizationTab)
}
updateState.prompt?.let { release ->
AlertDialog(
onDismissRequest = updateViewModel::dismissPrompt,
title = { Text(stringResource(R.string.update_dialog_title)) },
text = {
val body = stringResource(R.string.update_dialog_body, updateState.installedVersionName, release.versionName)
Text(if (release.notes != null) body + "\n\n" + release.notes else body)
},
confirmButton = {
TextButton(onClick = ::startUpdate) { Text(stringResource(R.string.update_dialog_download)) }
},
dismissButton = {
TextButton(onClick = updateViewModel::dismissPrompt) { Text(stringResource(R.string.update_dialog_later)) }
},
)
}
when (updateState.install) {
InstallStep.IDLE -> Unit
InstallStep.DOWNLOADING, InstallStep.INSTALLING -> {
val downloading = updateState.install == InstallStep.DOWNLOADING
AlertDialog(
// Only its button closes it: a stray tap should not drop a download.
onDismissRequest = {},
title = { Text(stringResource(R.string.update_progress_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (downloading) {
val fraction = updateState.downloaded
Text(stringResource(R.string.update_progress_downloading, (fraction * 100).toInt()))
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth())
} else {
Text(stringResource(R.string.update_progress_installing))
LinearProgressIndicator(Modifier.fillMaxWidth())
}
}
},
confirmButton = {
TextButton(onClick = updateViewModel::cancelInstall) {
Text(stringResource(if (downloading) R.string.update_progress_cancel else R.string.update_install_close))
}
},
)
}
InstallStep.FAILED -> AlertDialog(
onDismissRequest = updateViewModel::cancelInstall,
title = { Text(stringResource(R.string.update_install_failed_title)) },
text = { Text(stringResource(R.string.update_install_failed_body)) },
confirmButton = {
TextButton(onClick = ::startUpdate) { Text(stringResource(R.string.update_install_retry)) }
},
dismissButton = {
TextButton(onClick = updateViewModel::cancelInstall) { Text(stringResource(R.string.update_install_close)) }
},
)
}
if (confirmExit) {
ExitConfirmDialog(onStay = { confirmExit = false }, onExit = { finish() })
}
SplashOverlay(settled = sessionReady || state.sessionReadyOrNull() != null)
}
}
consumeCallback(intent)
consumeFavoritePushTarget(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
consumeCallback(intent)
consumeFavoritePushTarget(intent)
}
// OAuth's authorization/consent step must run in a real browser context, never an in-app
// WebView the host app controls (RFC 8252) — a WebView could read the Gitea login form's
// credentials directly. A Custom Tab is the standard middle ground: it's the actual system
// browser underneath (session cookies, saved passwords, the works), but it's presented as an
// overlay on top of this activity instead of switching away to a separate browser app/task,
// which is what the human product owner asked for ("앱 내부에서 처리").
private fun openAuthorizationTab(uri: String) {
try {
CustomTabsIntent.Builder()
.build()
.launchUrl(this, Uri.parse(uri))
} catch (_: ActivityNotFoundException) {
viewModel.browserLaunchFailed()
}
}
// TASK-073: the app fetches and installs the new build itself. Android asks once, in its own
// settings, whether this app may install apps; the update starts when the person comes back.
private fun startUpdate() {
if (packageManager.canRequestPackageInstalls()) {
updateViewModel.installUpdate()
return
}
updateViewModel.dismissPrompt()
Toast.makeText(this, R.string.update_allow_installs, Toast.LENGTH_LONG).show()
try {
allowInstalls.launch(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")))
} catch (_: ActivityNotFoundException) {
// No such settings screen on this device: nothing more the app can do.
}
}
private fun consumeCallback(intent: Intent?) {
val callback = intent?.dataString ?: return
setIntent(Intent(intent).setData(null))
viewModel.handleCallback(callback)
}
// TASK-036: reads the extras FavoritePushNotifier's PendingIntent carries when a favorited
// repository's default branch was known at notify-time (it isn't always -- see
// FavoritePushNotifier's own doc comment), then clears them from the stored intent so rotating
// the device or otherwise recomposing doesn't re-navigate to the same target repeatedly.
private fun consumeFavoritePushTarget(intent: Intent?) {
val owner = intent?.getStringExtra(FavoritePushNotifier.EXTRA_OWNER) ?: return
val name = intent.getStringExtra(FavoritePushNotifier.EXTRA_NAME) ?: return
val ref = intent.getStringExtra(FavoritePushNotifier.EXTRA_REF) ?: return
intent.removeExtra(FavoritePushNotifier.EXTRA_OWNER)
intent.removeExtra(FavoritePushNotifier.EXTRA_NAME)
intent.removeExtra(FavoritePushNotifier.EXTRA_REF)
pendingFavoritePushRoute = Route.BranchDetail(owner, name, CodeReferenceKind.BRANCH.name, ref)
}
}
private val AppTab.route: Route
get() = when (this) {
AppTab.HOME -> Route.Home
AppTab.REPOSITORIES -> Route.Repositories
AppTab.WORK -> Route.Work
AppTab.NOTIFICATIONS -> Route.Notifications
}
private val Route.tab: AppTab?
get() = when (this) {
Route.Home -> AppTab.HOME
Route.Repositories -> AppTab.REPOSITORIES
Route.Work -> AppTab.WORK
Route.Notifications -> AppTab.NOTIFICATIONS
else -> null
}
/** Whether there is a signed-in session, or null when this state does not say either way. */
private fun RepositoryListUiState.sessionReadyOrNull(): Boolean? = when (this) {
RepositoryListUiState.SignedOut, RepositoryListUiState.WaitingForBrowser -> false
RepositoryListUiState.Loading -> null
is RepositoryListUiState.Error -> canLogout
else -> true
}