Skip to content
Open
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
13 changes: 4 additions & 9 deletions app/src/main/java/ru/yeahub/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import androidx.compose.runtime.setValue
import androidx.navigation.compose.rememberNavController
import org.koin.android.ext.android.inject
import ru.yeahub.core_ui.theme.YeaHubTheme
import ru.yeahub.navigation_api.NavigationPathManager
import ru.yeahub.navigation_impl.AppNavigation
import ru.yeahub.navigation_impl.NotificationNavigationService
import timber.log.Timber
Expand Down Expand Up @@ -49,17 +48,13 @@ import timber.log.Timber
* - Используйте NavController для навигации между экранами
*/
class MainActivity : ComponentActivity() {

private val pathManager: NavigationPathManager by inject()
private lateinit var notificationService: NotificationNavigationService


private val notificationService: NotificationNavigationService by inject()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()

// Инициализируем сервис уведомлений
notificationService = NotificationNavigationService(pathManager)


setContent {
YeaHubTheme {
val navController = rememberNavController()
Expand Down
2 changes: 2 additions & 0 deletions core/navigation-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ android {
}

dependencies {
api(project(":core:feature-toggle-api"))

implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ package ru.yeahub.navigation_api
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import ru.yeahub.feature_toggle_api.FeatureToggle

/**
* Интерфейс для модульной регистрации навигации фичи.
*
*
* Каждая фича должна реализовать этот интерфейс, чтобы:
* 1. Максимально изолировать логику навигации внутри модуля
* 2. Самостоятельно регистрировать все необходимые маршруты
Expand All @@ -16,14 +17,21 @@ import androidx.navigation.NavHostController
interface FeatureApi {
/**
* Определяет имя фичи для создания маршрута.
*
*
* @return Имя фичи (например, "home", "profile", "questions")
*/
fun getFeatureName(): String


/**
* Тоггл доступности фичи или null, если фича всегда доступна.
*
* @return Тоггл фичи или null для core-фичи
*/
fun featureToggle(): FeatureToggle? = null

/**
* Определяет является ли фича корневой (отображается в нижней навигации).
*
*
* @return true если фича должна отображаться в нижней навигации
*/
fun isRootFeature(): Boolean = false
Expand Down
10 changes: 9 additions & 1 deletion core/navigation-impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,16 @@ android {
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
testOptions {
unitTests.all {
it.useJUnitPlatform()
}
}
}

dependencies {
implementation(project(":core:navigation-api"))
implementation(project(":core:feature-toggle-api"))
implementation(project(":core:ui"))

// Feature APIs
Expand All @@ -66,7 +72,9 @@ dependencies {
implementation(libs.androidx.runtime.android)
implementation(libs.androidx.icons)

testImplementation(libs.junit)
testImplementation(libs.junit.jupiter)
testImplementation(platform(libs.junit.bom))
testRuntimeOnly(libs.junit.platform.launcher)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
// Timber
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
Expand All @@ -33,6 +35,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import org.koin.compose.getKoin
import ru.yeahub.core_ui.theme.Theme
import ru.yeahub.feature_toggle_api.FeatureAvailabilityService
import ru.yeahub.navigation_api.FeatureApi
import ru.yeahub.navigation_api.NavigationPathManager
import ru.yeahub.navigation_impl.model.BottomNavigationItem
Expand Down Expand Up @@ -66,23 +69,45 @@ fun AppNavigation(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(),
pathManager: NavigationPathManager = getKoin().get<NavigationPathManager>(),
featureAvailabilityService: FeatureAvailabilityService = getKoin().get<FeatureAvailabilityService>(),
) {
val features: Set<FeatureApi> = getKoin().getAll<FeatureApi>().toSet()
Timber.d("AppNavigation onCreate: Loaded features: ${features.map { it.javaClass.simpleName }}")
val navItems = getBottomNavItems()

features.forEach { feature ->
feature.initialize(pathManager)
}

val featureFlagsSnapshot by featureAvailabilityService.featureFlagsSnapshot.collectAsState()
val disabledFeatureNames = remember(featureFlagsSnapshot) {
collectDisabledFeatureNames(features, featureAvailabilityService)
}

val visibleNavItems = remember(disabledFeatureNames) {
navItems.filter { isRouteEnabled(it.route, disabledFeatureNames) }
}

val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val selectedRoute = getSelectedRoute(currentRoute, navItems)
val selectedRoute = getSelectedRoute(currentRoute, visibleNavItems)

currentRoute?.let { route ->
pathManager.setCurrentPath(route)
}

LaunchedEffect(disabledFeatureNames, currentRoute) {
if (currentRoute != null && !isRouteEnabled(currentRoute, disabledFeatureNames)) {
val fallbackRoute = firstAvailableRoute(navItems.map { it.route }, disabledFeatureNames)
if (fallbackRoute != null && fallbackRoute != currentRoute) {
navController.navigate(fallbackRoute) {
launchSingleTop = true
popUpTo(navController.graph.startDestinationId) { inclusive = false }
}
}
}
}

Scaffold(
modifier = modifier,
bottomBar = {
Expand All @@ -91,7 +116,7 @@ fun AppNavigation(
.height(100.dp),
containerColor = Theme.colors.purple700
) {
navItems.forEach { item ->
visibleNavItems.forEach { item ->
val isSelected by remember(selectedRoute) {
derivedStateOf { selectedRoute == item.route }
}
Expand Down Expand Up @@ -139,7 +164,6 @@ fun AppNavigation(
},
alwaysShowLabel = false
)
Timber.d("NavSelected", "$currentRoute")
}
}
}
Expand Down Expand Up @@ -251,7 +275,7 @@ private fun registerChildFeatures(

targetRootFeatures.forEach { rootFeature ->
pathManager.setCurrentPath(rootFeature.getFeatureName())

// Регистрируем дочернюю фичу
childFeature.registerGraph(
navGraphBuilder = navGraphBuilder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ fun getBottomNavItems(): List<BottomNavigationItem> = listOf(
BottomNavigationItem.Questions
)

fun getSelectedRoute(currentRoute: String?, navItems: List<BottomNavigationItem>): String = when {
currentRoute == null -> navItems[1].route
fun getSelectedRoute(currentRoute: String?, navItems: List<BottomNavigationItem>): String? = when {
navItems.isEmpty() -> null
currentRoute == null -> navItems.first().route
navItems.any { it.route == currentRoute } -> currentRoute
else -> navItems.find { currentRoute.startsWith(it.route) }?.route ?: navItems.last().route
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package ru.yeahub.navigation_impl

import ru.yeahub.feature_toggle_api.FeatureAvailabilityService
import ru.yeahub.navigation_api.FeatureApi

fun collectDisabledFeatureNames(
features: Collection<FeatureApi>,
featureAvailabilityService: FeatureAvailabilityService
): Set<String> = features.mapNotNull { feature ->
feature.featureToggle()
?.takeUnless(featureAvailabilityService::isFeatureEnabled)
?.let { feature.getFeatureName() }
}.toSet()

fun isRouteEnabled(route: String?, disabledFeatureNames: Set<String>): Boolean {
if (route.isNullOrEmpty()) return true
return route.substringBefore('?')
.split('/')
.none { segment -> segment in disabledFeatureNames }
}

fun firstAvailableRoute(candidates: List<String>, disabledFeatureNames: Set<String>): String? =
candidates.firstOrNull { route -> isRouteEnabled(route, disabledFeatureNames) }

fun resolveDeepLinkRoute(
directPath: String,
disabledFeatureNames: Set<String>,
fallbackCandidates: List<String>
): String? = if (isRouteEnabled(directPath, disabledFeatureNames)) {
directPath
} else {
firstAvailableRoute(fallbackCandidates, disabledFeatureNames)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ val navigationPathModule = module {
singleOf(::NavigationPathManagerImpl) bind NavigationPathManager::class

// Сервис для обработки уведомлений
singleOf(::NotificationNavigationService)
single {
NotificationNavigationService(
pathManager = get(),
featureAvailabilityService = get(),
featureApis = getAll()
)
}

// Универсальный сервис уведомлений
single<NotificationService> { NotificationServiceImpl(get()) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package ru.yeahub.navigation_impl

import android.content.Intent
import androidx.navigation.NavHostController
import ru.yeahub.feature_toggle_api.FeatureAvailabilityService
import ru.yeahub.navigation_api.DeepLinkConfig
import ru.yeahub.navigation_api.FeatureApi
import ru.yeahub.navigation_api.NavigationPathManager
import timber.log.Timber

Expand All @@ -15,7 +17,9 @@ import timber.log.Timber
* - Правильную настройку back stack для корректной навигации назад
*/
class NotificationNavigationService(
private val pathManager: NavigationPathManager
private val pathManager: NavigationPathManager,
private val featureAvailabilityService: FeatureAvailabilityService,
private val featureApis: Collection<FeatureApi>
) {

/**
Expand Down Expand Up @@ -209,8 +213,29 @@ class NotificationNavigationService(
* Общий метод для навигации с direct path.
*/
private fun navigateWithDirectPath(directPath: String, navController: NavHostController) {
val disabledFeatureNames = collectDisabledFeatureNames(featureApis, featureAvailabilityService)
val targetRoute = resolveDeepLinkRoute(
directPath = directPath,
disabledFeatureNames = disabledFeatureNames,
fallbackCandidates = getBottomNavItems().map { it.route }
)

if (targetRoute == null) {
Timber.w("Deep link to disabled feature blocked, no fallback: '$directPath'")
return
}

if (targetRoute != directPath) {
Timber.w("Deep link to disabled feature blocked: '$directPath' -> '$targetRoute'")
navController.navigate(targetRoute) {
popUpTo(navController.graph.startDestinationId) { inclusive = false }
launchSingleTop = true
}
return
}

pathManager.prepareForDirectNavigation(directPath)

navController.navigate(directPath) {
popUpTo(navController.graph.startDestinationId) {
inclusive = false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package ru.yeahub.navigation_impl

import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.ArgumentsProvider
import org.junit.jupiter.params.provider.ArgumentsSource
import ru.yeahub.navigation_impl.model.BottomNavigationItem
import java.util.stream.Stream

class NavigationFactoryTest {

@ParameterizedTest
@ArgumentsSource(GetSelectedRouteArgumentsProvider::class)
fun `getSelectedRoute resolves selected tab for current route`(
testCase: GetSelectedRouteTestCase
) {
val result = getSelectedRoute(testCase.currentRoute, testCase.navItems)
Assertions.assertEquals(testCase.expectedResult, result)
}

data class GetSelectedRouteTestCase(
val currentRoute: String?,
val navItems: List<BottomNavigationItem>,
val expectedResult: String?
)

class GetSelectedRouteArgumentsProvider : ArgumentsProvider {
override fun provideArguments(context: ExtensionContext?): Stream<out Arguments> {
val navItems = getBottomNavItems()
return Stream.of(
Arguments.of(
GetSelectedRouteTestCase(
currentRoute = "home",
navItems = emptyList(),
expectedResult = null
)
),
Arguments.of(
GetSelectedRouteTestCase(
currentRoute = null,
navItems = navItems,
expectedResult = navItems.first().route
)
),
Arguments.of(
GetSelectedRouteTestCase(
currentRoute = "home",
navItems = navItems,
expectedResult = "home"
)
),
Arguments.of(
GetSelectedRouteTestCase(
currentRoute = "home/details/1/Title",
navItems = navItems,
expectedResult = "home"
)
)
)
}
}
}
Loading
Loading