Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ composeMultiplatform = "1.12.0"
lifecycleMultiplatform = "2.11.0"
material3 = "1.12.0-alpha03"
materialVersion = "1.12.0"
navigationMultiplatform = "2.10.0-alpha02"
navigation3 = "1.2.0-alpha02"
navigation3Browser = "1.1.0"
composeNavigationEvent = "1.1.0"

#JetBrains
Expand All @@ -24,7 +25,7 @@ kermit = "2.1.0"
ktorfit = "2.7.5"
kotlinInject = "0.9.0"
androidx-annotation = "1.10.0"
coil = "3.6.0"
coil = "3.6.1"
datastorePreferences = "1.3.0-alpha10"
filekitCompose = "0.15.0"
krop = "0.2.0"
Expand Down Expand Up @@ -63,7 +64,9 @@ androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycl
androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleMultiplatform" }
androidx-lifecycle-viewmodel-savedstate = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "lifecycleMultiplatform" }

androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationMultiplatform" }
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleMultiplatform" }
navigation3-browser = { module = "com.github.terrakok:navigation3-browser", version.ref = "navigation3Browser" }

androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastorePreferences" }
Expand Down
5 changes: 0 additions & 5 deletions kotlin-js-store/wasm/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==

ws@8.18.0:
version "8.18.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc"
integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==

ws@8.20.1:
version "8.20.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb"
Expand Down
7 changes: 6 additions & 1 deletion sharedUI/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ kotlin {
implementation(libs.androidx.lifecycle.viewmodel.savedstate)

//navigation
implementation(libs.androidx.navigation.compose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)

//annotation
implementation(libs.androidx.annotation)
Expand All @@ -134,6 +135,10 @@ kotlin {
implementation(libs.composemediaplayer)
}

webMain.dependencies {
implementation(libs.navigation3.browser)
}

androidMain.dependencies {
implementation(libs.androidx.exifinterface)
implementation(libs.kotlinx.coroutines.android)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.FileProvider
import co.touchlab.kermit.Logger
import com.daniebeler.pfpixelix.MyApplication
import com.daniebeler.pfpixelix.domain.service.general.BackendType
import com.daniebeler.pfpixelix.domain.service.preferences.UserPreferences
import com.daniebeler.pfpixelix.utils.KmpContext
import com.daniebeler.pfpixelix.utils.KmpUri
Expand Down Expand Up @@ -36,6 +37,10 @@ actual class Platform actual constructor(
return contentUri
}

actual fun prepareAuthBrowser(host: String, backendType: BackendType): Boolean = true

actual suspend fun consumePreparedAuthData(): PreparedAuthData? = null

actual fun openUrl(url: String) {
val activity = MyApplication.currentActivity?.get()
if (activity != null) {
Expand Down Expand Up @@ -68,7 +73,7 @@ actual class Platform actual constructor(

actual fun dismissBrowser() {}

actual fun getAppVersion(): String {
actual fun getAppVersion(): String {
return try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
} catch (e: Throwable) {
Expand Down
103 changes: 49 additions & 54 deletions sharedUI/src/commonMain/kotlin/com/daniebeler/pfpixelix/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,10 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation3.scene.DialogSceneStrategy
import androidx.navigation3.scene.SinglePaneSceneStrategy
import androidx.navigation3.ui.NavDisplay
import com.daniebeler.pfpixelix.ui.navigation.AppNavigator
import co.touchlab.kermit.Logger
import coil3.compose.AsyncImage
import com.daniebeler.pfpixelix.di.AppComponent
Expand All @@ -71,7 +69,8 @@ import com.daniebeler.pfpixelix.ui.composables.settings.preferences.prefs.Prefer
import com.daniebeler.pfpixelix.ui.composables.widgets.ReverseModalNavigationDrawer
import com.daniebeler.pfpixelix.ui.events.GlobalNavigationEvent
import com.daniebeler.pfpixelix.ui.navigation.Destination
import com.daniebeler.pfpixelix.ui.navigation.appGraph
import com.daniebeler.pfpixelix.ui.navigation.appEntryProvider
import com.daniebeler.pfpixelix.ui.navigation.rememberAppNavigationState
import com.daniebeler.pfpixelix.ui.theme.PixelixTheme
import com.daniebeler.pfpixelix.utils.end
import kotlinx.coroutines.delay
Expand Down Expand Up @@ -103,7 +102,6 @@ val LocalSnackbarPresenter = compositionLocalOf<(String) -> Unit> {
@Composable
fun App(
appComponent: AppComponent,
onNavHostReady: suspend (NavController) -> Unit = {},
exitApp: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Expand Down Expand Up @@ -156,12 +154,21 @@ fun App(
}
if (activeUser == "unknown") return@PixelixTheme

// navigation3-browser permits a single binding for the lifetime of the page.
// Keep it outside the session key so login/logout does not dispose the active binding.
BrowserIntegration()

key(activeUser) {
val scope = rememberCoroutineScope()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showAccountSwitchBottomSheet by remember { mutableStateOf(false) }
val navController = rememberNavController()
val startDestination =
if (activeUser == null) Destination.FirstLogin else Destination.HomeTabFeeds
val navigationState = rememberAppNavigationState(startDestination)
val navController = remember(navigationState, exitApp) {
AppNavigator(navigationState, exitApp)
}

val snackbarHostState = remember { SnackbarHostState() }
val snackBarPresenter: (String) -> Unit = { msg ->
Expand All @@ -176,34 +183,23 @@ fun App(
// Activity/Fragment because you didn't actually destroy them properly,
// you just dropped any access to them
LaunchedEffect(activeUser) {
navController.clearBackStack<Destination.HomeTabFeeds>()
navController.clearBackStack<Destination.HomeTabSearch>()
navController.clearBackStack<Destination.HomeTabNewPost>()
navController.clearBackStack<Destination.HomeTabNotifications>()
navController.clearBackStack<Destination.HomeTabOwnProfile>()
navController.clearBackStack(Destination.HomeTabFeeds)
navController.clearBackStack(Destination.HomeTabSearch)
navController.clearBackStack(Destination.HomeTabNewPost)
navController.clearBackStack(Destination.HomeTabNotifications)
navController.clearBackStack(Destination.HomeTabOwnProfile)
}

LaunchedEffect(appComponent.globalNavigator) {
appComponent.globalNavigator.navigationEvents.collect { event ->
when (event) {
is GlobalNavigationEvent.NavigateToLogin -> {
navController.navigate(Destination.FirstLogin) {
popUpTo(navController.graph.startDestinationId) {
inclusive = true
}
}
navController.clearAndNavigate(Destination.FirstLogin)
}
}
}
}

// Bridges the NavController to the host platform once the graph is set up.
// On web this binds browser Back/Forward and the address bar to navigation;
// other platforms pass the default no-op.
LaunchedEffect(navController) {
onNavHostReady(navController)
}

CompositionLocalProvider(
LocalSnackbarPresenter provides snackBarPresenter
) {
Expand All @@ -230,33 +226,36 @@ fun App(
modifier = Modifier.nestedScroll(scrollBehaviorBottom)
) { paddingValues ->
Box(Modifier.fillMaxSize().padding(paddingValues)) {
val startDestination =
if (activeUser == null) Destination.FirstLogin
else Destination.HomeTabFeeds
NavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
startDestination = startDestination,
builder = {
appGraph(
NavDisplay(
entries = navigationState.decoratedEntries(
appEntryProvider(
navController,
{ scope.launch { drawerState.open() } },
exitApp
exitApp,
)
})

val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
),
onBack = navController::popBackStack,
sceneStrategies = listOf(
remember { DialogSceneStrategy() },
remember { SinglePaneSceneStrategy() },
),
modifier = Modifier.fillMaxSize(),
)

val showBottomBar =
currentDestination?.hasRoute<Destination.OwnProfile>() == true || currentDestination?.hasRoute<Destination.Feeds>() == true || currentDestination?.hasRoute<Destination.Search>() == true || currentDestination?.hasRoute<Destination.Notifications>() == true
val currentDestination = navigationState.currentDestination
val showBottomBar = currentDestination == Destination.Feeds ||
currentDestination is Destination.Search ||
currentDestination == Destination.Notifications ||
currentDestination == Destination.OwnProfile ||
currentDestination in HomeTab.entries.map { it.destination }

if (showBottomBar) {
Box(
modifier = Modifier.align(Alignment.BottomCenter)
) {
BottomBarFloating(
navController, scrollBehaviorBottom
navController, navigationState.currentDestination,
navigationState.currentTopLevel, scrollBehaviorBottom
)

}
Expand Down Expand Up @@ -336,7 +335,9 @@ private enum class HomeTab(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun BottomBarFloating(
navController: NavController,
navController: AppNavigator,
currentDestination: Destination,
currentTopLevel: Destination,
scrollBehavior: FloatingToolbarScrollBehavior
) {
var avatar by remember { mutableStateOf<String?>(null) }
Expand All @@ -349,14 +350,11 @@ private fun BottomBarFloating(
}
}

val navBackStackEntry = navController.currentBackStackEntryAsState().value
val currentDestination = navBackStackEntry?.destination ?: return

val systemNavigationBarHeight =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
HorizontalFloatingToolbar(
expanded = true,
scrollBehavior = if (currentDestination.hasRoute<Destination.NewPost>()) null else scrollBehavior,
scrollBehavior = if (currentDestination is Destination.NewPost) null else scrollBehavior,
modifier = Modifier.padding(bottom = systemNavigationBarHeight + 4.dp),
colors = FloatingToolbarColors(
toolbarContentColor = MaterialTheme.colorScheme.onSurface,
Expand All @@ -366,10 +364,7 @@ private fun BottomBarFloating(
)
) {
HomeTab.entries.forEachIndexed { _, tab ->
val isSelected =
currentDestination.hasRoute(tab.destination::class) || currentDestination.parent?.hasRoute(
tab.destination::class
) == true
val isSelected = currentTopLevel == tab.destination


val containerColor =
Expand All @@ -391,9 +386,9 @@ private fun BottomBarFloating(
}
}
} else {
if (currentDestination.hasRoute<Destination.Search>()) {
if (currentDestination is Destination.Search || currentTopLevel == Destination.HomeTabSearch) {
appComponent.searchFieldFocus.focus()
} else if (currentDestination.hasRoute<Destination.Feeds>()) {
} else if (currentDestination == Destination.Feeds || currentTopLevel == Destination.HomeTabFeeds) {
appComponent.backToTopTrigger.scrollToTop()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.daniebeler.pfpixelix

import androidx.compose.runtime.Composable

@Composable
internal expect fun BrowserIntegration()
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.daniebeler.pfpixelix.domain.service.general.AuthService.Companion.gra
import com.daniebeler.pfpixelix.domain.service.general.BackendType
import com.daniebeler.pfpixelix.domain.service.general.Session
import com.daniebeler.pfpixelix.domain.service.platform.Platform
import com.daniebeler.pfpixelix.domain.service.platform.PreparedAuthData
import com.daniebeler.pfpixelix.domain.service.platform.redirectUrl
import com.daniebeler.pfpixelix.domain.service.search.SavedSearchesService
import com.daniebeler.pfpixelix.ui.events.SystemUrlHandler
Expand All @@ -36,18 +37,24 @@ class PixelfedAuthService(
override suspend fun auth(host: String) {
val serverUrl = getServerUrl(host)
val api = createPixelfedAuthApi(serverUrl, json)
val authData = api.getAuthData(clientName, platform.redirectUrl)
val preparedAuthData = platform.consumePreparedAuthData()
val client = preparedAuthData ?: api.getAuthData(
clientName,
platform.redirectUrl
).let { PreparedAuthData(it.clientId, it.clientSecret) }
val clientId = client.clientId
val clientSecret = requireNotNull(client.clientSecret) { "OAuth registration returned no client_secret" }

val authUrl = URLBuilder("${serverUrl}oauth/authorize").apply {
parameters.apply {
append("response_type", "code")
append("redirect_uri", platform.redirectUrl)
append("client_id", authData.clientId)
append("client_id", clientId)
}
}.build()

urlHandler.isAuthInProgress = true
platform.openUrl(authUrl.toString())
if (preparedAuthData == null) platform.openUrl(authUrl.toString())
val redirectString = urlHandler.redirects.first()
platform.dismissBrowser()

Expand All @@ -58,8 +65,8 @@ class PixelfedAuthService(
val code = redirect.parameters["code"] ?: error("Redirect doesn't have a code")

val token = api.getToken(
authData.clientId,
authData.clientSecret,
clientId,
clientSecret,
code,
platform.redirectUrl,
grantType
Expand All @@ -75,8 +82,8 @@ class PixelfedAuthService(
serverUrl = serverUrl.toString(),
token = token.accessToken,
refreshToken = token.refreshToken,
clientId = authData.clientId,
clientSecret = authData.clientSecret,
clientId = clientId,
clientSecret = clientSecret,
createdAt = token.createdAt,
backendType = BackendType.PIXELFED
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.daniebeler.pfpixelix.domain.service.platform

import com.daniebeler.pfpixelix.domain.service.general.BackendType
import com.daniebeler.pfpixelix.domain.service.preferences.UserPreferences
import com.daniebeler.pfpixelix.utils.KmpContext
import com.daniebeler.pfpixelix.utils.KmpUri
Expand All @@ -12,11 +13,22 @@ expect class Platform(
prefs: UserPreferences
) {
fun toSafeUri(platformFile: PlatformFile): KmpUri

/** Starts browser-side OAuth during the user gesture. Non-web platforms do nothing. */
fun prepareAuthBrowser(host: String, backendType: BackendType): Boolean

/** Returns data prepared by the web launcher, or null when OAuth must start in the app. */
suspend fun consumePreparedAuthData(): PreparedAuthData?
fun openUrl(url: String)
fun dismissBrowser()
fun shareText(text: String)
fun getAppVersion(): String
fun pinWidget()
}

data class PreparedAuthData(
val clientId: String,
val clientSecret: String?
)

internal expect val Platform.redirectUrl: String
Loading