diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bcc9d73..d5f8e8e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,7 +72,6 @@ dependencies { implementation(libs.androidx.adaptive.layout) implementation(libs.androidx.material3.navigation3) - implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.navigation3.runtime) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d749e4c..d75d220 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,7 +37,6 @@ android:theme="@style/Theme.Nav3Recipes"> - @@ -141,6 +140,48 @@ android:name=".migration.step7.Step7MigrationActivity" android:exported="true" android:theme="@style/Theme.Nav3Recipes"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt b/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt index 9532983..9fcc6bd 100644 --- a/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt +++ b/app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt @@ -31,6 +31,7 @@ import com.example.nav3recipes.basicdsl.BasicDslActivity import com.example.nav3recipes.basicsaveable.BasicSaveableActivity import com.example.nav3recipes.commonui.CommonUiActivity import com.example.nav3recipes.conditional.ConditionalActivity +import com.example.nav3recipes.deeplink.basic.CreateDeepLinkActivity import com.example.nav3recipes.dialog.DialogActivity import com.example.nav3recipes.modular.hilt.ModularActivity import com.example.nav3recipes.passingarguments.viewmodels.basic.BasicViewModelsActivity @@ -81,6 +82,9 @@ private val recipes = listOf( Heading("Returning Results"), Recipe("Return result as Event", ResultEventActivity::class.java), Recipe("Return result as State", ResultStateActivity::class.java), + + Heading("Deeplink"), + Recipe("Parse Intent", CreateDeepLinkActivity::class.java), ) class RecipePickerActivity : ComponentActivity() { diff --git a/app/src/main/java/com/example/nav3recipes/deeplink/basic/CreateDeepLinkActivity.kt b/app/src/main/java/com/example/nav3recipes/deeplink/basic/CreateDeepLinkActivity.kt new file mode 100644 index 0000000..94a1a43 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/deeplink/basic/CreateDeepLinkActivity.kt @@ -0,0 +1,189 @@ +package com.example.nav3recipes.deeplink.basic + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.example.nav3recipes.deeplink.basic.ui.DeepLinkButton +import com.example.nav3recipes.deeplink.basic.ui.EMPTY +import com.example.nav3recipes.deeplink.basic.ui.EntryScreen +import com.example.nav3recipes.deeplink.basic.ui.FIRST_NAME_JOHN +import com.example.nav3recipes.deeplink.basic.ui.FIRST_NAME_JULIE +import com.example.nav3recipes.deeplink.basic.ui.FIRST_NAME_MARY +import com.example.nav3recipes.deeplink.basic.ui.FIRST_NAME_TOM +import com.example.nav3recipes.deeplink.basic.ui.LOCATION_BC +import com.example.nav3recipes.deeplink.basic.ui.LOCATION_BR +import com.example.nav3recipes.deeplink.basic.ui.LOCATION_CA +import com.example.nav3recipes.deeplink.basic.ui.LOCATION_US +import com.example.nav3recipes.deeplink.basic.ui.MenuDropDown +import com.example.nav3recipes.deeplink.basic.ui.MenuTextInput +import com.example.nav3recipes.deeplink.basic.ui.PATH_BASE +import com.example.nav3recipes.deeplink.basic.ui.PATH_INCLUDE +import com.example.nav3recipes.deeplink.basic.ui.PATH_SEARCH +import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_HOME +import com.example.nav3recipes.deeplink.basic.ui.SearchKey +import com.example.nav3recipes.deeplink.basic.ui.TextContent +import com.example.nav3recipes.deeplink.basic.ui.HomeKey +import com.example.nav3recipes.deeplink.basic.ui.UsersKey + +/** + * This activity allows the user to create a deep link and make a request with it. + * + * **HOW THIS RECIPE WORKS** it consists of two activities - [CreateDeepLinkActivity] to construct + * and trigger the deeplink request, and the [MainActivity] to show how an app can handle + * that request. + * + * **DEMONSTRATED FORMS OF DEEPLINK** The [MainActivity] has a several backStack keys to + * demonstrate different types of supported deeplinks: + * 1. [HomeKey] - deeplink with an exact url (no deeplink arguments) + * 2. [UsersKey] - deeplink with path arguments + * 3. [SearchKey] - deeplink with query arguments + * See [MainActivity.deepLinkPatterns] for the actual url pattern of each. + * + * **RECIPE STRUCTURE** This recipe consists of three main packages: + * 1. basic.deeplink - Contains the two activities + * 2. basic.deeplink.ui - Contains the activity UI code, i.e. Screens, global string variables etc + * 3. basic.deeplink.deeplinkutil - Contains the classes and helper methods to parse and match + * the deeplinks + * + * See [MainActivity] for how the requested deeplink is handled. + */ +class CreateDeepLinkActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + /** + * UI for deeplink sandbox + */ + EntryScreen("Sandbox - Build Your Deeplink") { + TextContent("Base url:\n${PATH_BASE}/") + var showFilterOptions by remember { mutableStateOf(false) } + val selectedPath = remember { mutableStateOf(MENU_OPTIONS_PATH[KEY_PATH]?.first()) } + + var showQueryOptions by remember { mutableStateOf(false) } + var selectedFilter by remember { mutableStateOf("") } + val selectedSearchQuery = remember { mutableStateMapOf() } + + // manage path options + MenuDropDown( + menuOptions = MENU_OPTIONS_PATH, + ) { _, selection -> + selectedPath.value = selection + when (selection) { + PATH_SEARCH -> { + showQueryOptions = true + showFilterOptions = false + } + + PATH_INCLUDE -> { + showQueryOptions = false + showFilterOptions = true + } + + else -> { + showQueryOptions = false + showFilterOptions = false + } + } + } + + // manage path filter options, reset state if menu is closed + LaunchedEffect(showFilterOptions) { + selectedFilter = if (showFilterOptions) { + MENU_OPTIONS_FILTER.values.first().first() + } else { + "" + } + } + if (showFilterOptions) { + MenuDropDown( + menuOptions = MENU_OPTIONS_FILTER, + ) { _, selected -> + selectedFilter = selected + } + } + + // manage query options, reset state if menu is closed + LaunchedEffect(showQueryOptions) { + if (showQueryOptions) { + val initEntry = MENU_OPTIONS_SEARCH.entries.first() + selectedSearchQuery[initEntry.key] = initEntry.value.first() + } else { + selectedSearchQuery.clear() + } + } + if (showQueryOptions) { + MenuTextInput( + menuLabels = MENU_LABELS_SEARCH, + ) { label, selected -> + selectedSearchQuery[label] = selected + } + MenuDropDown( + menuOptions = MENU_OPTIONS_SEARCH, + ) { label, selected -> + selectedSearchQuery[label] = selected + } + } + + // form final deeplink url + val arguments = when (selectedPath.value) { + PATH_INCLUDE -> "/${selectedFilter}" + PATH_SEARCH -> { + buildString { + selectedSearchQuery.forEach { entry -> + if (entry.value.isNotEmpty()) { + val prefix = if (isEmpty()) "?" else "&" + append("$prefix${entry.key}=${entry.value}") + } + } + } + } + + else -> "" + } + val finalUrl = "${PATH_BASE}/${selectedPath.value}$arguments" + TextContent("Final url:\n$finalUrl") + // deeplink to target + DeepLinkButton( + context = this@CreateDeepLinkActivity, + targetActivity = MainActivity::class.java, + deepLinkUrl = finalUrl + ) + } + } + } +} + +private const val KEY_PATH = "path" +private val MENU_OPTIONS_PATH = mapOf( + KEY_PATH to listOf( + STRING_LITERAL_HOME, + PATH_INCLUDE, + PATH_SEARCH, + ), +) + +private val MENU_OPTIONS_FILTER = mapOf( + UsersKey.FILTER_KEY to listOf(UsersKey.FILTER_OPTION_RECENTLY_ADDED, UsersKey.FILTER_OPTION_ALL), +) + +private val MENU_OPTIONS_SEARCH = mapOf( + SearchKey::firstName.name to listOf( + EMPTY, + FIRST_NAME_JOHN, + FIRST_NAME_TOM, + FIRST_NAME_MARY, + FIRST_NAME_JULIE + ), + SearchKey::location.name to listOf(EMPTY, LOCATION_CA, LOCATION_BC, LOCATION_BR, LOCATION_US) +) + +private val MENU_LABELS_SEARCH = listOf(SearchKey::ageMin.name, SearchKey::ageMax.name) + + diff --git a/app/src/main/java/com/example/nav3recipes/deeplink/basic/MainActivity.kt b/app/src/main/java/com/example/nav3recipes/deeplink/basic/MainActivity.kt new file mode 100644 index 0000000..c3a6e7a --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/deeplink/basic/MainActivity.kt @@ -0,0 +1,128 @@ +package com.example.nav3recipes.deeplink.basic + +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.core.net.toUri +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay +import com.example.nav3recipes.deeplink.basic.deeplinkutil.DeepLinkMatcher +import com.example.nav3recipes.deeplink.basic.deeplinkutil.DeepLinkPattern +import com.example.nav3recipes.deeplink.basic.deeplinkutil.DeepLinkRequest +import com.example.nav3recipes.deeplink.basic.deeplinkutil.DeepLinkMatchResult +import com.example.nav3recipes.deeplink.basic.deeplinkutil.KeyDecoder +import com.example.nav3recipes.deeplink.basic.ui.EntryScreen +import com.example.nav3recipes.deeplink.basic.ui.FriendsList +import com.example.nav3recipes.deeplink.basic.ui.HomeKey +import com.example.nav3recipes.deeplink.basic.ui.LIST_USERS +import com.example.nav3recipes.deeplink.basic.ui.SearchKey +import com.example.nav3recipes.deeplink.basic.ui.TextContent +import com.example.nav3recipes.deeplink.basic.ui.URL_HOME_EXACT +import com.example.nav3recipes.deeplink.basic.ui.URL_SEARCH +import com.example.nav3recipes.deeplink.basic.ui.URL_USERS_WITH_FILTER +import com.example.nav3recipes.deeplink.basic.ui.UsersKey + +/** + * Parses a target deeplink into a NavKey. There are several crucial steps involved: + * + * STEP 1.Parse supported deeplinks (URLs that can be deeplinked into) into a readily readable + * format (see [DeepLinkPattern]) + * STEP 2. Parse the requested deeplink into a readily readable, format (see [DeepLinkRequest]) + * **note** the parsed requested deeplink and parsed supported deeplinks should be cohesive with each + * other to facilitate comparison and finding a match + * STEP 3. Compare the requested deeplink target with supported deeplinks in order to find a match + * (see [DeepLinkMatchResult]). The match result's format should enable conversion from result + * to backstack key, regardless of what the conversion method may be. + * STEP 4. Associate the match results with the correct backstack key + * + * This recipes provides an example for each of the above steps by way of kotlinx.serialization. + * + * **This recipe is designed to focus on parsing an intent into a key, and therefore these additional + * deeplink considerations are not included in this scope** + * - Create synthetic backStack + * - Multi-modular setup + * - DI + * - Managing TaskStack + * - Up button ves Back Button + * + */ +class MainActivity : ComponentActivity() { + /** STEP 1. Parse supported deeplinks */ + // internal so that landing activity can link to this in the kdocs + internal val deepLinkPatterns: List> = listOf( + // "https://www.nav3recipes.com/home" + DeepLinkPattern(HomeKey.serializer(), (URL_HOME_EXACT).toUri()), + // "https://www.nav3recipes.com/users/with/{filter}" + DeepLinkPattern(UsersKey.serializer(), (URL_USERS_WITH_FILTER).toUri()), + // "https://www.nav3recipes.com/users/search?{firstName}&{age}&{location}" + DeepLinkPattern(SearchKey.serializer(), (URL_SEARCH.toUri())), + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // retrieve the target Uri + val uri: Uri? = intent.data + // associate the target with the correct backstack key + val key: NavKey = 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) + } + } ?: HomeKey // fallback if intent.uri is null or match is not found + + /** + * Then pass starting key to backstack + */ + setContent { + val backStack: NavBackStack = rememberNavBackStack(key) + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryProvider = entryProvider { + entry { key -> + EntryScreen(key.name) { + TextContent("") + } + } + entry { key -> + EntryScreen("${key.name} : ${key.filter}") { + TextContent("") + val list = when { + key.filter.isEmpty() -> LIST_USERS + key.filter == UsersKey.FILTER_OPTION_ALL -> LIST_USERS + else -> LIST_USERS.take(5) + } + FriendsList(list) + } + } + entry { search -> + EntryScreen(search.name) { + TextContent("") + val matchingUsers = LIST_USERS.filter { user -> + (search.firstName == null || user.firstName == search.firstName) && + (search.location == null || user.location == search.location) && + (search.ageMin == null || user.age >= search.ageMin) && + (search.ageMax == null || user.age <= search.ageMax) + } + FriendsList(matchingUsers) + } + } + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/DeepLinkMatcher.kt b/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/DeepLinkMatcher.kt new file mode 100644 index 0000000..fdaf5bd --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/DeepLinkMatcher.kt @@ -0,0 +1,80 @@ +package com.example.nav3recipes.deeplink.basic.deeplinkutil + +import android.util.Log +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.KSerializer + +internal class DeepLinkMatcher( + val request: DeepLinkRequest, + val deepLinkPattern: DeepLinkPattern +) { + /** + * Match a [DeepLinkRequest] to a [DeepLinkPattern]. + * + * Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise + */ + fun match(): DeepLinkMatchResult? { + 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() + // 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 + val queryStringParser = deepLinkPattern.queryValueParsers[name] + 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( + val serializer: KSerializer, + val args: Map +) + +const val TAG_LOG_ERROR = "Nav3RecipesDeepLink" \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/DeepLinkPattern.kt b/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/DeepLinkPattern.kt new file mode 100644 index 0000000..c19c624 --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/DeepLinkPattern.kt @@ -0,0 +1,118 @@ +package com.example.nav3recipes.deeplink.basic.deeplinkutil + +import android.net.Uri +import androidx.navigation3.runtime.NavKey +import com.example.nav3recipes.deeplink.basic.ui.NavRecipeKey +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialKind +import java.io.Serializable + +/** + * Parse a supported deeplink and stores its metadata as a easily readable format + * + * The following notes applies specifically to this particular sample implementation: + * + * The supported deeplink is expected to be built from a serializable backstack key [T] that + * supports deeplink. This means that if this deeplink contains any arguments (path or query), + * the argument name must match any of [T] member field name. + * + * One [DeepLinkPattern] should be created for each supported deeplink. This means if [T] + * supports two deeplink patterns: + * ``` + * val deeplink1 = www.nav3recipes.com/home + * val deeplink2 = www.nav3recipes.com/profile/{userId} + * ``` + * Then two [DeepLinkPattern] should be created + * ``` + * val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1) + * val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2) + * ``` + * + * This implementation assumes a few things: + * 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match + * 2. all query arguments are optional by way of nullable/has default value + * + * @param T the backstack key type that supports the deeplinking of [uriPattern] + * @param serializer the serializer of [T] + * @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}" + */ +internal class DeepLinkPattern( + val serializer: KSerializer, + val uriPattern: Uri +) { + /** + * Help differentiate if a path segment is an argument or a static value + */ + private val regexPatternFillIn = Regex("\\{(.+?)\\}") + + // TODO make these lazy + /** + * parse the path into a list of [PathSegment] + * + * order matters here - path segments need to match in value and order when matching + * requested deeplink to supported deeplink + */ + val pathSegments: List = buildList { + uriPattern.pathSegments.forEach { segment -> + // first, check if it is a path arg + var result = regexPatternFillIn.find(segment) + if (result != null) { + // if so, extract the path arg name (the string value within the curly braces) + val argName = result.groups[1]!!.value + // from [T], read the primitive type of this argument to get the correct type parser + val elementIndex = serializer.descriptor.getElementIndex(argName) + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + // finally, add the arg name and its respective type parser to the map + add(PathSegment(argName, true,getTypeParser(elementDescriptor.kind))) + } else { + // if its not a path arg, then its just a static string path segment + add(PathSegment(segment,false, getTypeParser(PrimitiveKind.STRING))) + } + } + } + + /** + * Parse supported queries into a map of queryParameterNames to [TypeParser] + * + * This will be used later on to parse a provided query value into the correct KType + */ + val queryValueParsers: Map = buildMap { + uriPattern.queryParameterNames.forEach { paramName -> + val elementIndex = serializer.descriptor.getElementIndex(paramName) + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + this[paramName] = getTypeParser(elementDescriptor.kind) + } + } + + /** + * Metadata about a supported path segment + */ + class PathSegment( + val stringValue: String, + val isParamArg: Boolean, + val typeParser: TypeParser + ) +} + +/** + * Parses a String into a Serializable Primitive + */ +private typealias TypeParser = (String) -> Serializable + +private fun getTypeParser(kind: SerialKind): TypeParser { + return when (kind) { + PrimitiveKind.STRING -> Any::toString + PrimitiveKind.INT -> String::toInt + PrimitiveKind.BOOLEAN -> String::toBoolean + PrimitiveKind.BYTE -> String::toByte + PrimitiveKind.CHAR -> String::toCharArray + PrimitiveKind.DOUBLE -> String::toDouble + PrimitiveKind.FLOAT -> String::toFloat + PrimitiveKind.LONG -> String::toLong + PrimitiveKind.SHORT -> String::toShort + else -> throw IllegalArgumentException( + "Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive." + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/DeepLinkRequest.kt.kt b/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/DeepLinkRequest.kt.kt new file mode 100644 index 0000000..fe27e2f --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/DeepLinkRequest.kt.kt @@ -0,0 +1,28 @@ +package com.example.nav3recipes.deeplink.basic.deeplinkutil + +import android.net.Uri + +/** + * Parse the requested Uri and store it in a easily readable format + * + * @param uri the target deeplink uri to link to + */ +internal class DeepLinkRequest( + val uri: Uri +) { + /** + * A list of path segments + */ + val pathSegments: List = uri.pathSegments + + /** + * A map of query name to query value + */ + val queries = buildMap { + uri.queryParameterNames.forEach { argName -> + this[argName] = uri.getQueryParameter(argName)!! + } + } + + // TODO add parsing for other Uri components, i.e. fragments, mimeType, action +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/KeyDecoder.kt b/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/KeyDecoder.kt new file mode 100644 index 0000000..85a50df --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/deeplink/basic/deeplinkutil/KeyDecoder.kt @@ -0,0 +1,70 @@ +package com.example.nav3recipes.deeplink.basic.deeplinkutil + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractDecoder +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +/** + * Decodes the list of arguments into a a backstack key + * + * **IMPORTANT** This decoder assumes that all argument types are Primitives. + */ +@OptIn(ExperimentalSerializationApi::class) +internal class KeyDecoder( + private val arguments: Map, +) : AbstractDecoder() { + + override val serializersModule: SerializersModule = EmptySerializersModule() + private var elementIndex: Int = -1 + private var elementName: String = "" + + /** + * Decodes the index of the next element to be decoded. Index represents a position of the + * current element in the [descriptor] that can be found with [descriptor].getElementIndex. + * + * The returned index will trigger deserializer to call [decodeValue] on the argument at that + * index. + * + * The decoder continually calls this method to process the next available argument until this + * method returns [CompositeDecoder.DECODE_DONE], which indicates that there are no more + * arguments to decode. + * + * This method should sequentially return the element index for every element that has its value + * available within [arguments]. + */ + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + var currentIndex = elementIndex + while (true) { + // proceed to next element + currentIndex++ + // if we have reached the end, let decoder know there are not more arguments to decode + if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + val currentName = descriptor.getElementName(currentIndex) + // Check if bundle has argument value. If so, we tell decoder to process + // currentIndex. Otherwise, we skip this index and proceed to next index. + if (arguments.contains(currentName)) { + elementIndex = currentIndex + elementName = currentName + return elementIndex + } + } + } + + /** + * Returns argument value from the [arguments] for the argument at the index returned by + * [decodeElementIndex] + */ + override fun decodeValue(): Any { + val arg = arguments[elementName] + checkNotNull(arg) { "Unexpected null value for non-nullable argument $elementName" } + return arg + } + + override fun decodeNull(): Nothing? = null + + // we want to know if it is not null, so its !isNull + override fun decodeNotNullMark(): Boolean = arguments[elementName] != null +} \ No newline at end of file diff --git a/app/src/main/java/com/example/nav3recipes/deeplink/basic/ui/CommonResources.kt b/app/src/main/java/com/example/nav3recipes/deeplink/basic/ui/CommonResources.kt new file mode 100644 index 0000000..416677f --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/deeplink/basic/ui/CommonResources.kt @@ -0,0 +1,52 @@ +package com.example.nav3recipes.deeplink.basic.ui + +/** + * String resources + */ +internal const val STRING_LITERAL_FILTER = "filter" +internal const val STRING_LITERAL_HOME = "home" +internal const val STRING_LITERAL_USERS = "users" +internal const val STRING_LITERAL_SEARCH = "search" +internal const val STRING_LITERAL_INCLUDE = "include" +internal const val PATH_BASE = "https://www.nav3recipes.com" +internal const val PATH_INCLUDE = "$STRING_LITERAL_USERS/$STRING_LITERAL_INCLUDE" +internal const val PATH_SEARCH = "$STRING_LITERAL_USERS/$STRING_LITERAL_SEARCH" +internal const val URL_HOME_EXACT = "$PATH_BASE/$STRING_LITERAL_HOME" + +internal const val URL_USERS_WITH_FILTER = "$PATH_BASE/$PATH_INCLUDE/{$STRING_LITERAL_FILTER}" +internal val URL_SEARCH = "$PATH_BASE/$PATH_SEARCH" + + "?${SearchKey::ageMin.name}={${SearchKey::ageMin.name}}" + + "&${SearchKey::ageMax.name}={${SearchKey::ageMax.name}}" + + "&${SearchKey::firstName.name}={${SearchKey::firstName.name}}" + + "&${SearchKey::location.name}={${SearchKey::location.name}}" + +/** + * User data + */ +internal const val FIRST_NAME_JOHN = "John" +internal const val FIRST_NAME_TOM = "Tom" +internal const val FIRST_NAME_MARY = "Mary" +internal const val FIRST_NAME_JULIE = "Julie" +internal const val LOCATION_CA = "CA" +internal const val LOCATION_BC = "BC" +internal const val LOCATION_BR = "BR" +internal const val LOCATION_US = "US" +internal const val EMPTY = "" +internal val LIST_USERS = listOf( + User(FIRST_NAME_JOHN, 15, LOCATION_CA), + User(FIRST_NAME_JOHN, 22, LOCATION_BC), + User(FIRST_NAME_TOM, 25, LOCATION_CA), + User(FIRST_NAME_TOM, 68, LOCATION_BR), + User(FIRST_NAME_JULIE, 48, LOCATION_BR), + User(FIRST_NAME_JULIE, 33, LOCATION_US), + User(FIRST_NAME_JULIE, 9, LOCATION_BR), + User(FIRST_NAME_MARY, 64, LOCATION_US), + User(FIRST_NAME_MARY, 5, LOCATION_CA), + User(FIRST_NAME_MARY, 52, LOCATION_BC), + User(FIRST_NAME_TOM, 94, LOCATION_BR), + User(FIRST_NAME_JULIE, 46, LOCATION_CA), + User(FIRST_NAME_JULIE, 37, LOCATION_BC), + User(FIRST_NAME_JULIE, 73 ,LOCATION_US), + User(FIRST_NAME_MARY, 51, LOCATION_US), + User(FIRST_NAME_MARY, 63, LOCATION_BR), +) diff --git a/app/src/main/java/com/example/nav3recipes/deeplink/basic/ui/CommonScreens.kt b/app/src/main/java/com/example/nav3recipes/deeplink/basic/ui/CommonScreens.kt new file mode 100644 index 0000000..c815efa --- /dev/null +++ b/app/src/main/java/com/example/nav3recipes/deeplink/basic/ui/CommonScreens.kt @@ -0,0 +1,232 @@ +package com.example.nav3recipes.deeplink.basic.ui + +import android.content.Context +import android.content.Intent +import androidx.compose.animation.animateContentSize +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +internal interface NavRecipeKey: NavKey { + val name: String +} + +@Serializable +internal object HomeKey: NavRecipeKey { + override val name: String = STRING_LITERAL_HOME +} + +@Serializable +internal data class UsersKey( + val filter: String, +): NavRecipeKey { + override val name: String = STRING_LITERAL_USERS + companion object { + const val FILTER_KEY = STRING_LITERAL_FILTER + const val FILTER_OPTION_RECENTLY_ADDED = "recentlyAdded" + const val FILTER_OPTION_ALL = "all" + } +} + +@Serializable +internal data class SearchKey( + val firstName: String? = null, + val ageMin: Int? = null, + val ageMax: Int? = null, + val location: String? = null, +): NavRecipeKey { + override val name: String = STRING_LITERAL_SEARCH +} + +@Serializable +internal data class User( + val firstName: String, + val age: Int, + val location: String, +) + +@Composable +internal fun EntryScreen(text: String, content: @Composable () -> Unit = { }) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(text, fontWeight = FontWeight.Bold, fontSize = FONT_SIZE_TITLE) + content() + } + } +} + +@Composable +internal fun FriendsList(users: List) { + // display list of matching targets + if (users.isEmpty()) { + Text("List is Empty", fontWeight = FontWeight.Bold) + } else { + LazyColumn { + items(users.size) { idx -> + val user = users[idx] + TextContent("${user.firstName}(${user.age}), ${user.location}") + } + } + } +} + +/** + * Displays a text input menu, may include several text fields + */ +@Composable +internal fun MenuTextInput( + menuLabels: List, + onValueChange: (String, String) -> Unit = { _, _ ->}, +) { + Column { + menuLabels.forEach { label -> + var inputText by remember { mutableStateOf("") } + + OutlinedTextField( + value = inputText, + onValueChange = { + inputText = it + onValueChange(label, it) + }, + placeholder = { Text("enter integer") }, + label = { Text(label) }, + ) + } + } + +} + +/** + * Displays a drop down menu, may include multiple drop downs + */ +@Composable +internal fun MenuDropDown( + menuOptions: Map>, + onSelect: (label: String, selection: String) -> Unit = { _, _ ->}, +) { + Column( + modifier = Modifier.animateContentSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + menuOptions.forEach { entry -> + val key = entry.key + ArgumentDropDownMenu(label = key, menuItemOptions = entry.value) { label, selection -> + onSelect(key, selection) + } + } + } +} + +// Display list of selections for one drop down +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ArgumentDropDownMenu( + label: String, + menuItemOptions: List, + onSelect: (label: String, selection: String) -> Unit, +) { + val initValue = menuItemOptions.firstOrNull() ?: "" + var expanded by remember { mutableStateOf(false) } + var currSelected by remember { mutableStateOf(initValue) } + Box( + modifier = Modifier + .padding(16.dp) + ) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + TextField( + readOnly = true, + value = currSelected, + onValueChange = { }, + label = { Text(label) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + }, + modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true) + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + } + ) { + menuItemOptions.forEach { text -> + DropdownMenuItem( + text = { Text(text) }, + onClick = { + expanded = false + currSelected = text + onSelect(label, text) + } + ) + } + } + } + } +} + +@Composable +internal fun DeepLinkButton( + context: Context, + targetActivity: Class<*>, + deepLinkUrl: String, +) { + Button( + onClick = { + val intent = Intent( + context, + targetActivity + ) + // start activity with the url + intent.data = deepLinkUrl.toUri() + context.startActivity(intent) + } + ) { + Text("Deeplink away!") + } +} + +@Composable +fun TextContent(text: String) { + Text( + text = text, + modifier = Modifier.width(300.dp), + textAlign = TextAlign.Center, + fontSize = FONT_SIZE_TEXT, + ) +} + +internal val FONT_SIZE_TITLE: TextUnit = 20.sp +internal val FONT_SIZE_TEXT: TextUnit = 15.sp