choiminyeong 5cc11d8420 TASK-057: side pane cards, in-place folder tree, unfolded Home headline
- The viewer's side pane is two cards: folder structure, and classes and
  functions. A button swaps them and the choice is kept.
- Folders open in place; files inside them open from the tree.
- Unfolded Home: the headline is a card and the columns are equal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 15:44:54 +09:00

501 lines
27 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 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.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.core.auth.protocol.UBGiteaOAuthDefaults
import com.ubisam.ubgitea.core.notifications.FavoritePushNotifier
import com.ubisam.ubgitea.core.settings.ThemeMode
import com.ubisam.ubgitea.core.notifications.FavoritePushScheduler
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
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.CodeBrowserViewModel
import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind
import com.ubisam.ubgitea.feature.codebrowser.CodeViewerPreferences
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.accountNameOrNull
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
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.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)
}
// 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()
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
}
}
LaunchedEffect(currentRoute, sessionReady) {
if (!sessionReady) 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)
codeBrowserViewModel.open(CodeBrowserTarget(repository, ref, CodeReferenceKind.valueOf(route.refKind)))
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.
}
}
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()
}
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 -> false
else -> true
}
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) {
AppShell(
selectedTab = currentTab,
showNavigation = showNavigation,
unreadNotifications = unreadNotifications,
onTabSelected = { tab ->
if (currentRoute is Route.BranchDetail) {
codeBrowserViewModel.close()
historyViewModel.close()
}
if (currentRoute is Route.WorkItems) workItemsViewModel.close()
selectTab(tab)
},
) { isWide ->
// Back on any tab but Home goes Home; back on Home leaves the app.
BackHandler(enabled = sessionReady && currentRoute.isTab && currentRoute != Route.Home) {
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) },
)
}
when (val route = currentRoute.takeIf { sessionReady } ?: Route.Repositories) {
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() },
)
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()
}
}
LaunchedEffect(viewModel) {
viewModel.browserRequests.collect(::openAuthorizationTab)
}
}
}
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()
}
}
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
}