Skip to content
155 changes: 113 additions & 42 deletions app/src/main/java/com/kickstarter/features/home/ui/HomeActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Card
Expand All @@ -30,18 +30,27 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.kickstarter.features.home.data.Tab
import com.kickstarter.features.home.ui.components.FloatingBottomNav
import com.kickstarter.features.home.viewmodel.HomeScreenViewModel
import com.kickstarter.features.search.viewmodel.FilterMenuViewModel
import com.kickstarter.features.search.viewmodel.SearchAndFilterViewModel
import com.kickstarter.libs.Environment
import com.kickstarter.libs.utils.ThirdPartyEventValues
import com.kickstarter.libs.utils.TransitionUtils
import com.kickstarter.libs.utils.extensions.getEnvironment
import com.kickstarter.libs.utils.extensions.isDarkModeEnabled
import com.kickstarter.ui.activities.compose.search.SearchAndFilterScreen
import com.kickstarter.ui.compose.designsystem.KickstarterApp
import com.kickstarter.ui.extensions.setUpConnectivityStatusCheck
import com.kickstarter.ui.extensions.startPreLaunchProjectActivity
import com.kickstarter.ui.extensions.startProjectActivity
import com.kickstarter.ui.extensions.transition
import kotlin.getValue
import kotlin.random.Random
Expand All @@ -52,6 +61,12 @@ class HomeActivity : ComponentActivity() {
private lateinit var viewModelFactory: HomeScreenViewModel.Factory
private val viewModel: HomeScreenViewModel by viewModels { viewModelFactory }

// Search related VM's
private lateinit var searchVMFactory: SearchAndFilterViewModel.Factory
private lateinit var filterMenuViewModelFactory: FilterMenuViewModel.Factory
private val searchVM: SearchAndFilterViewModel by viewModels { searchVMFactory }
private val filterMenuVM: FilterMenuViewModel by viewModels { filterMenuViewModelFactory }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpConnectivityStatusCheck(lifecycle)
Expand All @@ -60,6 +75,9 @@ class HomeActivity : ComponentActivity() {
this.getEnvironment()?.let { env ->
environment = env
viewModelFactory = HomeScreenViewModel.Factory(env)
searchVMFactory = SearchAndFilterViewModel.Factory(environment)
filterMenuViewModelFactory = FilterMenuViewModel.Factory(environment)
filterMenuVM.getRootCategories()
}

setContent {
Expand All @@ -70,12 +88,12 @@ class HomeActivity : ComponentActivity() {
listOf(
Tab.Home,
Tab.Search,
if (homeUIState.userAvatarUrl.isNotEmpty()) Tab.Profile(homeUIState.userAvatarUrl) else Tab.LogIn
if (homeUIState.isLoggedInUser) Tab.Profile(homeUIState.userAvatarUrl) else Tab.LogIn
)
}

KickstarterApp(useDarkTheme = darModeEnabled) {
App(tabs = tabs)
App(tabs)
}
}

Expand All @@ -86,52 +104,105 @@ class HomeActivity : ComponentActivity() {
}
})
}
}

@Composable
@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
fun HomeActivityPreview() {
val tabs = listOf(
Tab.Home,
Tab.Search,
Tab.LogIn
)
KickstarterApp {
App(tabs = tabs)
@Composable
@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
fun HomeActivityPreview() {
val tabs = listOf(
Tab.Home,
Tab.Search,
Tab.LogIn
)
KickstarterApp {
App(tabs = tabs)
}
}

@Composable
private fun App(tabs: List<Tab>) {
val navController = rememberNavController()
val shouldShowBottomNav = remember { mutableStateOf(true) }
val backStack by navController.currentBackStackEntryAsState()
val currentRoute = backStack?.destination?.route

val activeTab = tabs.find { it.route == currentRoute } ?: tabs.first()
Scaffold(
modifier = Modifier.systemBarsPadding(),
bottomBar = {
if (shouldShowBottomNav.value) {
FloatingBottomNav(
tabs = tabs,
activeTab = activeTab,
onTabClicked = { tab ->
navController.navWithDefaults(tab.route)
}
)
}
}
) { inner ->
NavHost(
navController = navController,
startDestination = tabs.first().route,
modifier = Modifier
.fillMaxSize()
.padding(top = inner.calculateTopPadding())
) {
tabs.map { tab ->
when (tab) {
is Tab.Search -> {
composable(tab.route) {
SearchAndFilterScreen(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Integrating the newly created SearchAndFilterScreen as navigation destination by utilizing the same Composable and ViewModel pair currently used in SearchAndFilterActivity, we are ensuring feature parity while initiating our transition away from Activity-based navigation. This represents the first step in our long-term strategy to adopt a modern, Compose-driven navigation architecture.

env = environment,
searchViewModel = searchVM,
filterMenuVM = filterMenuVM,
onBackClicked = { },
preLaunchedCallback = { project, tag ->
startPreLaunchProjectActivity(
project = project,
previousScreen = ThirdPartyEventValues.ScreenName.SEARCH.value,
refTag = tag
)
},
projectCallback = { projectAndRef ->
startProjectActivity(
project = projectAndRef.first,
refTag = projectAndRef.second,
previousScreen = ThirdPartyEventValues.ScreenName.SEARCH.value
)
},
)
}
}

else -> {
composable(tab.route) {
ScreenStub(tab.route)
}
}
}
}
}
}
}
}

/**
* Home Screen composable parent UI
* Navigates to a specified route using the standard default configuration for bottom navigation.
*
* @param tabs: Contains the list of tabs represented on the floating bottomNav
* This helper ensures that:
* 1. The back stack is popped up to the start destination to avoid a large stack of screens.
* 2. State is saved and restored when switching between tabs.
* 3. Only a single instance of a destination is launched (launchSingleTop) to prevent multiple
* copies of the same screen when re-selecting a tab.
*
* @param route The destination route to navigate to.
*/
@Composable
fun App(
tabs: List<Tab> = listOf(Tab.Home, Tab.Search, Tab.LogIn)
) {
val nav = rememberNavController()
val shouldShowBottomNav = remember { mutableStateOf(true) }
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
bottomBar = {
if (shouldShowBottomNav.value) {
FloatingBottomNav(nav, tabs = tabs)
}
}
) { inner ->
NavHost(
navController = nav,
startDestination = Tab.Home.route,
modifier = Modifier
.fillMaxSize()
.padding(top = inner.calculateTopPadding())
) {
tabs.map { tab ->
composable(tab.route) { ScreenStub(tab.route) }
}
}
private fun NavHostController.navWithDefaults(route: String) {
this.navigate(route) {
popUpTo(this@navWithDefaults.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.kickstarter.features.home.data.Tab
import com.kickstarter.features.home.data.TabIcon
import com.kickstarter.features.home.ui.components.FloatingBottomNavTestTags.SLIDING_INDICATOR
Expand All @@ -61,9 +57,8 @@ fun FloatingBottomNavLoggedOutPreview() {
Box(
modifier = Modifier.background(Color.LightGray)
) {
val nav = rememberNavController()
val tabs = listOf<Tab>(Tab.Home, Tab.Search, Tab.LogIn)
FloatingBottomNav(nav, tabs)
FloatingBottomNav(tabs)
}
}
}
Expand All @@ -76,9 +71,8 @@ fun FloatingBottomNavLoggedInPreview() {
Box(
modifier = Modifier.background(Color.LightGray)
) {
val nav = rememberNavController()
val tabs = listOf<Tab>(Tab.Home, Tab.Search, Tab.Profile(""))
FloatingBottomNav(nav, tabs)
FloatingBottomNav(tabs)
}
}
}
Expand Down Expand Up @@ -167,21 +161,19 @@ private fun FloatingCenterNavItem(

@Composable
fun FloatingBottomNav(
nav: NavHostController,
tabs: List<Tab> = listOf(Tab.Home, Tab.Search, Tab.LogIn)
tabs: List<Tab> = listOf(Tab.Home, Tab.Search, Tab.LogIn),
activeTab: Tab = Tab.Home, // - initial active tab will be home
onTabClicked: (Tab) -> Unit = { a -> }
) {
// TODO: hoisting the navigation logic, FloatingCenterBottomNav should only have a callback for ie onTabSelected: (Tab) -> Unit
val backStack by nav.currentBackStackEntryAsState()
val current = backStack?.destination?.route
val activeIndex = remember(current, tabs) {
tabs.indexOfFirst { it.route == current }.coerceAtLeast(0)
}

// - animation offSet X for sliding container
val indicatorOffset = remember { Animatable(0f) }
// - coordinates directory data sample: Index 0 (Home) is at 40.0f || Index 1 (Search) is at 120.0f ...
val tabsXCoordinate = remember { mutableStateMapOf<Int, Float>() }

val activeIndex = remember(activeTab, tabs) {
tabs.indexOf(activeTab).coerceAtLeast(0)
}

LaunchedEffect(activeIndex, tabsXCoordinate.size) {
tabsXCoordinate[activeIndex]?.let { targetX ->
indicatorOffset.animateTo(
Expand Down Expand Up @@ -240,7 +232,7 @@ fun FloatingBottomNav(
verticalAlignment = Alignment.CenterVertically
) {
tabs.forEachIndexed { index, tab ->
val selected = current == tab.route
val selected = activeTab.route == tab.route
Box(
modifier = Modifier
.weight(1f)
Expand All @@ -259,14 +251,7 @@ fun FloatingBottomNav(
tab = tab,
selected = selected,
onClick = {
// TODO: should be a callback floating Nav should have no knowledge of navigation graph.
nav.navigate(tab.route) {
popUpTo(nav.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
onTabClicked(tab)
}
)
}
Expand Down
Loading