diff --git a/anime/src/main/kotlin/com/imashnake/animite/anime/AnimeViewModel.kt b/anime/src/main/kotlin/com/imashnake/animite/anime/AnimeViewModel.kt index 80aeb2dd..aa1a214c 100644 --- a/anime/src/main/kotlin/com/imashnake/animite/anime/AnimeViewModel.kt +++ b/anime/src/main/kotlin/com/imashnake/animite/anime/AnimeViewModel.kt @@ -1,6 +1,5 @@ package com.imashnake.animite.anime -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.imashnake.animite.api.anilist.AnilistMediaRepository @@ -19,6 +18,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -29,10 +29,9 @@ import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) class AnimeViewModel @Inject constructor( private val mediaListRepository: AnilistMediaRepository, - savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val now = savedStateHandle.getStateFlow(NOW, LocalDate.now()) + private val now = flowOf(LocalDate.now()) private val refreshTrigger = MutableSharedFlow() var useNetwork = false @@ -123,8 +122,4 @@ class AnimeViewModel @Inject constructor( useNetwork = false setIsRefreshing(false) } - - companion object { - const val NOW = "now" - } } diff --git a/anime/src/main/kotlin/com/imashnake/animite/anime/navigation/di/NavigationModule.kt b/anime/src/main/kotlin/com/imashnake/animite/anime/navigation/di/NavigationModule.kt new file mode 100644 index 00000000..c9772d06 --- /dev/null +++ b/anime/src/main/kotlin/com/imashnake/animite/anime/navigation/di/NavigationModule.kt @@ -0,0 +1,33 @@ +package com.imashnake.animite.anime.navigation.di + +import androidx.compose.animation.SharedTransitionScope +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import com.imashnake.animite.anime.AnimeScreen +import com.imashnake.animite.navigation.AnimeRoute +import com.imashnake.animite.navigation.Navigator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object NavigationModule { + + @Provides + @IntoSet + fun provideNavEntry( + navigator: Navigator, + ): EntryProviderScope.(SharedTransitionScope) -> Unit = { sharedScope -> + entry { + AnimeScreen( + onNavigateToMediaItem = navigator::navigate, + sharedTransitionScope = sharedScope, + animatedVisibilityScope = LocalNavAnimatedContentScope.current, + ) + } + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7e287a7d..60190f69 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -88,7 +88,6 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.lifecycleRuntimeKtx) - implementation(libs.androidx.navigationCompose) // Compose implementation(libs.bundles.compose) diff --git a/app/src/main/kotlin/com/imashnake/animite/ApplicationModule.kt b/app/src/main/kotlin/com/imashnake/animite/ApplicationModule.kt new file mode 100644 index 00000000..cb7e4b2b --- /dev/null +++ b/app/src/main/kotlin/com/imashnake/animite/ApplicationModule.kt @@ -0,0 +1,14 @@ +package com.imashnake.animite + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +object ApplicationModule { + + @Provides + fun provideVersionName() = BuildConfig.VERSION_NAME +} diff --git a/app/src/main/kotlin/com/imashnake/animite/features/MainActivity.kt b/app/src/main/kotlin/com/imashnake/animite/features/MainActivity.kt index f1a9cfd3..99e05477 100644 --- a/app/src/main/kotlin/com/imashnake/animite/features/MainActivity.kt +++ b/app/src/main/kotlin/com/imashnake/animite/features/MainActivity.kt @@ -1,16 +1,15 @@ package com.imashnake.animite.features -import android.content.Intent import android.content.res.Configuration -import android.os.Build import android.os.Bundle -import android.view.RoundedCorner import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.SizeTransform import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally @@ -27,49 +26,50 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navDeepLink -import com.imashnake.animite.BuildConfig -import com.imashnake.animite.anime.AnimeScreen +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay import com.imashnake.animite.api.anilist.sanitize.media.MediaList import com.imashnake.animite.core.ui.LocalPaddings import com.imashnake.animite.features.searchbar.SearchFrontDrop import com.imashnake.animite.features.theme.AnimiteTheme import com.imashnake.animite.features.theme.manropeFontFamily -import com.imashnake.animite.manga.MangaScreen import com.imashnake.animite.media.MediaPage -import com.imashnake.animite.navigation.AnimeRoute -import com.imashnake.animite.navigation.MangaRoute import com.imashnake.animite.navigation.NavigationBar import com.imashnake.animite.navigation.NavigationBarPaths import com.imashnake.animite.navigation.NavigationRail -import com.imashnake.animite.navigation.ProfileRoute -import com.imashnake.animite.navigation.SocialRoute -import com.imashnake.animite.profile.ProfileScreen -import com.imashnake.animite.profile.ProfileViewModel -import com.imashnake.animite.profile.dev.internal.ANILIST_AUTH_DEEPLINK +import com.imashnake.animite.navigation.Navigator +import com.imashnake.animite.profile.AvatarViewModel import com.imashnake.animite.settings.SettingsPage import com.imashnake.animite.settings.SettingsViewModel import com.imashnake.animite.settings.Theme -import com.imashnake.animite.social.SocialScreen import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.filterNotNull +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + + @Inject + internal lateinit var entryProviders: Set<@JvmSuppressWildcards EntryProviderScope.(SharedTransitionScope) -> Unit> + + @Inject + internal lateinit var navigator: Navigator + private val settingsViewModel: SettingsViewModel by viewModels() - private val profileViewModel: ProfileViewModel by viewModels() + private val avatarViewModel: AvatarViewModel by viewModels() + var showSplashScreen = true override fun onCreate(savedInstanceState: Bundle?) { @@ -88,7 +88,7 @@ class MainActivity : ComponentActivity() { val useSystemColorScheme by settingsViewModel.useSystemColorScheme.collectAsState(initial = false) - val useDarkTheme = when(Theme.valueOf(theme)) { + val useDarkTheme = when (Theme.valueOf(theme)) { Theme.DARK -> true Theme.LIGHT -> false Theme.DEVICE_THEME -> isSystemInDarkTheme() @@ -96,7 +96,7 @@ class MainActivity : ComponentActivity() { val dayHour by settingsViewModel.dayHour.collectAsState(initial = null) - val avatar by profileViewModel.viewerAvatar.collectAsState(initial = null) + val avatar by avatarViewModel.viewerAvatar.collectAsState(initial = null) AnimiteTheme( useDarkTheme = useDarkTheme, @@ -104,8 +104,8 @@ class MainActivity : ComponentActivity() { dayHour = dayHour ) { MainScreen( - deviceScreenCornerRadius = getDeviceScreenCornerRadius(), - useDarkTheme = useDarkTheme, + navigator = navigator, + navEntries = entryProviders, avatar = avatar, modifier = Modifier .fillMaxSize() @@ -114,37 +114,20 @@ class MainActivity : ComponentActivity() { } } } - - private fun getDeviceScreenCornerRadius(): Int { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - return windowManager - .currentWindowMetrics - .windowInsets - .getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT) - ?.radius - ?: 0 - } - return 0 - } } @OptIn(ExperimentalLayoutApi::class) @Composable fun MainScreen( - deviceScreenCornerRadius: Int, - useDarkTheme: Boolean, + navigator: Navigator, + navEntries: Set.(SharedTransitionScope) -> Unit>, avatar: String?, modifier: Modifier = Modifier, ) { - val navController = rememberNavController() - - val currentBackStackEntry by navController.currentBackStackEntryAsState() - val isNavBarVisible = rememberSaveable(currentBackStackEntry) { - if (currentBackStackEntry != null) { - NavigationBarPaths.entries.any { - it.matchesDestination(currentBackStackEntry!!) - } - } else false + val isNavBarVisible by remember(navigator.backStack.size) { + derivedStateOf { + NavigationBarPaths.entries.any { it.route == navigator.backStack.lastOrNull() } + } } // TODO: Refactor to use Scaffold once AnimatedVisibility issues are fixed; @@ -154,72 +137,55 @@ fun MainScreen( LocalContentColor provides MaterialTheme.colorScheme.onBackground ) { SharedTransitionLayout { - NavHost(navController = navController, startDestination = AnimeRoute) { - composable { - AnimeScreen( - onNavigateToMediaItem = navController::navigate, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - ) - } - composable { - MangaScreen( - onNavigateToMediaItem = navController::navigate, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - ) - } - composable { - MediaPage( - onBack = navController::navigateUp, - onNavigateToMediaItem = navController::navigate, - useDarkTheme = useDarkTheme, - deviceScreenCornerRadius = deviceScreenCornerRadius, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - ) - } - composable( - deepLinks = listOf( - navDeepLink { - uriPattern = ANILIST_AUTH_DEEPLINK - action = Intent.ACTION_VIEW - } - ) - ) { - ProfileScreen( - onNavigateToMediaItem = navController::navigate, - onNavigateToSettings = navController::navigate, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - ) - } - composable { - SettingsPage(versionName = BuildConfig.VERSION_NAME) - } - composable { - SocialScreen() - } - } + val backstack = navigator.backStack + if (backstack.isEmpty()) return@SharedTransitionLayout + + NavDisplay( + onBack = navigator::onBackPressed, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + backStack = backstack, + entryProvider = entryProvider { + navEntries.forEach { builder -> + builder(this@SharedTransitionLayout) + } + }, + sharedTransitionScope = this, + ) } } - when(LocalConfiguration.current.orientation) { + when (LocalConfiguration.current.orientation) { Configuration.ORIENTATION_LANDSCAPE -> { AnimatedVisibility( visible = isNavBarVisible, modifier = Modifier.align(Alignment.CenterStart), enter = slideInHorizontally { -it }, exit = slideOutHorizontally { -it } - ) { NavigationRail(navController, avatar) } + ) { + NavigationRail( + backStack = navigator.backStack, + avatar = avatar, + onNavigate = navigator::navigate, + ) + } } + else -> { AnimatedVisibility( visible = isNavBarVisible, modifier = Modifier.align(Alignment.BottomCenter), enter = slideInVertically { it }, exit = slideOutVertically { it } - ) { NavigationBar(navController, avatar) } + ) { + NavigationBar( + backStack = navigator.backStack, + avatar = avatar, + onNavigate = navigator::navigate, + ) + } } } @@ -227,16 +193,15 @@ fun MainScreen( hasExtraPadding = isNavBarVisible && LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT, onItemClick = { id, mediaType, title -> - navController.navigate( - MediaPage( - id = id, - source = MediaList.Type.SEARCH.name, - mediaType = mediaType.rawValue, - title = title, - ) + val route = MediaPage( + id = id, + source = MediaList.Type.SEARCH.name, + mediaType = mediaType.rawValue, + title = title, ) + navigator.navigate(route) }, - isFabVisible = currentBackStackEntry?.destination?.hasRoute() == false, + isFabVisible = !navigator.backStack.contains(SettingsPage), modifier = Modifier .align(Alignment.BottomEnd) .padding( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b55ef248..8ef3e094 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,6 @@ coreSplashscreen = "1.2.0" lifecycle = "2.10.0" extJunit = "1.3.0" espresso = "3.7.0" -navigation = "2.9.7" # Apollo Kotlin # https://github.com/apollographql/apollo-kotlin/releases. @@ -104,6 +103,10 @@ materialKolor = "4.1.1" # https://github.com/saket/cascade/releases saketCascade = "2.3.0" +# Nav3 +nav3Core = "1.1.0-alpha05" +lifecycleViewmodelNav3 = "2.10.0" + [libraries] android-desugaring = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugaring" } @@ -111,8 +114,6 @@ androidx-activityCompose = { group = "androidx.activity", name = "activity-compo androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } androidx-lifecycleRuntimeKtx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } -androidx-navigationCommon = { group = "androidx.navigation", name = "navigation-common", version.ref = "navigation" } -androidx-navigationCompose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "extJunit" } androidx-test-espressoCore = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "composeAnimation" } @@ -155,6 +156,9 @@ ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-contentNegotation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } saket-cascade = { group = "me.saket.cascade", name = "cascade-compose", version.ref = "saketCascade" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } +androidx-navigation3-vm = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } [bundles] @@ -179,3 +183,9 @@ ktor = [ "ktor-contentNegotation", "ktor-serialization" ] + +nav3 = [ + "androidx-navigation3-runtime", + "androidx-navigation3-ui", + "androidx-navigation3-vm", +] diff --git a/manga/src/main/kotlin/com/imashnake/animite/manga/navigation/di/NavigationModule.kt b/manga/src/main/kotlin/com/imashnake/animite/manga/navigation/di/NavigationModule.kt new file mode 100644 index 00000000..011dd04f --- /dev/null +++ b/manga/src/main/kotlin/com/imashnake/animite/manga/navigation/di/NavigationModule.kt @@ -0,0 +1,33 @@ +package com.imashnake.animite.manga.navigation.di + +import androidx.compose.animation.SharedTransitionScope +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import com.imashnake.animite.manga.MangaScreen +import com.imashnake.animite.navigation.MangaRoute +import com.imashnake.animite.navigation.Navigator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object NavigationModule { + + @Provides + @IntoSet + fun provideNavEntry( + navigator: Navigator + ): EntryProviderScope.(SharedTransitionScope) -> Unit = { sharedScope -> + entry { + MangaScreen( + onNavigateToMediaItem = navigator::navigate, + sharedTransitionScope = sharedScope, + animatedVisibilityScope = LocalNavAnimatedContentScope.current, + ) + } + } +} diff --git a/media/src/main/kotlin/com/imashnake/animite/media/MediaPage.kt b/media/src/main/kotlin/com/imashnake/animite/media/MediaPage.kt index 40f70358..27a327a5 100644 --- a/media/src/main/kotlin/com/imashnake/animite/media/MediaPage.kt +++ b/media/src/main/kotlin/com/imashnake/animite/media/MediaPage.kt @@ -3,7 +3,10 @@ package com.imashnake.animite.media import android.content.Intent +import android.os.Build +import android.view.RoundedCorner import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Down import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Up @@ -105,6 +108,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -122,7 +126,7 @@ import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.lerp import androidx.compose.ui.zIndex import androidx.core.net.toUri -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.NavKey import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade @@ -171,17 +175,17 @@ private const val RELATIONS = "Relations" "LongMethod" ) fun MediaPage( - onBack: () -> Unit, onNavigateToMediaItem: (MediaPage) -> Unit, - deviceScreenCornerRadius: Int, useDarkTheme: Boolean, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, + viewModel: MediaPageViewModel, + deviceScreenCornerRadius: Int = getTopRightRadius(), contentWindowInsets: WindowInsets = WindowInsets.systemBars.union(WindowInsets.displayCutout), - viewModel: MediaPageViewModel = hiltViewModel(), ) { val insetPaddingValues = contentWindowInsets.asPaddingValues() val horizontalInsets = insetPaddingValues.horizontalOnly + val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val scrollState = rememberScrollState() @@ -448,7 +452,7 @@ fun MediaPage( top = LocalPaddings.current.small ) .clip(CircleShape) - .clickable(enabled = !isExpanded) { onBack() } + .clickable(enabled = !isExpanded) { dispatcher?.onBackPressed() } .padding(LocalPaddings.current.small), tint = if (isSystemInDarkTheme()) Color.White else Color.Black ) @@ -509,9 +513,8 @@ fun MediaPage( .padding(paddingValues) .padding(bottom = LocalPaddings.current.large) .graphicsLayer { - val pageOffset = ( - creditPagerState.currentPage - page + creditPagerState.currentPageOffsetFraction - ).absoluteValue + val pageOffset = + (creditPagerState.currentPage - page + creditPagerState.currentPageOffsetFraction).absoluteValue alpha = lerp( start = 0f, @@ -584,8 +587,11 @@ fun MediaPage( MediaDescription( html = description, onLinkClick = onLinkClick@{ - val id = it?.split("/")?.getOrNull(4)?.toIntOrNull() ?: return@onLinkClick null - val character = media.characters?.find { character -> character.id == id } ?: return@onLinkClick null + val id = it?.split("/")?.getOrNull(4)?.toIntOrNull() + ?: return@onLinkClick null + val character = + media.characters?.find { character -> character.id == id } + ?: return@onLinkClick null val index = media.characters.indexOf(character) if (index != -1) { coroutineScope.launch { @@ -600,8 +606,8 @@ fun MediaPage( .padding(top = LocalPaddings.current.medium) .graphicsLayer { val pageOffset = ( - creditPagerState.currentPage - page + creditPagerState.currentPageOffsetFraction - ).absoluteValue + creditPagerState.currentPage - page + creditPagerState.currentPageOffsetFraction + ).absoluteValue alpha = lerp( start = 0f, @@ -715,7 +721,8 @@ fun MediaPage( ) { if (it) { MediaMediumGrid( - mediaMediumList = media.genreTitleList?.second ?: persistentListOf(), + mediaMediumList = media.genreTitleList?.second + ?: persistentListOf(), onItemClick = { id, title -> onNavigateToMediaItem( MediaPage( @@ -729,7 +736,8 @@ fun MediaPage( ) } else { MediaMediumList( - mediaMediumList = media.genreTitleList?.second ?: persistentListOf(), + mediaMediumList = media.genreTitleList?.second + ?: persistentListOf(), onItemClick = { id, title -> onNavigateToMediaItem( MediaPage( @@ -951,6 +959,7 @@ private fun MediaInfo( ) ) } + else -> { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -968,13 +977,14 @@ private fun MediaInfo( style = MaterialTheme.typography.labelSmallEmphasized ) Text( - text = when(it) { + text = when (it) { is Media.Info.Item -> it.value is Media.Info.Season -> listOfNotNull( stringResource(it.season.res), it.year ).joinToString(" ") + else -> stringResource( - when(it) { + when (it) { is Media.Info.Format -> it.format.res is Media.Info.Status -> it.status.res is Media.Info.Source -> it.source.res @@ -1023,14 +1033,17 @@ private fun MediaRankings( ) { Row( verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.tiny)) { + horizontalArrangement = Arrangement.spacedBy(LocalPaddings.current.tiny) + ) { Text(text = stringResource(timeSpan.res), Modifier.alignByBaseline()) when (timeSpan.index) { 1 -> year?.let { Text( text = it, fontSize = 10.sp, - modifier = Modifier.graphicsLayer { alpha = 0.5f }.alignByBaseline() + modifier = Modifier + .graphicsLayer { alpha = 0.5f } + .alignByBaseline() ) } 2 -> season?.let { @@ -1056,7 +1069,7 @@ private fun MediaRankings( slideIntoContainer(towards = Up, initialOffset = { it / 3 })) .togetherWith( fadeOut(animationSpec = tween(90)) + - slideOutOfContainer(towards = Down, targetOffset = { it / 3 }) + slideOutOfContainer(towards = Down, targetOffset = { it / 3 }) ) } ) { @@ -1456,10 +1469,25 @@ private fun MediaRecommendations( } } +@Composable +private fun getTopRightRadius(): Int { + val view = LocalView.current + // Use remember to avoid recalculating on every recomposition + // but observe the view's layout/insets if necessary. + return remember(view) { + val insets = view.rootWindowInsets + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT)?.radius ?: 0 + } else { + 0 + } + } +} + @Serializable data class MediaPage( val id: Int, val source: String, val mediaType: String, val title: String?, -) +) : NavKey diff --git a/media/src/main/kotlin/com/imashnake/animite/media/MediaPageViewModel.kt b/media/src/main/kotlin/com/imashnake/animite/media/MediaPageViewModel.kt index 18471986..2567106f 100644 --- a/media/src/main/kotlin/com/imashnake/animite/media/MediaPageViewModel.kt +++ b/media/src/main/kotlin/com/imashnake/animite/media/MediaPageViewModel.kt @@ -3,33 +3,35 @@ package com.imashnake.animite.media import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import com.imashnake.animite.api.anilist.AnilistMediaRepository import com.imashnake.animite.api.anilist.type.MediaSort import com.imashnake.animite.api.anilist.type.MediaType +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import java.io.IOException -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = MediaPageViewModel.Factory::class) @Suppress("SwallowedException") -class MediaPageViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, +class MediaPageViewModel @AssistedInject constructor( + @Assisted navArgs: MediaPage, private val mediaRepository: AnilistMediaRepository ) : ViewModel() { - private val navArgs = savedStateHandle.toRoute() - var uiState by mutableStateOf(MediaUiState( + + var uiState by mutableStateOf( + MediaUiState( source = navArgs.source, id = navArgs.id, type = navArgs.mediaType, title = navArgs.title - )) + ) + ) private set init { @@ -60,7 +62,7 @@ class MediaPageViewModel @Inject constructor( relations = media?.relations, recommendations = media?.recommendations ) - } catch(_: IOException) { + } catch (_: IOException) { TODO() } } @@ -68,7 +70,8 @@ class MediaPageViewModel @Inject constructor( fun getGenreMediaMediums(genre: String?) = viewModelScope.launch { if (genre == null) { - uiState = uiState.copy(genreTitleList = uiState.genreTitleList?.first.orEmpty() to persistentListOf()) + uiState = + uiState.copy(genreTitleList = uiState.genreTitleList?.first.orEmpty() to persistentListOf()) return@launch } val list = mediaRepository.fetchMediaMediumList( @@ -81,4 +84,9 @@ class MediaPageViewModel @Inject constructor( uiState = uiState.copy(genreTitleList = list?.let { genre to it }) } + + @AssistedFactory + interface Factory { + fun create(navArgs: MediaPage): MediaPageViewModel + } } diff --git a/media/src/main/kotlin/com/imashnake/animite/media/navigation/di/NavigationModule.kt b/media/src/main/kotlin/com/imashnake/animite/media/navigation/di/NavigationModule.kt new file mode 100644 index 00000000..cf2298d7 --- /dev/null +++ b/media/src/main/kotlin/com/imashnake/animite/media/navigation/di/NavigationModule.kt @@ -0,0 +1,38 @@ +package com.imashnake.animite.media.navigation.di + +import androidx.compose.animation.SharedTransitionScope +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import com.imashnake.animite.media.MediaPage +import com.imashnake.animite.media.MediaPageViewModel +import com.imashnake.animite.navigation.Navigator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object NavigationModule { + + @Provides + @IntoSet + fun provideNavEntry( + navigator: Navigator, + ): EntryProviderScope.(SharedTransitionScope) -> Unit = { sharedScope -> + entry { args -> + MediaPage( + onNavigateToMediaItem = navigator::navigate, + useDarkTheme = true, // fix? + sharedTransitionScope = sharedScope, + animatedVisibilityScope = LocalNavAnimatedContentScope.current, + viewModel = hiltViewModel { factory -> + factory.create(args) + } + ) + } + } +} diff --git a/navigation/build.gradle.kts b/navigation/build.gradle.kts index c80946c1..8ea295ca 100644 --- a/navigation/build.gradle.kts +++ b/navigation/build.gradle.kts @@ -3,6 +3,8 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.compose.compiler) alias(libs.plugins.detekt) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) } android { @@ -23,14 +25,27 @@ kotlin { } dependencies { - api(libs.androidx.navigationCompose) + api(libs.bundles.nav3) + + // Graphics implementation(libs.bundles.coil) + implementation(libs.compose.animation.graphics) + + // Hilt + implementation(libs.hilt.android) + implementation(libs.hilt.navigationCompose) + ksp(libs.hilt.android.compiler) + + // Compose implementation(libs.bundles.compose) implementation(libs.compose.material) - implementation(libs.compose.animation.graphics) - debugImplementation(libs.compose.ui.tooling) - implementation(libs.compose.ui.toolingPreview) + + // Serialization implementation(libs.kotlinx.serialization.core) + + // Previews + implementation(libs.compose.ui.toolingPreview) + debugImplementation(libs.compose.ui.tooling) } detekt { diff --git a/navigation/src/main/kotlin/com/imashnake/animite/navigation/AnimatedAnimeIcon.kt b/navigation/src/main/kotlin/com/imashnake/animite/navigation/AnimatedAnimeIcon.kt new file mode 100644 index 00000000..997ec43f --- /dev/null +++ b/navigation/src/main/kotlin/com/imashnake/animite/navigation/AnimatedAnimeIcon.kt @@ -0,0 +1,41 @@ +package com.imashnake.animite.navigation + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource + +@Composable +internal fun AnimatedAnimeIcon() { + val infiniteTransition = rememberInfiniteTransition() + val angle by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(12000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + Box { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.anime_inner), + contentDescription = stringResource(R.string.anime) + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.anime_outer), + contentDescription = stringResource(R.string.anime), + modifier = Modifier.graphicsLayer { rotationZ = angle } + ) + } +} diff --git a/navigation/src/main/kotlin/com/imashnake/animite/navigation/AnimatedProfileIcon.kt b/navigation/src/main/kotlin/com/imashnake/animite/navigation/AnimatedProfileIcon.kt new file mode 100644 index 00000000..aa89082a --- /dev/null +++ b/navigation/src/main/kotlin/com/imashnake/animite/navigation/AnimatedProfileIcon.kt @@ -0,0 +1,34 @@ +package com.imashnake.animite.navigation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage + +@Composable +internal fun AnimatedProfileIcon( + avatar: String?, +) { + AnimatedContent(targetState = avatar) { + if (it != null) { + AsyncImage( + model = it, + contentDescription = "Avatar", + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.onSurface.copy( + 0.1f + ) + ) + ) + } + } +} diff --git a/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationBar.kt b/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationBar.kt index 4415d944..02f9abdb 100644 --- a/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationBar.kt +++ b/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationBar.kt @@ -8,26 +8,28 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Surface import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation3.runtime.NavKey @Composable fun NavigationBar( - navController: NavController, + backStack: List, + onNavigate: (NavKey) -> Unit, avatar: String?, modifier: Modifier = Modifier, containerColor: Color = NavigationBarDefaults.containerColor, @@ -35,8 +37,6 @@ fun NavigationBar( tonalElevation: Dp = NavigationBarDefaults.Elevation, windowInsets: WindowInsets = NavigationBarDefaults.windowInsets, ) { - val currentBackStackEntry by navController.currentBackStackEntryAsState() - // This is a clone of Material3 NavigationBar, except we've shrunk the height from 80dp to 65dp Surface( color = containerColor, @@ -54,13 +54,29 @@ fun NavigationBar( verticalAlignment = Alignment.CenterVertically, ) { NavigationBarPaths.entries.forEach { destination -> - val selected = remember(destination, currentBackStackEntry) { - currentBackStackEntry?.let { destination.matchesDestination(it) } == true - } + val isSelected = backStack.lastOrNull() == destination.route + NavigationBarItem( - selected = selected, - onClick = { if (!selected) destination.navigateTo(navController) }, - icon = { destination.icon.invoke(selected, avatar) }, + selected = isSelected, + onClick = { onNavigate(destination.route) }, + icon = { + when (destination) { + NavigationBarPaths.Profile if avatar != null -> { + AnimatedProfileIcon(avatar) + } + + NavigationBarPaths.Anime if isSelected -> { + AnimatedAnimeIcon() + } + + else -> { + Icon( + ImageVector.vectorResource(destination.icon), + contentDescription = stringResource(destination.iconDescription) + ) + } + } + }, modifier = Modifier.height(dimensionResource(R.dimen.navigation_bar_height)), ) } diff --git a/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationBarPaths.kt b/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationBarPaths.kt index d8100983..091b617c 100644 --- a/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationBarPaths.kt +++ b/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationBarPaths.kt @@ -1,151 +1,32 @@ package com.imashnake.animite.navigation +import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavController -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination -import coil3.compose.AsyncImage +import androidx.navigation3.runtime.NavKey enum class NavigationBarPaths( - val navigateTo: (NavController) -> Unit, - val matchesDestination: (NavBackStackEntry) -> Boolean, - val icon: @Composable (selected: Boolean, avatar: String?) -> Unit, - @param:StringRes val labelRes: Int + val route: NavKey, + @param:DrawableRes val icon: Int, + @param:StringRes val iconDescription: Int, ) { Social( - navigateTo = { - it.navigate(SocialRoute) { - popUpTo(id = it.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - } - }, - matchesDestination = { navBackStackEntry -> - navBackStackEntry.destination.hierarchy.any { it.hasRoute(SocialRoute::class) } - }, - icon = { _, _ -> - Icon(ImageVector.vectorResource(R.drawable.social), contentDescription = stringResource(R.string.social)) - }, - labelRes = R.string.social + route = SocialRoute, + icon = R.drawable.social, + iconDescription = R.string.social, ), Anime( - navigateTo = { - it.navigate(AnimeRoute) { - popUpTo(id = it.graph.findStartDestination().id) { - saveState = true - inclusive = true - } - launchSingleTop = true - } - }, - matchesDestination = { navBackStackEntry -> - navBackStackEntry.destination.hierarchy.any { it.hasRoute(AnimeRoute::class) } - }, - icon = { selected, _ -> - if (selected) { - val infiniteTransition = rememberInfiniteTransition() - val angle by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(12000, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - ) - Box { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.anime_inner), - contentDescription = stringResource(R.string.anime) - ) - Icon( - imageVector = ImageVector.vectorResource(R.drawable.anime_outer), - contentDescription = stringResource(R.string.anime), - modifier = Modifier.graphicsLayer { rotationZ = angle } - ) - } - } else { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.anime), - contentDescription = stringResource(R.string.anime) - ) - } - }, - labelRes = R.string.anime + route = AnimeRoute, + icon = R.drawable.anime, + iconDescription = R.string.anime, ), Manga( - navigateTo = { - it.navigate(MangaRoute) { - popUpTo(id = it.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - } - }, - matchesDestination = { navBackStackEntry -> - navBackStackEntry.destination.hierarchy.any { it.hasRoute(MangaRoute::class) } - }, - icon = { _, _ -> - Icon(ImageVector.vectorResource(R.drawable.manga), contentDescription = stringResource(R.string.manga)) - }, - labelRes = R.string.manga + route = MangaRoute, + icon = R.drawable.manga, + iconDescription = R.string.manga, ), - Profile( - navigateTo = { - it.navigate(ProfileRoute()) { - popUpTo(id = it.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - } - }, - matchesDestination = { navBackStackEntry -> - navBackStackEntry.destination.hierarchy.any { it.hasRoute(ProfileRoute::class) } - }, - icon = { _, avatar -> - AnimatedContent(targetState = avatar) { - if (it != null) { - AsyncImage( - model = it, - contentDescription = "Avatar", - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onSurface.copy(0.1f)) - ) - } else { - Icon( - ImageVector.vectorResource(R.drawable.profile), - contentDescription = stringResource(R.string.profile) - ) - } - } - }, - labelRes = R.string.profile + route = ProfileRoute(), + icon = R.drawable.profile, + iconDescription = R.string.profile, ), } diff --git a/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationRail.kt b/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationRail.kt index 07923bba..015e3888 100644 --- a/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationRail.kt +++ b/navigation/src/main/kotlin/com/imashnake/animite/navigation/NavigationRail.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.NavigationRailDefaults @@ -22,21 +23,22 @@ import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Surface import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation3.runtime.NavKey @Composable fun NavigationRail( - navController: NavController, + backStack: List, + onNavigate: (NavKey) -> Unit, avatar: String?, modifier: Modifier = Modifier, containerColor: Color = NavigationBarDefaults.containerColor, @@ -44,7 +46,6 @@ fun NavigationRail( tonalElevation: Dp = NavigationBarDefaults.Elevation, windowInsets: WindowInsets = NavigationRailDefaults.windowInsets.union(WindowInsets.displayCutout), ) { - val currentBackStackEntry by navController.currentBackStackEntryAsState() val insetPaddingValues = windowInsets.asPaddingValues() val layoutDirection = LocalLayoutDirection.current @@ -57,24 +58,40 @@ fun NavigationRail( modifier = modifier, ) { Column( - modifier = Modifier - .fillMaxHeight() - .padding(start = insetPaddingValues.calculateStartPadding(layoutDirection)) - .windowInsetsPadding(windowInsets.only(WindowInsetsSides.Vertical)) - .defaultMinSize(minWidth = dimensionResource(R.dimen.navigation_rail_width)) - .padding(vertical = 4.dp) - .selectableGroup(), + modifier = Modifier + .fillMaxHeight() + .padding(start = insetPaddingValues.calculateStartPadding(layoutDirection)) + .windowInsetsPadding(windowInsets.only(WindowInsetsSides.Vertical)) + .defaultMinSize(minWidth = dimensionResource(R.dimen.navigation_rail_width)) + .padding(vertical = 4.dp) + .selectableGroup(), verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { NavigationBarPaths.entries.forEach { destination -> - val selected = remember(destination, currentBackStackEntry) { - currentBackStackEntry?.let { destination.matchesDestination(it) } == true - } + val isSelected = backStack.lastOrNull() == destination.route + NavigationRailItem( - selected = selected, - onClick = { if (!selected) destination.navigateTo(navController) }, - icon = { destination.icon.invoke(selected, avatar) }, + selected = isSelected, + onClick = { onNavigate(destination.route) }, + icon = { + when (destination) { + NavigationBarPaths.Profile if avatar != null -> { + AnimatedProfileIcon(avatar) + } + + NavigationBarPaths.Anime if isSelected -> { + AnimatedAnimeIcon() + } + + else -> { + Icon( + ImageVector.vectorResource(destination.icon), + contentDescription = stringResource(destination.iconDescription) + ) + } + } + }, modifier = Modifier.width(dimensionResource(R.dimen.navigation_rail_width)) ) } diff --git a/navigation/src/main/kotlin/com/imashnake/animite/navigation/Navigator.kt b/navigation/src/main/kotlin/com/imashnake/animite/navigation/Navigator.kt new file mode 100644 index 00000000..f5cc4b67 --- /dev/null +++ b/navigation/src/main/kotlin/com/imashnake/animite/navigation/Navigator.kt @@ -0,0 +1,40 @@ +package com.imashnake.animite.navigation + +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@ActivityRetainedScoped +class Navigator( + startDestination: NavKey, +) { + + val backStack = NavBackStack(startDestination) + + fun navigate(navKey: NavKey, allowMultiple: Boolean = false) { + when { + navKey is AnimeRoute -> { + if (backStack.lastOrNull() is AnimeRoute) return popBackTo(AnimeRoute) + } + !allowMultiple && backStack.contains(navKey) -> return + } + + if (NavigationBarPaths.entries.any { it.route == navKey }) { + backStack.removeAll { it != AnimeRoute } + } + + backStack.add(navKey) + } + + fun onBackPressed() { + if (backStack.lastOrNull() is AnimeRoute) { + backStack.clear() + } else { + backStack.removeLastOrNull() + } + } + + fun popBackTo(navKey: NavKey) { + backStack.removeAll { it != navKey } + } +} diff --git a/navigation/src/main/kotlin/com/imashnake/animite/navigation/TopLevelDestinations.kt b/navigation/src/main/kotlin/com/imashnake/animite/navigation/TopLevelDestinations.kt index 1d1153ff..56283956 100644 --- a/navigation/src/main/kotlin/com/imashnake/animite/navigation/TopLevelDestinations.kt +++ b/navigation/src/main/kotlin/com/imashnake/animite/navigation/TopLevelDestinations.kt @@ -1,5 +1,6 @@ package com.imashnake.animite.navigation +import androidx.navigation3.runtime.NavKey import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -11,13 +12,13 @@ data class ProfileRoute( val tokenType: String? = null, @SerialName("expiresIn") val expiresIn: Int = -1 -) +) : NavKey @Serializable -data object SocialRoute +data object SocialRoute : NavKey @Serializable -data object AnimeRoute +data object AnimeRoute : NavKey @Serializable -data object MangaRoute +data object MangaRoute : NavKey diff --git a/navigation/src/main/kotlin/com/imashnake/animite/navigation/di/NavigationModule.kt b/navigation/src/main/kotlin/com/imashnake/animite/navigation/di/NavigationModule.kt new file mode 100644 index 00000000..5afd642a --- /dev/null +++ b/navigation/src/main/kotlin/com/imashnake/animite/navigation/di/NavigationModule.kt @@ -0,0 +1,19 @@ +package com.imashnake.animite.navigation.di + +import com.imashnake.animite.navigation.AnimeRoute +import com.imashnake.animite.navigation.Navigator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped + +@Module +@InstallIn(ActivityRetainedComponent::class) +object NavigationModule { + + @Provides + @ActivityRetainedScoped + fun provideNavigator() = Navigator(AnimeRoute) + +} diff --git a/profile/build.gradle.kts b/profile/build.gradle.kts index 2d4ca456..713af6c5 100644 --- a/profile/build.gradle.kts +++ b/profile/build.gradle.kts @@ -46,9 +46,6 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycleRuntimeKtx) - // Navigation - implementation(libs.androidx.navigationCommon) - // Compose implementation(libs.compose.animation) implementation(libs.compose.foundation) diff --git a/profile/src/main/kotlin/com/imashnake/animite/profile/AvatarViewModel.kt b/profile/src/main/kotlin/com/imashnake/animite/profile/AvatarViewModel.kt new file mode 100644 index 00000000..2f8e4a09 --- /dev/null +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/AvatarViewModel.kt @@ -0,0 +1,14 @@ +package com.imashnake.animite.profile + +import androidx.lifecycle.ViewModel +import com.imashnake.animite.api.preferences.PreferencesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class AvatarViewModel @Inject constructor( + preferencesRepository: PreferencesRepository +): ViewModel() { + + val viewerAvatar = preferencesRepository.viewerAvatar +} \ No newline at end of file diff --git a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt index 2b6cf55b..c70286a4 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileScreen.kt @@ -73,7 +73,6 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import coil3.compose.AsyncImage import com.boswelja.markdown.material3.MarkdownDocument import com.boswelja.markdown.material3.m3TextStyles @@ -109,11 +108,11 @@ fun ProfileScreen( onNavigateToSettings: (SettingsPage) -> Unit, sharedTransitionScope: SharedTransitionScope, animatedVisibilityScope: AnimatedVisibilityScope, + viewModel: ProfileViewModel, contentWindowInsets: WindowInsets = WindowInsets.systemBars.union(WindowInsets.displayCutout), - viewModel: ProfileViewModel = hiltViewModel(), ) { val insetPaddingValues = contentWindowInsets.asPaddingValues() - val navigationComponentPaddingValues = when(LocalConfiguration.current.orientation) { + val navigationComponentPaddingValues = when (LocalConfiguration.current.orientation) { Configuration.ORIENTATION_PORTRAIT -> PaddingValues( bottom = dimensionResource(navigationR.dimen.navigation_bar_height) ) diff --git a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt index 4b06e704..f149e863 100644 --- a/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/ProfileViewModel.kt @@ -1,15 +1,16 @@ package com.imashnake.animite.profile -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import com.imashnake.animite.api.anilist.AnilistUserRepository import com.imashnake.animite.api.anilist.type.MediaType import com.imashnake.animite.api.preferences.PreferencesRepository import com.imashnake.animite.core.resource.Resource import com.imashnake.animite.core.resource.Resource.Companion.asResource import com.imashnake.animite.navigation.ProfileRoute +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -23,17 +24,15 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel -class ProfileViewModel @Inject constructor( +@HiltViewModel(assistedFactory = ProfileViewModel.Factory::class) +class ProfileViewModel @AssistedInject constructor( private val userRepository: AnilistUserRepository, private val preferencesRepository: PreferencesRepository, - savedStateHandle: SavedStateHandle + @Assisted val navArgs: ProfileRoute, ) : ViewModel() { - private val navArgs = savedStateHandle.toRoute() private val refreshTrigger = MutableSharedFlow() var useNetwork = false @@ -113,4 +112,9 @@ class ProfileViewModel @Inject constructor( } } } + + @AssistedFactory + interface Factory { + fun create(navArgs: ProfileRoute): ProfileViewModel + } } diff --git a/profile/src/main/kotlin/com/imashnake/animite/profile/navigation/di/NavigationModule.kt b/profile/src/main/kotlin/com/imashnake/animite/profile/navigation/di/NavigationModule.kt new file mode 100644 index 00000000..89d97f1d --- /dev/null +++ b/profile/src/main/kotlin/com/imashnake/animite/profile/navigation/di/NavigationModule.kt @@ -0,0 +1,39 @@ +package com.imashnake.animite.profile.navigation.di + +import androidx.compose.animation.SharedTransitionScope +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import com.imashnake.animite.navigation.Navigator +import com.imashnake.animite.navigation.ProfileRoute +import com.imashnake.animite.profile.ProfileScreen +import com.imashnake.animite.profile.ProfileViewModel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object NavigationModule { + + @Provides + @IntoSet + fun provideNavEntry( + navigator: Navigator, + ): EntryProviderScope.(SharedTransitionScope) -> Unit = { sharedScope -> + entry { args -> + ProfileScreen( + onNavigateToSettings = navigator::navigate, + onNavigateToMediaItem = navigator::navigate, + sharedTransitionScope = sharedScope, + animatedVisibilityScope = LocalNavAnimatedContentScope.current, + viewModel = hiltViewModel { factory -> + factory.create(args) + } + ) + } + } +} diff --git a/settings/build.gradle.kts b/settings/build.gradle.kts index 1324c26d..4d5f2c90 100644 --- a/settings/build.gradle.kts +++ b/settings/build.gradle.kts @@ -45,9 +45,6 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycleRuntimeKtx) - // Navigation - implementation(libs.androidx.navigationCommon) - // Compose implementation(libs.compose.animation) implementation(libs.compose.foundation) diff --git a/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsPage.kt b/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsPage.kt index 6ffff344..542382a5 100644 --- a/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsPage.kt +++ b/settings/src/main/kotlin/com/imashnake/animite/settings/SettingsPage.kt @@ -78,6 +78,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.NavKey import com.imashnake.animite.banner.BannerLayout import com.imashnake.animite.banner.MountFuji import com.imashnake.animite.core.ui.DayPart @@ -291,6 +292,7 @@ fun SettingsPage( valueRange = 0f..1f, ) } + 1 -> { Column(verticalArrangement = Arrangement.spacedBy(LocalPaddings.current.small)) { DayPart.entries.map { it.name }.plus(SYSTEM_DAY_PART).forEach { @@ -329,7 +331,6 @@ fun SettingsPage( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } } } } @@ -601,7 +602,9 @@ private fun AboutItem( Column( verticalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier.fillMaxHeight().weight(1f) + modifier = Modifier + .fillMaxHeight() + .weight(1f) ) { Text( text = ANIMITE, @@ -739,7 +742,9 @@ private fun PreviewItems() { ), onItemClick = {}, isDarkMode = false, - modifier = Modifier.fillMaxWidth().padding(horizontal = padding) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = padding) ) { index -> when (index) { 0 -> Row( @@ -812,4 +817,4 @@ enum class Theme(@param:StringRes val theme: Int) { } @Serializable -data object SettingsPage +data object SettingsPage : NavKey diff --git a/settings/src/main/kotlin/com/imashnake/animite/settings/navigation/di/NavigationModule.kt b/settings/src/main/kotlin/com/imashnake/animite/settings/navigation/di/NavigationModule.kt new file mode 100644 index 00000000..1dc2847a --- /dev/null +++ b/settings/src/main/kotlin/com/imashnake/animite/settings/navigation/di/NavigationModule.kt @@ -0,0 +1,28 @@ +package com.imashnake.animite.settings.navigation.di + +import androidx.compose.animation.SharedTransitionScope +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.imashnake.animite.settings.SettingsPage +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object NavigationModule { + + @Provides + @IntoSet + fun provideNavEntry( + versionName: String + ): EntryProviderScope.(SharedTransitionScope) -> Unit = { _ -> + entry { + SettingsPage( + versionName = versionName + ) + } + } +} diff --git a/social/build.gradle.kts b/social/build.gradle.kts index da8b4bca..a2d92709 100644 --- a/social/build.gradle.kts +++ b/social/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.ksp) alias(libs.plugins.detekt) + alias(libs.plugins.hilt) } android { @@ -25,6 +26,7 @@ kotlin { dependencies { implementation(projects.core.ui) + implementation(projects.navigation) // AndroidX implementation(libs.androidx.activityCompose) @@ -46,6 +48,11 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.core) + // Hilt + implementation(libs.hilt.android) + implementation(libs.hilt.navigationCompose) + ksp(libs.hilt.android.compiler) + testImplementation(libs.test.junit) androidTestImplementation(libs.androidx.test.junit) diff --git a/social/src/main/kotlin/com/imashnake/animite/social/navigation/di/NavigationModule.kt b/social/src/main/kotlin/com/imashnake/animite/social/navigation/di/NavigationModule.kt new file mode 100644 index 00000000..723b2518 --- /dev/null +++ b/social/src/main/kotlin/com/imashnake/animite/social/navigation/di/NavigationModule.kt @@ -0,0 +1,26 @@ +package com.imashnake.animite.social.navigation.di + +import androidx.compose.animation.SharedTransitionScope +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.imashnake.animite.navigation.SocialRoute +import com.imashnake.animite.social.SocialScreen +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityRetainedComponent::class) +object NavigationModule { + + @Provides + @IntoSet + fun provideNavEntry(): EntryProviderScope.(SharedTransitionScope) -> Unit = + { _ -> + entry { + SocialScreen() + } + } +}