Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/local.properties
/.idea/caches
/.idea/libraries
/.idea/copilotDiffState.xml
/.idea/modules.xml
/.idea/deploymentTargetSelector.xml
/.idea/workspace.xml
Expand Down
1 change: 0 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ dependencies {
implementation(project(":domain:main")) // needed for di
implementation(project(":data:main")) // needed for di
implementation(project(":core:network")) // needed for di
implementation(libs.composeNavigation)

api platform(libs.firebaseBoM)
implementation(libs.firebaseCrashlytics)
Expand Down
56 changes: 37 additions & 19 deletions app/src/main/kotlin/nl/q42/template/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.scene.DialogSceneStrategy
import androidx.navigation3.ui.NavDisplay
import co.touchlab.kermit.Logger
import nl.q42.template.core.utils.config.AppScheme
import nl.q42.template.navigation.Destination
import nl.q42.template.navigation.homeGraph
import nl.q42.template.navigation.onboardingDestinations
import nl.q42.template.navigation.deeplink.DeeplinkParser
import nl.q42.template.navigation.homeEntry
import nl.q42.template.navigation.onboardingEntry
import nl.q42.template.navigation.viewmodel.Navigator
import nl.q42.template.navigation.viewmodel.rememberNavigationState
import nl.q42.template.navigation.viewmodel.toEntries
import nl.q42.template.ui.compose.composables.widgets.AppSurface
import nl.q42.template.ui.compose.composables.window.LocalSnackbarHostState
import nl.q42.template.ui.compose.composables.window.toSnackBarVisuals
Expand All @@ -31,19 +37,37 @@ import org.koin.android.ext.android.inject

class MainActivity : ComponentActivity() {

private val appDeepLinkScheme: AppScheme by inject()

private val snackbarPresenter: SnackbarPresenter by inject()

private val deeplinkParser: DeeplinkParser by inject()

@OptIn(ExperimentalAnimationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() // must be called before super.onCreate
super.onCreate(savedInstanceState)

Logger.d { "onCreate received, ${intent.data}" }

val startDestination: Destination = deeplinkParser.parseIntent(intent) ?: Destination.Home
Logger.i { "Start destination: $startDestination" }

setContent {

val navigationState = rememberNavigationState(
startRoute = startDestination,
topLevelRoutes = setOf<NavKey>(
// the destinations that can be used to enter the app, typically the tabs in the bottom navigation bar.
Destination.Home,
Destination.Onboarding
)
)

val navigator = remember { Navigator(navigationState) }
val entryProvider: (NavKey) -> NavEntry<NavKey> = entryProvider {
homeEntry(navigator = navigator)
onboardingEntry(navigator = navigator)
}

val snackbarHostState = remember { SnackbarHostState() }
SnackbarChangedEffect(snackbarHostState)

Expand All @@ -52,22 +76,16 @@ class MainActivity : ComponentActivity() {
) {
AppTheme {

val navController = rememberNavController()

AppSurface(
modifier = Modifier.fillMaxSize(),
) {

NavHost(
navController = navController,
startDestination = Destination.HomeGraph
) {
homeGraph(
navController = navController,
appDeepLinkScheme = appDeepLinkScheme
)
onboardingDestinations(navController)
}
NavDisplay(
entries = navigationState.toEntries(entryProvider),
onBack = { navigator.goBack() },
sceneStrategy = remember { DialogSceneStrategy() }
)

}
}
}
Expand Down
5 changes: 4 additions & 1 deletion app/src/main/kotlin/nl/q42/template/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import nl.q42.template.core.network.di.networkModule
import nl.q42.template.data.main.di.dataModule
import nl.q42.template.domain.main.di.domainModule
import nl.q42.template.home.di.homeModule
import nl.q42.template.navigation.deeplink.DeeplinkParser
import nl.q42.template.navigation.di.navigationModule
import nl.q42.template.onboarding.di.onboardingModule
import nl.q42.template.ui.di.presentationModule
Expand All @@ -27,6 +28,8 @@ fun initDependencyInjection(application: MainApplication) {
}

val appModule = module {
single { DeeplinkParser() }

includes(
configModule,

Expand All @@ -45,4 +48,4 @@ val appModule = module {
// domain
domainModule
)
}
}
25 changes: 25 additions & 0 deletions app/src/main/kotlin/nl/q42/template/navigation/HomeEntry.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package nl.q42.template.navigation

import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import nl.q42.template.home.main.presentation.HomeViewModel
import nl.q42.template.home.main.ui.HomeScreen
import nl.q42.template.home.second.presentation.HomeSecondViewModel
import nl.q42.template.home.second.ui.HomeSecondScreen
import nl.q42.template.navigation.viewmodel.InitNavigator
import nl.q42.template.navigation.viewmodel.Navigator
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf

internal fun EntryProviderScope<NavKey>.homeEntry(navigator: Navigator) {
entry<Destination.Home> { key ->
val viewModel: HomeViewModel = koinViewModel { parametersOf(key) }
InitNavigator(navigator = navigator, routeNavigator = viewModel)
HomeScreen(viewModel = viewModel)
}
entry<Destination.HomeSecond> { key ->
val viewModel: HomeSecondViewModel = koinViewModel { parametersOf(key) }
InitNavigator(navigator = navigator, routeNavigator = viewModel)
HomeSecondScreen(viewModel = viewModel)
}
}
42 changes: 0 additions & 42 deletions app/src/main/kotlin/nl/q42/template/navigation/HomeGraph.kt

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
package nl.q42.template.navigation

import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import nl.q42.template.navigation.viewmodel.InitNavigator
import nl.q42.template.navigation.viewmodel.Navigator
import nl.q42.template.onboarding.start.presentation.OnboardingStartViewModel
import nl.q42.template.onboarding.start.ui.OnboardingStartScreen
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf

internal fun NavGraphBuilder.onboardingDestinations(navController: NavHostController) {
composable<Destination.Onboarding> {

val viewModel: OnboardingStartViewModel = koinViewModel()
InitNavigator(navController = navController, viewModel)
internal fun EntryProviderScope<NavKey>.onboardingEntry(navigator: Navigator) {
entry<Destination.Onboarding> { key ->
val viewModel: OnboardingStartViewModel = koinViewModel { parametersOf(key) }
InitNavigator(navigator = navigator, viewModel)

OnboardingStartScreen(viewModel = viewModel)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package nl.q42.template.navigation.deeplink

/**
* Source: https://github.com/android/nav3-recipes/blob/main/app/src/main/java/com/example/nav3recipes/deeplink/basic/util/DeepLinkMatcher.kt
*/

import android.util.Log
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.KSerializer
import nl.q42.template.navigation.Destination

internal class DeepLinkMatcher<T : Destination>(
val request: DeepLinkRequest,
val deepLinkPattern: DeepLinkPattern<T>
) {
/**
* Match a [DeepLinkRequest] to a [DeepLinkPattern].
*
* Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise
*/
fun match(): DeepLinkMatchResult<T>? {
if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null
if (!request.uri.authority.equals(
deepLinkPattern.uriPattern.authority,
ignoreCase = true
)
) return null
if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null
// exact match (url does not contain any arguments)
if (request.uri == deepLinkPattern.uriPattern)
return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf())

val args = mutableMapOf<String, Any>()
// match the path
request.pathSegments
.asSequence()
// zip to compare the two objects side by side, order matters here so we
// need to make sure the compared segments are at the same position within the url
.zip(deepLinkPattern.pathSegments.asSequence())
.forEach { it ->
// retrieve the two path segments to compare
val requestedSegment = it.first
val candidateSegment = it.second
// if the potential match expects a path arg for this segment, try to parse the
// requested segment into the expected type
if (candidateSegment.isParamArg) {
val parsedValue = try {
candidateSegment.typeParser.invoke(requestedSegment)
} catch (e: IllegalArgumentException) {
Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e)
return null
}
args[candidateSegment.stringValue] = parsedValue
} else if (requestedSegment != candidateSegment.stringValue) {
// if it's path arg is not the expected type, its not a match
return null
}
}
// match queries (if any)
request.queries.forEach { query ->
val name = query.key
// If the pattern does not define this query parameter, ignore it.
// This prevents a NullPointerException.
val queryStringParser = deepLinkPattern.queryValueParsers[name] ?: return@forEach

val queryParsedValue = try {
queryStringParser.invoke(query.value)
} catch (e: IllegalArgumentException) {
Log.e(
TAG_LOG_ERROR,
"Failed to parse query name:[$name] value:[${query.value}].",
e
)
return null
}
args[name] = queryParsedValue
}
// provide the serializer of the matching key and map of arg names to parsed arg values
return DeepLinkMatchResult(deepLinkPattern.serializer, args)
}
}


/**
* Created when a requested deeplink matches with a supported deeplink
*
* @param [T] the backstack key associated with the deeplink that matched with the requested deeplink
* @param serializer serializer for [T]
* @param args The map of argument name to argument value. The value is expected to have already
* been parsed from the raw url string back into its proper KType as declared in [T].
* Includes arguments for all parts of the uri - path, query, etc.
* */
internal data class DeepLinkMatchResult<T : NavKey>(
val serializer: KSerializer<T>,
val args: Map<String, Any>
)

const val TAG_LOG_ERROR = "Nav3RecipesDeepLink"
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package nl.q42.template.navigation.deeplink

import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import nl.q42.template.navigation.Destination

internal val deepLinkPatterns: List<DeepLinkPattern<out Destination>> = listOf(
DeepLinkPattern(
uriPattern = "template://onboarding".toUri(),
serializer = Destination.Onboarding.serializer()
)
)

class DeeplinkParser {
fun parseIntent(intent: Intent): Destination? {
val uri: Uri? = intent.data
// associate the target with the correct backstack key
return uri?.let {
/** STEP 2. Parse requested deeplink */
val request = DeepLinkRequest(uri)

/** STEP 3. Compared requested with supported deeplink to find match*/
val match = deepLinkPatterns.firstNotNullOfOrNull { pattern ->
DeepLinkMatcher(request, pattern).match()
}
/** STEP 4. If match is found, associate match to the correct key*/
match?.let {
//leverage kotlinx.serialization's Decoder to decode
// match result into a backstack key
KeyDecoder(match.args)
.decodeSerializableValue(match.serializer)
}
}
}
}
Loading