diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 458548a..833a7b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,12 +11,12 @@ plugins { android { namespace = "hu.bbara.purefin" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "hu.bbara.purefin" minSdk = 29 - targetSdk = 35 + targetSdk = 36 versionCode = 1 versionName = "1.0" @@ -50,6 +50,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) @@ -70,6 +71,8 @@ dependencies { implementation(libs.medi3.ui) implementation(libs.medi3.exoplayer) implementation(libs.medi3.ui.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt b/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt index b6eb8e2..d43b6bc 100644 --- a/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt +++ b/app/src/main/java/hu/bbara/purefin/PurefinActivity.kt @@ -5,9 +5,21 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay import coil3.ImageLoader import coil3.SingletonImageLoader import coil3.disk.DiskCache @@ -16,10 +28,13 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.crossfade import coil3.util.DebugLogger import dagger.hilt.android.AndroidEntryPoint -import hu.bbara.purefin.app.HomePage import hu.bbara.purefin.client.JellyfinApiClient import hu.bbara.purefin.client.JellyfinAuthInterceptor import hu.bbara.purefin.login.ui.LoginScreen +import hu.bbara.purefin.navigation.LocalNavigationManager +import hu.bbara.purefin.navigation.NavigationCommand +import hu.bbara.purefin.navigation.NavigationManager +import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.session.UserSessionRepository import hu.bbara.purefin.ui.theme.PurefinTheme import kotlinx.coroutines.launch @@ -30,9 +45,14 @@ import javax.inject.Inject @AndroidEntryPoint class PurefinActivity : ComponentActivity() { + @Inject + lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope.() -> Unit> @Inject lateinit var userSessionRepository: UserSessionRepository + @Inject + lateinit var navigationManager: NavigationManager + @Inject lateinit var jellyfinApiClient: JellyfinApiClient @@ -47,18 +67,16 @@ class PurefinActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - lifecycleScope.launch { init() } configureImageLoader() enableEdgeToEdge() setContent { - PurefinTheme { - val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false) - if (isLoggedIn) { - HomePage() - } else { - LoginScreen() - } + PurefinTheme() { + MainApp( + userSessionRepository = userSessionRepository, + entryBuilders = entryBuilders, + navigationManager = navigationManager + ) } } } @@ -101,4 +119,50 @@ class PurefinActivity : ComponentActivity() { private fun isDebuggable(): Boolean { return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 } + + @Composable + fun MainApp( + userSessionRepository: UserSessionRepository, + entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope.() -> Unit>, + navigationManager: NavigationManager + ) { + + val isLoggedIn by userSessionRepository.isLoggedIn.collectAsState(initial = false) + if (isLoggedIn) { + @Suppress("UNCHECKED_CAST") + val backStack = rememberNavBackStack(Route.Home) as NavBackStack + val appEntryProvider = + entryProvider { + entryBuilders.forEach { builder -> builder() } + } + + LaunchedEffect(navigationManager, backStack) { + navigationManager.commands.collect { command -> + when (command) { + NavigationCommand.Pop -> backStack.removeLastOrNull() + is NavigationCommand.Navigate -> backStack.add(command.route) + is NavigationCommand.ReplaceAll -> { + backStack.clear() + backStack.add(command.route) + } + } + } + } + + CompositionLocalProvider(LocalNavigationManager provides navigationManager) { + NavDisplay( + backStack = backStack, + modifier = Modifier.fillMaxSize(), + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = appEntryProvider + ) + } + } else { + LoginScreen() + } + } } diff --git a/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt b/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt index 8dc41cb..cd74a4e 100644 --- a/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt +++ b/app/src/main/java/hu/bbara/purefin/PurefinApplication.kt @@ -6,6 +6,4 @@ import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class PurefinApplication : Application() { - - } \ No newline at end of file diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt new file mode 100644 index 0000000..5534466 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieCard.kt @@ -0,0 +1,119 @@ +package hu.bbara.purefin.app.content.movie + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import org.jellyfin.sdk.model.UUID + +@Composable +fun MovieCard( + movieId: String, + modifier: Modifier = Modifier, + viewModel: MovieScreenViewModel = hiltViewModel() +) { + + LaunchedEffect(movieId) { + viewModel.selectMovie(UUID.fromString(movieId)) + } + + val movieItem = viewModel.movie.collectAsState() + + if (movieItem.value != null) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(MovieBackgroundDark) + ) { + val isWide = maxWidth >= 900.dp + val contentPadding = if (isWide) 32.dp else 20.dp + + Box(modifier = Modifier.fillMaxSize()) { + if (isWide) { + Row(modifier = Modifier.fillMaxSize()) { + MovieHero( + movie = movieItem.value!!, + height = 300.dp, + isWide = true, + modifier = Modifier + .fillMaxHeight() + .weight(0.5f) + ) + MovieDetails( + movie = movieItem.value!!, + modifier = Modifier + .weight(0.5f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(start = contentPadding, end = contentPadding, top = 96.dp, bottom = 32.dp) + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + MovieHero( + movie = movieItem.value!!, + height = 400.dp, + isWide = false, + modifier = Modifier.fillMaxWidth() + ) + MovieDetails( + movie = movieItem.value!!, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = contentPadding) + .offset(y = (-48).dp) + .padding(bottom = 96.dp) + ) + } + } + + MovieTopBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) + + if (!isWide) { + FloatingPlayButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(20.dp) + ) + } + } + } + } else { + Box( + modifier = modifier + .fillMaxSize() + .background(MovieBackgroundDark), + contentAlignment = Alignment.Center + ) { + Text( + text = "Loading...", + color = Color.White + ) + } + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieColors.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieColors.kt new file mode 100644 index 0000000..1745a90 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieColors.kt @@ -0,0 +1,10 @@ +package hu.bbara.purefin.app.content.movie + +import androidx.compose.ui.graphics.Color + +internal val MoviePrimary = Color(0xFFDDA73C) +internal val MovieBackgroundDark = Color(0xFF141414) +internal val MovieSurfaceDark = Color(0xFF1F1F1F) +internal val MovieSurfaceBorder = Color(0x1AFFFFFF) +internal val MovieMuted = Color(0x99FFFFFF) +internal val MovieMutedStrong = Color(0x66FFFFFF) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt new file mode 100644 index 0000000..4d60ef5 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt @@ -0,0 +1,452 @@ +package hu.bbara.purefin.app.content.movie + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Cast +import androidx.compose.material.icons.outlined.ClosedCaption +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.VolumeUp +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage + +@Composable +internal fun MovieTopBar(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + GhostIconButton(icon = Icons.Outlined.ArrowBack, contentDescription = "Back") + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GhostIconButton(icon = Icons.Outlined.Cast, contentDescription = "Cast") + GhostIconButton(icon = Icons.Outlined.MoreVert, contentDescription = "More") + } + } +} + +@Composable +private fun GhostIconButton( + icon: ImageVector, + contentDescription: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .size(40.dp) + .clip(CircleShape) + .background(MovieBackgroundDark.copy(alpha = 0.4f)) + .clickable { }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = Color.White + ) + } +} + +@Composable +internal fun MovieHero( + movie: MovieUiModel, + height: Dp, + isWide: Boolean, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .height(height) + .background(MovieBackgroundDark) + ) { + AsyncImage( + model = movie.heroImageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + MovieBackgroundDark.copy(alpha = 0.4f), + MovieBackgroundDark + ) + ) + ) + ) + if (isWide) { + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.horizontalGradient( + colors = listOf( + Color.Transparent, + MovieBackgroundDark.copy(alpha = 0.8f) + ) + ) + ) + ) + } + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + PlayButton(size = if (isWide) 96.dp else 80.dp) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun MovieDetails( + movie: MovieUiModel, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = movie.title, + color = Color.White, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + lineHeight = 38.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + MetaChip(text = movie.year) + MetaChip(text = movie.rating) + MetaChip(text = movie.runtime) + MetaChip( + text = movie.format, + background = MoviePrimary.copy(alpha = 0.2f), + border = MoviePrimary.copy(alpha = 0.3f), + textColor = MoviePrimary + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + PlaybackSettings(movie = movie) + + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Synopsis", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = movie.synopsis, + color = MovieMuted, + fontSize = 15.sp, + lineHeight = 22.sp + ) + + Spacer(modifier = Modifier.height(24.dp)) + ActionButtons() + + Spacer(modifier = Modifier.height(28.dp)) + Text( + text = "Cast", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + CastRow(cast = movie.cast) + } +} + +@Composable +private fun MetaChip( + text: String, + background: Color = Color.White.copy(alpha = 0.1f), + border: Color = Color.Transparent, + textColor: Color = Color.White +) { + Box( + modifier = Modifier + .height(28.dp) + .wrapContentHeight(Alignment.CenterVertically) + .clip(RoundedCornerShape(6.dp)) + .background(background) + .border(width = 1.dp, color = border, shape = RoundedCornerShape(6.dp)) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = textColor, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun PlaybackSettings(movie: MovieUiModel) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MovieSurfaceDark) + .border(1.dp, MovieSurfaceBorder, RoundedCornerShape(16.dp)) + .padding(20.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = MoviePrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Playback Settings", + color = MovieMuted, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 2.sp + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + SettingDropdown( + label = "Audio Track", + icon = Icons.Outlined.VolumeUp, + value = movie.audioTrack + ) + SettingDropdown( + label = "Subtitles", + icon = Icons.Outlined.ClosedCaption, + value = movie.subtitles + ) + } + } +} + +@Composable +private fun SettingDropdown( + label: String, + icon: ImageVector, + value: String +) { + Column { + Text( + text = label, + color = MovieMutedStrong, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 4.dp, bottom = 6.dp) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MovieBackgroundDark) + .border(1.dp, MovieSurfaceBorder, RoundedCornerShape(12.dp)) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = icon, contentDescription = null, tint = MovieMutedStrong) + Spacer(modifier = Modifier.width(10.dp)) + Text(text = value, color = Color.White, fontSize = 14.sp) + } + Icon(imageVector = Icons.Outlined.ExpandMore, contentDescription = null, tint = MovieMutedStrong) + } + } +} + +@Composable +private fun ActionButtons() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ActionButton( + text = "Watchlist", + icon = Icons.Outlined.Add, + modifier = Modifier.weight(1f) + ) + ActionButton( + text = "Download", + icon = Icons.Outlined.Download, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ActionButton( + text: String, + icon: ImageVector, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .border(1.dp, MovieSurfaceBorder, RoundedCornerShape(12.dp)) + .clickable { }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(imageVector = icon, contentDescription = null, tint = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = text, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun CastRow(cast: List) { + LazyRow( + contentPadding = PaddingValues(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(cast) { member -> + Column(modifier = Modifier.width(96.dp)) { + Box( + modifier = Modifier + .aspectRatio(4f / 5f) + .clip(RoundedCornerShape(12.dp)) + .background(MovieSurfaceDark) + ) { + if (member.imageUrl == null) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White.copy(alpha = 0.05f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Person, + contentDescription = null, + tint = MovieMutedStrong + ) + } + } else { + AsyncImage( + model = member.imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = member.name, + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = member.role, + color = MovieMutedStrong, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun PlayButton( + size: Dp, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .size(size) + .shadow(24.dp, CircleShape) + .clip(CircleShape) + .background(MoviePrimary), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Play", + tint = MovieBackgroundDark, + modifier = Modifier.size(42.dp) + ) + } +} + +@Composable +internal fun FloatingPlayButton(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(56.dp) + .shadow(20.dp, CircleShape) + .clip(CircleShape) + .background(MoviePrimary), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Play", + tint = MovieBackgroundDark + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieModels.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieModels.kt new file mode 100644 index 0000000..1d4dccc --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieModels.kt @@ -0,0 +1,20 @@ +package hu.bbara.purefin.app.content.movie + +data class CastMember( + val name: String, + val role: String, + val imageUrl: String? +) + +data class MovieUiModel( + val title: String, + val year: String, + val rating: String, + val runtime: String, + val format: String, + val synopsis: String, + val heroImageUrl: String, + val audioTrack: String, + val subtitles: String, + val cast: List +) diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt new file mode 100644 index 0000000..4f7cabf --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt @@ -0,0 +1,22 @@ +package hu.bbara.purefin.app.content.movie + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun MovieScreen( + movieId: String, + modifier: Modifier = Modifier +) { + MovieCard( + movieId = movieId, + modifier = modifier + ) +} + +@Preview(showBackground = true) +@Composable +private fun MovieScreenPreview() { + MovieScreen(movieId = "test") +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenNavigation.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenNavigation.kt new file mode 100644 index 0000000..c62de29 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenNavigation.kt @@ -0,0 +1,13 @@ +package hu.bbara.purefin.app.content.movie + +import androidx.navigation3.runtime.EntryProviderScope +import hu.bbara.purefin.navigation.Route + +/** + * Navigation 3 entry definition for the Home section. + */ +fun EntryProviderScope.homeSection() { + entry { + MovieScreen(movieId = it.movieId) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt new file mode 100644 index 0000000..34d8a4a --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreenViewModel.kt @@ -0,0 +1,91 @@ +package hu.bbara.purefin.app.content.movie + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import hu.bbara.purefin.client.JellyfinApiClient +import hu.bbara.purefin.image.JellyfinImageHelper +import hu.bbara.purefin.session.UserSessionRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemPerson +import org.jellyfin.sdk.model.api.ImageType +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltViewModel +class MovieScreenViewModel @Inject constructor( + private val jellyfinApiClient: JellyfinApiClient, + private val userSessionRepository: UserSessionRepository +): ViewModel() { + + private val _movie = MutableStateFlow(null) + val movie = _movie.asStateFlow() + + fun selectMovie(movieId: UUID) { + viewModelScope.launch { + val movieInfo = jellyfinApiClient.getItemInfo(movieId) + if (movieInfo == null) { + _movie.value = null + return@launch + } + val serverUrl = userSessionRepository.serverUrl.first().trim().ifBlank { + "https://jellyfin.bbara.hu" + } + _movie.value = movieInfo.toUiModel(serverUrl) + } + } + + private fun BaseItemDto.toUiModel(serverUrl: String): MovieUiModel { + val year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty() + val rating = officialRating ?: "NR" + val runtime = formatRuntime(runTimeTicks) + val format = container?.uppercase() ?: "VIDEO" + val synopsis = overview ?: "No synopsis available." + val heroImageUrl = id?.let { itemId -> + JellyfinImageHelper.toImageUrl( + url = serverUrl, + itemId = itemId, + type = ImageType.BACKDROP + ) + } ?: "" + val cast = people.orEmpty().map { it.toCastMember() } + return MovieUiModel( + title = name ?: "Unknown title", + year = year, + rating = rating, + runtime = runtime, + format = format, + synopsis = synopsis, + heroImageUrl = heroImageUrl, + audioTrack = "Default", + subtitles = "Unknown", + cast = cast + ) + } + + private fun BaseItemPerson.toCastMember(): CastMember { + return CastMember( + name = name ?: "Unknown", + role = role ?: "", + imageUrl = null + ) + } + + private fun formatRuntime(ticks: Long?): String { + if (ticks == null || ticks <= 0) return "—" + val totalSeconds = ticks / 10_000_000 + val hours = TimeUnit.SECONDS.toHours(totalSeconds) + val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60 + return if (hours > 0) { + "${hours}h ${minutes}m" + } else { + "${minutes}m" + } + } + +} diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeNavigation.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomeNavigation.kt new file mode 100644 index 0000000..41303bb --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomeNavigation.kt @@ -0,0 +1,13 @@ +package hu.bbara.purefin.app.home + +import androidx.navigation3.runtime.EntryProviderScope +import hu.bbara.purefin.navigation.Route + +/** + * Navigation 3 entry definition for the Home section. + */ +fun EntryProviderScope.homeSection() { + entry { + HomePage() + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/HomePage.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt similarity index 89% rename from app/src/main/java/hu/bbara/purefin/app/HomePage.kt rename to app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt index e787321..bfe4f91 100644 --- a/app/src/main/java/hu/bbara/purefin/app/HomePage.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePage.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.app +package hu.bbara.purefin.app.home import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -14,11 +14,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.app.home.HomeContent -import hu.bbara.purefin.app.home.HomeDrawerContent -import hu.bbara.purefin.app.home.HomeMockData -import hu.bbara.purefin.app.home.HomeTopBar -import hu.bbara.purefin.app.home.rememberHomeColors +import hu.bbara.purefin.app.home.ui.HomeContent +import hu.bbara.purefin.app.home.ui.HomeDrawerContent +import hu.bbara.purefin.app.home.ui.HomeMockData +import hu.bbara.purefin.app.home.ui.HomeTopBar +import hu.bbara.purefin.app.home.ui.rememberHomeColors import kotlinx.coroutines.launch @Composable diff --git a/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt similarity index 84% rename from app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt rename to app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt index 221e315..a7673be 100644 --- a/app/src/main/java/hu/bbara/purefin/app/HomePageViewModel.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/HomePageViewModel.kt @@ -1,13 +1,15 @@ -package hu.bbara.purefin.app +package hu.bbara.purefin.app.home import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import hu.bbara.purefin.app.home.ContinueWatchingItem -import hu.bbara.purefin.app.home.LibraryItem -import hu.bbara.purefin.app.home.PosterItem +import hu.bbara.purefin.app.home.ui.ContinueWatchingItem +import hu.bbara.purefin.app.home.ui.LibraryItem +import hu.bbara.purefin.app.home.ui.PosterItem import hu.bbara.purefin.client.JellyfinApiClient +import hu.bbara.purefin.navigation.NavigationManager +import hu.bbara.purefin.navigation.Route import hu.bbara.purefin.session.UserSessionRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,6 +25,7 @@ import javax.inject.Inject @HiltViewModel class HomePageViewModel @Inject constructor( private val userSessionRepository: UserSessionRepository, + private val navigationManager: NavigationManager, private val jellyfinApiClient: JellyfinApiClient ) : ViewModel() { @@ -42,6 +45,19 @@ class HomePageViewModel @Inject constructor( loadHomePageData() } + fun onMovieSelected(movieId: String) { + navigationManager.navigate(Route.Movie(movieId)) + } + + fun onBack() { + navigationManager.pop() + } + + fun onGoHome() { + navigationManager.replaceAll(Route.Home) + } + + fun loadContinueWatching() { viewModelScope.launch { val continueWatching: List = jellyfinApiClient.getContinueWatching() @@ -103,7 +119,8 @@ class HomePageViewModel @Inject constructor( val libraryPosterItems = libraryItems.map { PosterItem( id = it.id, - title = it.name ?: "Unknown" + title = it.name ?: "Unknown", + type = it.type ) } _libraryItems.update { currentMap -> @@ -132,16 +149,19 @@ class HomePageViewModel @Inject constructor( when (it.type) { BaseItemKind.MOVIE -> PosterItem( id = it.id, - title = it.name ?: "Unknown" + title = it.name ?: "Unknown", + type = it.type ) BaseItemKind.EPISODE -> PosterItem( id = it.seriesId!!, - title = it.seriesName ?: "Unknown" + title = it.seriesName ?: "Unknown", + type = it.type ) BaseItemKind.SEASON -> PosterItem( id = it.seriesId!!, - title = it.seriesName ?: "Unknown" + title = it.seriesName ?: "Unknown", + type = it.type ) else -> null } diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeAvatar.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeAvatar.kt similarity index 96% rename from app/src/main/java/hu/bbara/purefin/app/home/HomeAvatar.kt rename to app/src/main/java/hu/bbara/purefin/app/home/ui/HomeAvatar.kt index 822467f..cee05c7 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeAvatar.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeAvatar.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.app.home +package hu.bbara.purefin.app.home.ui import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeContent.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt similarity index 95% rename from app/src/main/java/hu/bbara/purefin/app/home/HomeContent.kt rename to app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt index 0431055..dec989a 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeContent.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeContent.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.app.home +package hu.bbara.purefin.app.home.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Spacer @@ -12,7 +12,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.app.HomePageViewModel +import hu.bbara.purefin.app.home.HomePageViewModel @Composable fun HomeContent( diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeDrawer.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDrawer.kt similarity index 98% rename from app/src/main/java/hu/bbara/purefin/app/home/HomeDrawer.kt rename to app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDrawer.kt index 9bbfef5..a1f6a72 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeDrawer.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeDrawer.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.app.home +package hu.bbara.purefin.app.home.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -26,7 +26,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.app.HomePageViewModel +import hu.bbara.purefin.app.home.HomePageViewModel @Composable fun HomeDrawerContent( diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeMockData.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeMockData.kt similarity index 95% rename from app/src/main/java/hu/bbara/purefin/app/home/HomeMockData.kt rename to app/src/main/java/hu/bbara/purefin/app/home/ui/HomeMockData.kt index c25febf..eb9cb5a 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeMockData.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeMockData.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.app.home +package hu.bbara.purefin.app.home.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Home diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeModels.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt similarity index 82% rename from app/src/main/java/hu/bbara/purefin/app/home/HomeModels.kt rename to app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt index ea080a0..c6e8d18 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeModels.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeModels.kt @@ -1,8 +1,9 @@ -package hu.bbara.purefin.app.home +package hu.bbara.purefin.app.home.ui import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind data class ContinueWatchingItem( val id: UUID, @@ -20,7 +21,8 @@ data class LibraryItem( data class PosterItem( val id: UUID, - val title: String + val title: String, + val type: BaseItemKind ) data class HomeNavItem( diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt similarity index 90% rename from app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt rename to app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt index 25e9d9f..ef30ecc 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeSections.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeSections.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.app.home +package hu.bbara.purefin.app.home.ui import android.content.Intent import androidx.compose.foundation.background @@ -36,9 +36,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel import coil3.compose.AsyncImage +import hu.bbara.purefin.app.home.HomePageViewModel import hu.bbara.purefin.image.JellyfinImageHelper import hu.bbara.purefin.player.PlayerActivity +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import kotlin.math.nextUp @@ -122,9 +125,9 @@ fun ContinueWatchingCard( Button( modifier = Modifier.align(Alignment.BottomEnd), onClick = { - val intent = Intent(context, PlayerActivity::class.java) - intent.putExtra("MEDIA_ID", item.id.toString()) - context.startActivity(intent) + val intent = Intent(context, PlayerActivity::class.java) + intent.putExtra("MEDIA_ID", item.id.toString()) + context.startActivity(intent) }) { Icon(imageVector = Icons.Outlined.PlayArrow, contentDescription = "Play") } @@ -173,7 +176,7 @@ fun LibraryPosterSection( ) { item -> PosterCard( item = item, - colors = colors + colors = colors, ) } } @@ -183,8 +186,18 @@ fun LibraryPosterSection( fun PosterCard( item: PosterItem, colors: HomeColors, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + viewModel: HomePageViewModel = hiltViewModel() ) { + fun openItem(posterItem: PosterItem) + { + when (posterItem.type) { + BaseItemKind.MOVIE -> viewModel.onMovieSelected(posterItem.id.toString()) + else -> {} + } + + } + Box( modifier = modifier .width(144.dp) @@ -192,6 +205,7 @@ fun PosterCard( .shadow(10.dp, RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(14.dp)) .background(colors.card) + .clickable(onClick = { openItem(item) }) ) { AsyncImage( model = JellyfinImageHelper.toImageUrl(url = "https://jellyfin.bbara.hu", itemId = item.id, type = ImageType.PRIMARY), diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeTokens.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTokens.kt similarity index 97% rename from app/src/main/java/hu/bbara/purefin/app/home/HomeTokens.kt rename to app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTokens.kt index eda28ab..2bed89b 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeTokens.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTokens.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.app.home +package hu.bbara.purefin.app.home.ui import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable diff --git a/app/src/main/java/hu/bbara/purefin/app/home/HomeTopBar.kt b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt similarity index 97% rename from app/src/main/java/hu/bbara/purefin/app/home/HomeTopBar.kt rename to app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt index 8555ccc..42fc680 100644 --- a/app/src/main/java/hu/bbara/purefin/app/home/HomeTopBar.kt +++ b/app/src/main/java/hu/bbara/purefin/app/home/ui/HomeTopBar.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.app.home +package hu.bbara.purefin.app.home.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel -import hu.bbara.purefin.app.HomePageViewModel +import hu.bbara.purefin.app.home.HomePageViewModel @Composable fun HomeTopBar( diff --git a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt index 411aad6..8c96652 100644 --- a/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt +++ b/app/src/main/java/hu/bbara/purefin/client/JellyfinApiClient.kt @@ -9,6 +9,7 @@ import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.extensions.authenticateUserByName import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.mediaInfoApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -24,6 +25,7 @@ import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.jellyfin.sdk.model.api.SubtitleProfile import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import java.util.UUID import javax.inject.Inject @@ -146,6 +148,31 @@ class JellyfinApiClient @Inject constructor( return response.content } + suspend fun getItemInfo(mediaId: UUID): BaseItemDto? { + if (!ensureConfigured()) { + return null + } + val result = api.userLibraryApi.getItem( + itemId = mediaId, + userId = getUserId() + ) + Log.d("getItemInfo response: {}", result.content.toString()) + return result.content + } + + suspend fun getNextUpEpisode(mediaId: UUID): BaseItemDto { + if (!ensureConfigured()) { + throw IllegalStateException("Not configured") + } + val getNextUpRequest = GetNextUpRequest( + userId = getUserId(), + seriesId = mediaId, + ) + val result = api.tvShowsApi.getNextUp(getNextUpRequest) + Log.d("getNextUpEpisode response: {}", result.content.toString()) + return result.content.items.first() + } + suspend fun getMediaSources(mediaId: UUID): List { val result = api.mediaInfoApi .getPostedPlaybackInfo( diff --git a/app/src/main/java/hu/bbara/purefin/navigation/NavigationManager.kt b/app/src/main/java/hu/bbara/purefin/navigation/NavigationManager.kt new file mode 100644 index 0000000..c535f18 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/navigation/NavigationManager.kt @@ -0,0 +1,45 @@ +package hu.bbara.purefin.navigation + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +sealed interface NavigationCommand { + data class Navigate(val route: Route) : NavigationCommand + data class ReplaceAll(val route: Route) : NavigationCommand + data object Pop : NavigationCommand +} + +interface NavigationManager { + val commands: SharedFlow + fun navigate(route: Route) + fun replaceAll(route: Route) + fun pop() +} + +class DefaultNavigationManager : NavigationManager { + private val _commands = + MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val commands: SharedFlow = _commands.asSharedFlow() + + override fun navigate(route: Route) { + _commands.tryEmit(NavigationCommand.Navigate(route)) + } + + override fun replaceAll(route: Route) { + _commands.tryEmit(NavigationCommand.ReplaceAll(route)) + } + + override fun pop() { + _commands.tryEmit(NavigationCommand.Pop) + } +} + +val LocalNavigationManager: ProvidableCompositionLocal = + staticCompositionLocalOf { error("NavigationManager not provided") } diff --git a/app/src/main/java/hu/bbara/purefin/navigation/NavigationManagerModule.kt b/app/src/main/java/hu/bbara/purefin/navigation/NavigationManagerModule.kt new file mode 100644 index 0000000..e5c87b0 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/navigation/NavigationManagerModule.kt @@ -0,0 +1,15 @@ +package hu.bbara.purefin.navigation + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NavigationManagerModule { + @Provides + @Singleton + fun provideNavigationManager(): NavigationManager = DefaultNavigationManager() +} diff --git a/app/src/main/java/hu/bbara/purefin/navigation/NavigationModule.kt b/app/src/main/java/hu/bbara/purefin/navigation/NavigationModule.kt new file mode 100644 index 0000000..c2f31d5 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/navigation/NavigationModule.kt @@ -0,0 +1,19 @@ +package hu.bbara.purefin.navigation + +import androidx.navigation3.runtime.EntryProviderScope +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 { + + @IntoSet + @Provides + fun provideAppEntryBuilder(): EntryProviderScope.() -> Unit = { + appRouteEntryBuilder() + } +} diff --git a/app/src/main/java/hu/bbara/purefin/navigation/Route.kt b/app/src/main/java/hu/bbara/purefin/navigation/Route.kt new file mode 100644 index 0000000..8792c15 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/navigation/Route.kt @@ -0,0 +1,12 @@ +package hu.bbara.purefin.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +sealed interface Route : NavKey { + @Serializable + data object Home: Route + + @Serializable + data class Movie(val movieId: String) : Route +} diff --git a/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt b/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt new file mode 100644 index 0000000..8979012 --- /dev/null +++ b/app/src/main/java/hu/bbara/purefin/navigation/RouteEntryBuilder.kt @@ -0,0 +1,14 @@ +package hu.bbara.purefin.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import hu.bbara.purefin.app.content.movie.MovieScreen +import hu.bbara.purefin.app.home.HomePage + +fun EntryProviderScope.appRouteEntryBuilder() { + entry { + HomePage() + } + entry { + MovieScreen(movieId = it.movieId) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec3cd7d..1d6e560 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.3" +agp = "8.9.1" coreKtx = "1.15.0" junit = "4.13.2" junitVersion = "1.2.1" @@ -18,6 +18,7 @@ okhttp = "4.12.0" foundation = "1.10.1" coil = "3.3.0" media3 = "1.9.0" +nav3Core = "1.0.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -26,6 +27,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } @@ -50,6 +52,8 @@ coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp" medi3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3"} medi3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3"} medi3-ui-compose = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3"} +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } [plugins]