Skip to content

Commit

Permalink
Merge pull request #17 from futuredapp/feature/predictive-back-gesture
Browse files Browse the repository at this point in the history
Predictive back gesture and navigation update
  • Loading branch information
PavelMesicek authored May 16, 2024
2 parents bdf81e0 + 6c2d672 commit 5182e7a
Show file tree
Hide file tree
Showing 23 changed files with 498 additions and 105 deletions.
3 changes: 1 addition & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,7 @@ dependencies {
implementation(Dependencies.Compose.animation)
implementation(Dependencies.Compose.foundation)
implementation(Dependencies.Compose.foundation_layout)
implementation(Dependencies.Compose.material)
implementation(Dependencies.Compose.material_icons_extended)
implementation(Dependencies.Compose.material3)
implementation(Dependencies.Compose.runtime_livedata)
implementation(Dependencies.Compose.runtime)
implementation(Dependencies.Compose.ui)
Expand Down
7 changes: 5 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">

<application
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.App"
android:supportsRtl="true">
android:supportsRtl="true"
tools:targetApi="tiramisu">

<activity
android:name=".AppActivity"
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/kotlin/app/futured/androidprojecttemplate/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app.futured.androidprojecttemplate
import android.app.Application
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import app.futured.androidprojecttemplate.ui.AppUI
import dagger.hilt.android.AndroidEntryPoint
Expand All @@ -23,6 +24,7 @@ class App : Application() {
class AppActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AppUI()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.futured.androidprojecttemplate.navigation

import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NamedNavArgument
Expand All @@ -11,19 +12,35 @@ import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
import androidx.navigation.navArgument
import app.futured.androidprojecttemplate.ui.screens.detail.DetailScreen
import app.futured.androidprojecttemplate.ui.screens.home.HomeScreen

typealias DestinationArgumentKey = String
typealias DestinationArgumentValue = String

internal val screens = listOf(
Destination.Home,
Destination.Detail,
)

internal val dialogs = listOf<Destination>()

sealed class Destination(
val route: String,
val arguments: List<NamedNavArgument> = emptyList(),
val deepLinks: List<NavDeepLink> = emptyList(),
val destinationScreen: @Composable (router: NavRouter) -> Unit,
) {
data object Home : Destination(route = "home")
data object Home : Destination(
route = "home",
destinationScreen = { HomeScreen(navigation = it) },
)

data object Detail : Destination(
route = "detail/{title}?subtitle={subtitle}?value={value}",
arguments = listOf(
destinationScreen = { DetailScreen(navigation = it) },
arguments =
listOf(
navArgument("title") {
type = NavType.StringType
},
Expand All @@ -47,7 +64,7 @@ sealed class Destination(
/**
* Registers provided [destination] as a composable in [NavGraphBuilder].
*/
fun NavGraphBuilder.composable(
fun NavGraphBuilder.composableScreen(
destination: Destination,
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) = composable(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package app.futured.androidprojecttemplate.navigation

interface NavRouter {
fun popBackStack()
fun navigateBack(popUpToDestination: Destination, inclusive: Boolean = false)

fun navigateToDetail(title: String, subtitle: String? = null, value: String? = null)

fun <T> navigateBackWithResult(key: String, value: T)
fun <T> setCurrentResult(key: String, value: T)
fun <T> subscribeForResult(key: String, callback: (T) -> Unit)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package app.futured.androidprojecttemplate.navigation

import androidx.navigation.NavController
import app.futured.androidprojecttemplate.tools.extensions.subscribeForResult
import timber.log.Timber

/**
* Class that triggers navigation actions on provided [navController].
*/
class NavRouterImpl(private val navController: NavController) : NavRouter {
override fun popBackStack() {
navController.navigateUp()
}

override fun navigateBack(popUpToDestination: Destination, inclusive: Boolean) {
navController.popBackStack(route = popUpToDestination.route, inclusive = inclusive)
}

override fun navigateToDetail(title: String, subtitle: String?, value: String?) =
Destination.Detail.buildRoute(title, subtitle, value).execute()

override fun <T> navigateBackWithResult(key: String, value: T) {
navController.previousBackStackEntry?.savedStateHandle?.also {
it[key] = value
navController.popBackStack()
}
}

override fun <T> setCurrentResult(key: String, value: T) {
navController.currentBackStackEntry?.savedStateHandle?.also {
it[key] = value
}
}

override fun <T> subscribeForResult(key: String, callback: (T) -> Unit) {
navController.currentBackStackEntry?.savedStateHandle?.subscribeForResult<T>(key) { callback(it) }
}

private fun String.execute(
popUpToDestinationRoute: String? = null,
isInclusive: Boolean = true,
) {
Timber.d("## Navigate to $this, popupTo $popUpToDestinationRoute, inclusive $isInclusive")
if (popUpToDestinationRoute != null) {
navController.navigate(this) {
popUpTo(popUpToDestinationRoute) {
inclusive = isInclusive
}
}
} else {
navController.navigate(this)
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package app.futured.androidprojecttemplate.tools.extensions

inline fun <T> T?.ifNull(defaultValue: () -> T): T = this ?: defaultValue.invoke()

inline fun <T> withValue(receiver: T, block: T.() -> Unit) {
receiver.block()
}

inline fun <reified T : Any> withNonNullValue(receiver: T?, block: (T) -> Unit) {
if (receiver != null) block(receiver)
}

inline fun <T> safe(block: () -> T): T? {
return try {
block()
} catch (e: Exception) {
e.printStackTrace()
null
}
}

fun <T> T?.orThrow(): T = this ?: error("UnexpectedError") // Dev error

inline fun <reified T> ifElseNull(predicate: Boolean, block: () -> T?) = if (predicate) block.invoke() else null

inline fun <T> T.runWith(block: (T) -> Unit) {
block(this)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package app.futured.androidprojecttemplate.tools.extensions

import android.util.Base64.NO_PADDING
import android.util.Base64.NO_WRAP
import android.util.Base64.URL_SAFE
import android.util.Base64.decode
import android.util.Base64.encodeToString
import androidx.lifecycle.Observer
import androidx.lifecycle.SavedStateHandle
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

inline fun <reified T> SavedStateHandle.getSerializedArgument(key: String): T? = get<String>(key)?.deserializeFromNavArgument()

inline fun <reified T> SavedStateHandle.getRequiredSerializedArgument(key: String): T =
getSerializedArgument(key) ?: error("Required parameter is not present")

inline fun <reified T> SavedStateHandle.getArgument(key: String): T? = safe { get<T>(key) }

inline fun <reified T> SavedStateHandle.getRequiredArgument(key: String): T =
getArgument(key) ?: getSerializedArgument(key) ?: error("Required parameter is not present")

inline fun <reified T : Any> T.serializeAsNavArgument(): String? =
encodeToString(Json.encodeToString(this).encodeToByteArray(), NO_PADDING or NO_WRAP or URL_SAFE)

inline fun <reified T> String.deserializeFromNavArgument(): T? =
safe { Json.decodeFromString(decode(this, NO_PADDING or NO_WRAP or URL_SAFE).decodeToString()) }

fun <T> SavedStateHandle.subscribeForResult(key: String, callback: (T) -> Unit) {
val liveData = this.getLiveData<T>(key)
val observer = object : Observer<T> {
override fun onChanged(t: T) {
if (t != null) {
val value = remove<T>(key)
if (value != null) {
callback(t)
}
liveData.removeObserver(this)
subscribeForResult(key, callback)
}
}
}
liveData.observeForever(observer)
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
package app.futured.androidprojecttemplate.ui

import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import app.futured.androidprojecttemplate.navigation.Destination
import app.futured.androidprojecttemplate.navigation.NavigationDestinations
import app.futured.androidprojecttemplate.navigation.NavigationDestinationsImpl
import app.futured.androidprojecttemplate.navigation.composable
import app.futured.androidprojecttemplate.ui.screens.detail.DetailScreen
import app.futured.androidprojecttemplate.ui.screens.home.HomeScreen
import app.futured.androidprojecttemplate.navigation.NavRouter
import app.futured.androidprojecttemplate.navigation.NavRouterImpl
import app.futured.androidprojecttemplate.navigation.composableDialog
import app.futured.androidprojecttemplate.navigation.composableScreen
import app.futured.androidprojecttemplate.navigation.dialogs
import app.futured.androidprojecttemplate.navigation.screens

@Composable
fun NavGraph(
navController: NavHostController = rememberNavController(),
navigation: NavigationDestinations = remember { NavigationDestinationsImpl(navController) },
navigation: NavRouter = remember { NavRouterImpl(navController) },
) {
LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher?.let {
navController.navigateUp()
}

NavHost(
navController = navController,
startDestination = Destination.Home.route,
) {
composable(Destination.Home) {
HomeScreen(navigation)
// Destinations without navbar at the bottom
screens.forEach { destination ->
composableScreen(destination) { destination.destinationScreen(navigation) }
}

composable(Destination.Detail) {
DetailScreen(navigation)
// Dialogs
dialogs.forEach { destination ->
composableDialog(destination) { destination.destinationScreen(navigation) }
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package app.futured.androidprojecttemplate.ui.components

import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.futured.androidprojecttemplate.tools.compose.ComponentPreviews
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package app.futured.androidprojecttemplate.ui.components

import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.futured.androidprojecttemplate.ui.theme.AppTheme
Expand All @@ -12,7 +12,7 @@ import app.futured.androidprojecttemplate.ui.theme.AppTheme
@Composable
fun Showcase(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
AppTheme {
Surface(color = MaterialTheme.colors.background, modifier = modifier) {
Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) {
content()
}
}
Expand Down
Loading

0 comments on commit 5182e7a

Please sign in to comment.