diff --git a/app/build.gradle b/app/build.gradle index 9cc713b0..83afd547 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,13 +1,14 @@ plugins { - id 'com.google.devtools.ksp' + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-parcelize' id 'com.google.dagger.hilt.android' + id 'com.google.gms.google-services' + id 'com.google.devtools.ksp' + id 'org.jetbrains.kotlin.plugin.compose' + id 'com.google.firebase.crashlytics' } -apply plugin: 'com.android.application' -apply plugin: 'com.google.gms.google-services' -apply plugin: 'com.google.firebase.crashlytics' -apply plugin: 'kotlin-parcelize' - repositories { google() mavenCentral() @@ -16,9 +17,9 @@ repositories { android { defaultConfig { applicationId "tech.salroid.filmy" - compileSdk 36 + compileSdk = 37 minSdk 24 - targetSdk 36 + targetSdk 37 versionCode 24 versionName "3.1.0" multiDexEnabled true @@ -34,6 +35,15 @@ android { buildConfigField("String", "OMDB_API_KEY", "\"" + getOMDBApiKey() + "\"") buildConfigField("String", "YOUTUBE_API_KEY", "\"" + getYoutubeApiKey() + "\"") } + + releaseUnminified { + initWith release + minifyEnabled false + shrinkResources false + signingConfig signingConfigs.debug + matchingFallbacks = ['release'] + } + debug { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' buildConfigField("String", "TMDB_API_KEY", "\"" + getTMDBApiKey() + "\"") @@ -50,8 +60,9 @@ android { } buildFeatures { - viewBinding = true - buildConfig = true + viewBinding true + buildConfig true + compose true } namespace 'tech.salroid.filmy' } @@ -85,57 +96,95 @@ dependencies { implementation 'com.google.android.material:material:1.13.0' implementation "androidx.constraintlayout:constraintlayout:2.2.1" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2' + implementation 'androidx.compose.ui:ui-text-google-fonts:1.10.6' implementation "androidx.core:core-ktx:1.17.0" implementation "androidx.activity:activity-ktx:1.11.0" - def paging_version = "3.3.5" + def paging_version = "3.4.2" implementation "androidx.paging:paging-runtime-ktx:$paging_version" + implementation "androidx.paging:paging-compose:$paging_version" - implementation 'androidx.browser:browser:1.9.0' - implementation "androidx.work:work-runtime:2.10.5" + implementation 'androidx.browser:browser:1.10.0' + implementation "androidx.work:work-runtime:2.11.2" implementation 'androidx.palette:palette-ktx:1.0.0' implementation "androidx.concurrent:concurrent-futures-ktx:1.3.0" implementation 'com.github.bumptech.glide:glide:5.0.5' + ksp 'com.github.bumptech.glide:ksp:5.0.5' - implementation 'com.github.paolorotolo:appintro:4.1.0' implementation "androidx.preference:preference-ktx:1.2.1" implementation "androidx.fragment:fragment-ktx:1.8.9" - implementation "androidx.core:core-splashscreen:1.0.1" + implementation "androidx.core:core-splashscreen:1.2.0" implementation 'com.squareup.retrofit2:retrofit:3.0.0' implementation 'com.squareup.retrofit2:converter-gson:3.0.0' - implementation 'com.squareup.okhttp3:logging-interceptor:4.10.0' - implementation 'com.squareup.okhttp3:okhttp:4.10.0' + implementation 'com.squareup.okhttp3:logging-interceptor:5.3.2' + implementation 'com.squareup.okhttp3:okhttp:5.3.2' implementation 'com.github.chrisbanes:PhotoView:2.3.0' implementation 'com.facebook.shimmer:shimmer:0.5.0' - def lottieVersion = "3.4.0" - implementation "com.airbnb.android:lottie:$lottieVersion" + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" - def lifecycle_version = "2.9.4" + def lifecycle_version = "2.10.0" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" - def roomVersion = "2.8.1" + def roomVersion = "2.8.4" implementation "androidx.room:room-runtime:$roomVersion" + implementation "androidx.room:room-ktx:$roomVersion" + implementation "androidx.room:room-paging:$roomVersion" ksp "androidx.room:room-compiler:$roomVersion" // Dagger Hilt - implementation "com.google.dagger:hilt-android:2.60.1" - ksp "com.google.dagger:hilt-compiler:2.60.1" + def daggerHiltVersion = "2.60.1" + implementation "com.google.dagger:hilt-android:$daggerHiltVersion" + ksp "com.google.dagger:hilt-compiler:$daggerHiltVersion" // Navigation - def nav_version = "2.9.5" + def nav_version = "2.9.7" implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" - implementation platform('com.google.firebase:firebase-bom:34.3.0') + def lottieVersion = "6.7.1" + implementation "com.airbnb.android:lottie:$lottieVersion" + + // Compose + implementation "com.google.android.material:compose-theme-adapter:1.2.1" + + // Firebase + implementation platform('com.google.firebase:firebase-bom:34.11.0') implementation 'com.google.firebase:firebase-analytics' implementation 'com.google.firebase:firebase-crashlytics' + // Compose + def composeBom = platform('androidx.compose:compose-bom:2026.03.01') + implementation composeBom + androidTestImplementation composeBom + implementation("androidx.compose.material3:material3:1.5.0-alpha16") + implementation("androidx.compose.material:material-icons-core") + implementation("androidx.compose.material:material-icons-extended") + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose' + implementation 'androidx.activity:activity-compose' + implementation 'androidx.compose.material3.adaptive:adaptive-android' + implementation("androidx.hilt:hilt-navigation-compose:1.3.0") + implementation("io.coil-kt.coil3:coil-compose:3.4.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + releaseUnminifiedImplementation 'androidx.compose.ui:ui-tooling' + + // Glance + implementation "androidx.glance:glance-appwidget:1.1.1" + implementation "androidx.glance:glance-material3:1.1.1" + + //debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'junit:junit:4.13.2' androidTestImplementation "androidx.test.espresso:espresso-core:3.7.0" androidTestImplementation "androidx.test:runner:1.7.0" androidTestImplementation "androidx.test:rules:1.7.0" androidTestImplementation 'androidx.test:core-ktx:1.7.0' + + // Compose + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test") + testImplementation("app.cash.turbine:turbine") } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf10dae7..8165efb3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,17 +9,17 @@ + > @@ -58,11 +58,10 @@ - + android:name=".ui.widget.WatchlistWidgetReceiver" + android:exported="true"> @@ -71,5 +70,16 @@ android:resource="@xml/filmy_widget_info" /> + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/core/navigation/AppNavHost.kt b/app/src/main/java/tech/salroid/filmy/core/navigation/AppNavHost.kt new file mode 100644 index 00000000..8ca3b37b --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/core/navigation/AppNavHost.kt @@ -0,0 +1,535 @@ +package tech.salroid.filmy.core.navigation + +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import tech.salroid.filmy.ui.account.AccountScreen +import tech.salroid.filmy.ui.cast_crew.AllCastCrewScreen +import tech.salroid.filmy.ui.cast_crew.CastCrewDetailsScreen +import tech.salroid.filmy.ui.collections.CollectionScreen +import tech.salroid.filmy.ui.discover.DiscoverRoute +import tech.salroid.filmy.ui.franchise.FranchiseScreen +import tech.salroid.filmy.ui.full.AllMoviesScreen +import tech.salroid.filmy.ui.gallery.GalleryScreen +import tech.salroid.filmy.ui.lists.ListDetailsScreen +import tech.salroid.filmy.ui.lists.MyListsScreen +import tech.salroid.filmy.ui.movies.MoviesRoute +import tech.salroid.filmy.ui.movies.details.MovieDetailsScreen +import tech.salroid.filmy.ui.onboarding.OnboardingScreen +import tech.salroid.filmy.ui.people.PeopleScreen +import tech.salroid.filmy.ui.reviews.ReviewsListScreen +import tech.salroid.filmy.data.model.SearchPreview +import tech.salroid.filmy.ui.search.SearchScreenState +import tech.salroid.filmy.ui.season.SeasonScreen +import tech.salroid.filmy.ui.settings.AboutScreen +import tech.salroid.filmy.ui.settings.LicenseScreen +import tech.salroid.filmy.ui.shows.ShowsRoute +import tech.salroid.filmy.ui.shows.details.ShowDetailsScreen + +private const val DEEP_LINK_URI = "http://tech.salroid.com" + +@Composable +fun AppNavHost( + navController: NavHostController, + textFieldState: TextFieldState, + searchUiState: SearchScreenState, + isSearchExpanded: Boolean, + onSearchExpandedChange: (Boolean) -> Unit, + onSearch: (String) -> Unit, + recentSearches: List = emptyList(), + onRecentSearchClick: (String) -> Unit = {}, + onRemoveRecentSearch: (String) -> Unit = {}, + onClearRecentSearches: () -> Unit = {}, + bottomPadding: Dp, + modifier: Modifier = Modifier, + startDestination: String = AppRoute.MoviesGraph.route +) { + NavHost( + modifier = modifier, + navController = navController, + startDestination = startDestination, + ) { + composable(AppRoute.Onboarding.route) { + OnboardingScreen( + onDone = { + navController.navigate(AppRoute.MoviesGraph.route) { + popUpTo(AppRoute.Onboarding.route) { inclusive = true } + } + } + ) + } + moviesGraph( + navController, + textFieldState, + searchUiState, + isSearchExpanded, + onSearchExpandedChange, + onSearch, + recentSearches, + onRecentSearchClick, + onRemoveRecentSearch, + onClearRecentSearches, + bottomPadding + ) + showsGraph( + navController, + textFieldState, + searchUiState, + isSearchExpanded, + onSearchExpandedChange, + onSearch, + recentSearches, + onRecentSearchClick, + onRemoveRecentSearch, + onClearRecentSearches, + bottomPadding + ) + collectionGraph(navController, bottomPadding) + accountGraph(navController, bottomPadding) + commonScreens(navController) + } +} + +private fun NavGraphBuilder.moviesGraph( + navController: NavHostController, + textFieldState: TextFieldState, + searchUiState: SearchScreenState, + isSearchExpanded: Boolean, + onSearchExpandedChange: (Boolean) -> Unit, + onSearch: (String) -> Unit, + recentSearches: List, + onRecentSearchClick: (String) -> Unit, + onRemoveRecentSearch: (String) -> Unit, + onClearRecentSearches: () -> Unit, + bottomPadding: Dp +) { + navigation( + startDestination = AppRoute.Movies.route, + route = AppRoute.MoviesGraph.route + ) { + composable(AppRoute.Movies.route) { + MoviesRoute( + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = bottomPadding), + textFieldState = textFieldState, + searchUiState = searchUiState, + isSearchExpanded = isSearchExpanded, + onSearchExpandedChange = onSearchExpandedChange, + onSearch = onSearch, + recentSearches = recentSearches, + onRecentSearchClick = onRecentSearchClick, + onRemoveRecentSearch = onRemoveRecentSearch, + onClearRecentSearches = onClearRecentSearches, + onMovieClick = { id -> + navController.navigate(AppRoute.MovieDetails.create(id)) + }, + onSearchResultClick = { item: SearchPreview -> + when (item.mediaType) { + "tv" -> navController.navigate(AppRoute.ShowDetails.create(item.id)) + "person" -> navController.navigate(AppRoute.CastCrewDetails.create(item.id, isTv = false)) + else -> navController.navigate(AppRoute.MovieDetails.create(item.id)) + } + }, + onFilterClick = { + navController.navigate(AppRoute.Discover.create(isTv = false)) + }, + onPeopleClick = { + navController.navigate(AppRoute.People.route) + } + ) + } + composable( + route = AppRoute.MovieDetails.route, + arguments = listOf(navArgument("movieId") { + type = NavType.IntType + }), + deepLinks = listOf(navDeepLink { + uriPattern = "$DEEP_LINK_URI/movies/{movieId}" + }) + ) { + MovieDetailsScreen( + movieId = it.arguments?.getInt("movieId") ?: 0, + onBackNavigation = { navController.popBackStack() }, + onViewAllCastClick = { id, isTv, title -> + navController.navigate(AppRoute.AllCastCrew.create(id, isTv, title)) + }, + onViewAllReviewsClick = { id, isTv, title -> + navController.navigate(AppRoute.ReviewsList.create(id, isTv, title)) + }, + onMemberClick = { memberId, isTv -> + navController.navigate(AppRoute.CastCrewDetails.create(memberId, isTv)) + }, + onMovieClick = { id -> + navController.navigate(AppRoute.MovieDetails.create(id)) + }, + onCollectionClick = { id, name -> + navController.navigate(AppRoute.Franchise.create(id, name)) + }, + onGalleryClick = { id, isTv -> + navController.navigate(AppRoute.Gallery.create(id, isTv)) + }, + onKeywordClick = { id, name -> + navController.navigate(AppRoute.Discover.create(isTv = false, keywordId = id, keywordName = name)) + } + ) + } + } +} + +private fun NavGraphBuilder.showsGraph( + navController: NavHostController, + textFieldState: TextFieldState, + searchUiState: SearchScreenState, + isSearchExpanded: Boolean, + onSearchExpandedChange: (Boolean) -> Unit, + onSearch: (String) -> Unit, + recentSearches: List, + onRecentSearchClick: (String) -> Unit, + onRemoveRecentSearch: (String) -> Unit, + onClearRecentSearches: () -> Unit, + bottomPadding: Dp +) { + navigation( + route = AppRoute.ShowsGraph.route, + startDestination = AppRoute.Shows.route + ) { + composable(AppRoute.Shows.route) { + ShowsRoute( + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = bottomPadding), + textFieldState = textFieldState, + searchUiState = searchUiState, + isSearchExpanded = isSearchExpanded, + onSearchExpandedChange = onSearchExpandedChange, + onSearch = onSearch, + recentSearches = recentSearches, + onRecentSearchClick = onRecentSearchClick, + onRemoveRecentSearch = onRemoveRecentSearch, + onClearRecentSearches = onClearRecentSearches, + onShowClick = { id -> + navController.navigate(AppRoute.ShowDetails.create(id)) + }, + onSearchResultClick = { item: SearchPreview -> + when (item.mediaType) { + "tv" -> navController.navigate(AppRoute.ShowDetails.create(item.id)) + "person" -> navController.navigate(AppRoute.CastCrewDetails.create(item.id, isTv = false)) + else -> navController.navigate(AppRoute.MovieDetails.create(item.id)) + } + }, + onFilterClick = { + navController.navigate(AppRoute.Discover.create(isTv = true)) + } + ) + } + composable( + route = AppRoute.ShowDetails.route, + arguments = listOf(navArgument("showId") { + type = NavType.IntType + }), + deepLinks = listOf(navDeepLink { + uriPattern = "$DEEP_LINK_URI/shows/{showId}" + }) + ) { + ShowDetailsScreen( + showId = it.arguments?.getInt("showId") ?: 0, + onBackNavigation = { navController.popBackStack() }, + onViewAllCastClick = { id, isTv, title -> + navController.navigate(AppRoute.AllCastCrew.create(id, isTv, title)) + }, + onViewAllReviewsClick = { id, isTv, title -> + navController.navigate(AppRoute.ReviewsList.create(id, isTv, title)) + }, + onMemberClick = { memberId, isTv -> + navController.navigate(AppRoute.CastCrewDetails.create(memberId, isTv)) + }, + onShowClick = { id -> + navController.navigate(AppRoute.ShowDetails.create(id)) + }, + onSeasonClick = { tvId, seasonNumber, showTitle -> + navController.navigate(AppRoute.SeasonDetails.create(tvId, seasonNumber, showTitle)) + }, + onGalleryClick = { id, isTv -> + navController.navigate(AppRoute.Gallery.create(id, isTv)) + }, + onKeywordClick = { id, name -> + navController.navigate(AppRoute.Discover.create(isTv = true, keywordId = id, keywordName = name)) + } + ) + } + } +} + +private fun NavGraphBuilder.collectionGraph(navController: NavHostController, bottomPadding: Dp) { + navigation( + route = AppRoute.CollectionGraph.route, + startDestination = AppRoute.Collection.route + ) { + composable(AppRoute.Collection.route) { + CollectionScreen( + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = bottomPadding) + ) { id, type -> + if (type == 1) { + navController.navigate(AppRoute.ShowDetails.create(id)) + } else { + navController.navigate(AppRoute.MovieDetails.create(id)) + } + } + } + } +} + +private fun NavGraphBuilder.accountGraph(navController: NavHostController, bottomPadding: Dp) { + navigation( + route = AppRoute.AccountGraph.route, + startDestination = AppRoute.Account.route + ) { + composable(AppRoute.Account.route) { + AccountScreen( + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = bottomPadding), + onAboutClick = { navController.navigate(AppRoute.About.route) }, + onLicenseClick = { navController.navigate(AppRoute.License.route) }, + onMyListsClick = { navController.navigate(AppRoute.MyLists.route) } + ) + } + composable(AppRoute.About.route) { + AboutScreen { navController.popBackStack() } + } + composable(AppRoute.License.route) { + LicenseScreen { navController.popBackStack() } + } + composable(AppRoute.MyLists.route) { + MyListsScreen( + onListClick = { listId, listName -> + navController.navigate(AppRoute.ListDetails.create(listId, listName)) + }, + onBackClick = { navController.popBackStack() } + ) + } + } +} + +private fun NavGraphBuilder.commonScreens(navController: NavHostController) { + composable( + route = AppRoute.AllCastCrew.route, + arguments = listOf( + navArgument("id") { type = NavType.IntType }, + navArgument("isTv") { type = NavType.BoolType }, + navArgument("title") { type = NavType.StringType } + ) + ) { backStackEntry -> + val id = backStackEntry.arguments?.getInt("id") ?: 0 + val isTv = backStackEntry.arguments?.getBoolean("isTv") ?: false + val title = backStackEntry.arguments?.getString("title") ?: "" + AllCastCrewScreen( + id = id, + isTv = isTv, + title = title, + onMemberClick = { memberId -> + navController.navigate(AppRoute.CastCrewDetails.create(memberId, isTv)) + }, + onBackClick = { navController.popBackStack() } + ) + } + + composable( + route = AppRoute.CastCrewDetails.route, + arguments = listOf( + navArgument("memberId") { type = NavType.IntType }, + navArgument("isTv") { type = NavType.BoolType } + ) + ) { backStackEntry -> + val memberId = backStackEntry.arguments?.getInt("memberId") ?: 0 + val isTv = backStackEntry.arguments?.getBoolean("isTv") ?: false + CastCrewDetailsScreen( + memberId = memberId, + isTv = isTv, + onMovieClick = { id, creditIsTv, _ -> + if (creditIsTv) { + navController.navigate(AppRoute.ShowDetails.create(id)) + } else { + navController.navigate(AppRoute.MovieDetails.create(id)) + } + }, + onViewAllMoviesClick = { id, tv, title -> + navController.navigate(AppRoute.AllMovies.create(id, tv, title)) + }, + onBackClick = { navController.popBackStack() } + ) + } + + composable( + route = AppRoute.AllMovies.route, + arguments = listOf( + navArgument("memberId") { type = NavType.IntType }, + navArgument("isTv") { type = NavType.BoolType }, + navArgument("title") { type = NavType.StringType } + ) + ) { backStackEntry -> + val memberId = backStackEntry.arguments?.getInt("memberId") ?: 0 + val isTv = backStackEntry.arguments?.getBoolean("isTv") ?: false + val title = backStackEntry.arguments?.getString("title") ?: "" + AllMoviesScreen( + memberId = memberId, + isTv = isTv, + title = title, + onMovieClick = { id, _ -> + if (isTv) { + navController.navigate(AppRoute.ShowDetails.create(id)) + } else { + navController.navigate(AppRoute.MovieDetails.create(id)) + } + }, + onBackClick = { navController.popBackStack() } + ) + } + + composable( + route = AppRoute.ReviewsList.route, + arguments = listOf( + navArgument("id") { type = NavType.IntType }, + navArgument("isTv") { type = NavType.BoolType }, + navArgument("title") { type = NavType.StringType } + ) + ) { backStackEntry -> + val id = backStackEntry.arguments?.getInt("id") ?: 0 + val isTv = backStackEntry.arguments?.getBoolean("isTv") ?: false + val title = backStackEntry.arguments?.getString("title") ?: "" + ReviewsListScreen( + id = id, + isTv = isTv, + title = title, + onBackClick = { navController.popBackStack() } + ) + } + + composable( + route = AppRoute.Discover.route, + arguments = listOf( + navArgument("isTv") { type = NavType.BoolType }, + navArgument("keywordId") { + type = NavType.IntType + defaultValue = -1 + }, + navArgument("keywordName") { + type = NavType.StringType + nullable = true + defaultValue = null + } + ) + ) { backStackEntry -> + val isTv = backStackEntry.arguments?.getBoolean("isTv") ?: false + val keywordId = backStackEntry.arguments?.getInt("keywordId")?.takeIf { it != -1 } + val keywordName = backStackEntry.arguments?.getString("keywordName") + DiscoverRoute( + isTv = isTv, + keywordId = keywordId, + keywordName = keywordName, + onBackClick = { navController.popBackStack() }, + onMovieClick = { id -> + navController.navigate(AppRoute.MovieDetails.create(id)) + }, + onShowClick = { id -> + navController.navigate(AppRoute.ShowDetails.create(id)) + } + ) + } + + composable(AppRoute.People.route) { + PeopleScreen( + onPersonClick = { id -> + navController.navigate(AppRoute.CastCrewDetails.create(id, isTv = false)) + }, + onBackClick = { navController.popBackStack() } + ) + } + + composable( + route = AppRoute.ListDetails.route, + arguments = listOf( + navArgument("listId") { type = NavType.IntType }, + navArgument("listName") { type = NavType.StringType } + ) + ) { backStackEntry -> + val listId = backStackEntry.arguments?.getInt("listId") ?: 0 + val listName = backStackEntry.arguments?.getString("listName") ?: "" + ListDetailsScreen( + listId = listId, + title = listName, + onMovieClick = { id -> + navController.navigate(AppRoute.MovieDetails.create(id)) + }, + onBackClick = { navController.popBackStack() } + ) + } + + composable( + route = AppRoute.Franchise.route, + arguments = listOf( + navArgument("id") { type = NavType.IntType }, + navArgument("name") { type = NavType.StringType } + ) + ) { backStackEntry -> + val id = backStackEntry.arguments?.getInt("id") ?: 0 + val name = backStackEntry.arguments?.getString("name") ?: "" + FranchiseScreen( + collectionId = id, + title = name, + onMovieClick = { movieId -> + navController.navigate(AppRoute.MovieDetails.create(movieId)) + }, + onBackClick = { navController.popBackStack() } + ) + } + + composable( + route = AppRoute.SeasonDetails.route, + arguments = listOf( + navArgument("tvId") { type = NavType.IntType }, + navArgument("seasonNumber") { type = NavType.IntType }, + navArgument("showTitle") { type = NavType.StringType } + ) + ) { backStackEntry -> + val tvId = backStackEntry.arguments?.getInt("tvId") ?: 0 + val seasonNumber = backStackEntry.arguments?.getInt("seasonNumber") ?: 0 + val showTitle = backStackEntry.arguments?.getString("showTitle") ?: "" + SeasonScreen( + tvId = tvId, + seasonNumber = seasonNumber, + showTitle = showTitle, + onBackClick = { navController.popBackStack() } + ) + } + + composable( + route = AppRoute.Gallery.route, + arguments = listOf( + navArgument("id") { type = NavType.IntType }, + navArgument("isTv") { type = NavType.BoolType } + ) + ) { backStackEntry -> + val id = backStackEntry.arguments?.getInt("id") ?: 0 + val isTv = backStackEntry.arguments?.getBoolean("isTv") ?: false + GalleryScreen( + mediaId = id, + isTv = isTv, + onBackClick = { navController.popBackStack() } + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/core/navigation/AppNavigationBar.kt b/app/src/main/java/tech/salroid/filmy/core/navigation/AppNavigationBar.kt new file mode 100644 index 00000000..47809a2e --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/core/navigation/AppNavigationBar.kt @@ -0,0 +1,55 @@ +package tech.salroid.filmy.core.navigation + +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.painterResource +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState + +@Composable +fun AppNavigationBar(navController: NavHostController) { + val backStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = backStackEntry?.destination + + NavigationBar { + TopLevelDestinations.forEach { destination -> + NavigationBarItem( + selected = currentDestination + ?.hierarchy + ?.any { + it.route == destination.graphRoute + } == true, + onClick = { + navController.navigate(route = destination.graphRoute) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + painterResource(destination.iconRes), + contentDescription = destination.contentDescription + ) + }, + label = { Text(destination.label) }, + colors = NavigationBarItemDefaults.colors( + selectedTextColor = MaterialTheme.colorScheme.primary, + selectedIconColor = MaterialTheme.colorScheme.primary, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/core/navigation/AppRoute.kt b/app/src/main/java/tech/salroid/filmy/core/navigation/AppRoute.kt new file mode 100644 index 00000000..16120b59 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/core/navigation/AppRoute.kt @@ -0,0 +1,91 @@ +package tech.salroid.filmy.core.navigation + +sealed class AppRoute(val route: String) { + + // Bottom Tabs (graphs) + object MoviesGraph : AppRoute("movies_graph") + object ShowsGraph : AppRoute("shows_graph") + object CollectionGraph : AppRoute("collection_graph") + object AccountGraph : AppRoute("account_graph") + + // Screens + object Movies : AppRoute("movies") + + object MovieDetails : AppRoute("movies/{movieId}") { + fun create(id: Int) = "movies/$id" + } + + object Shows : AppRoute("shows") + object ShowDetails : AppRoute("shows/{showId}") { + fun create(id: Int) = "shows/$id" + } + + object Collection : AppRoute("collection") + object People : AppRoute("people") + object Account : AppRoute("account") + object About : AppRoute("about") + object License : AppRoute("license") + + object AllCastCrew : AppRoute("cast_crew/{id}/{isTv}/{title}") { + fun create( + id: Int, + isTv: Boolean, + title: String + ) = "cast_crew/$id/$isTv/$title" + } + + object CastCrewDetails : AppRoute("cast_crew_details/{memberId}/{isTv}") { + fun create( + memberId: Int, + isTv: Boolean + ) = "cast_crew_details/$memberId/$isTv" + } + + object AllMovies : AppRoute("all_movies/{memberId}/{isTv}/{title}") { + fun create( + memberId: Int, + isTv: Boolean, + title: String + ) = "all_movies/$memberId/$isTv/$title" + } + + object ReviewsList : AppRoute("reviews_list/{id}/{isTv}/{title}") { + fun create( + id: Int, + isTv: Boolean, + title: String + ) = "reviews_list/$id/$isTv/$title" + } + + object Discover : AppRoute("discover/{isTv}?keywordId={keywordId}&keywordName={keywordName}") { + fun create(isTv: Boolean, keywordId: Int? = null, keywordName: String? = null): String { + val base = "discover/$isTv" + return if (keywordId != null && keywordName != null) { + "$base?keywordId=$keywordId&keywordName=$keywordName" + } else { + base + } + } + } + + object MyLists : AppRoute("my_lists") + + object ListDetails : AppRoute("list_details/{listId}/{listName}") { + fun create(listId: Int, listName: String) = "list_details/$listId/$listName" + } + + object Franchise : AppRoute("franchise/{id}/{name}") { + fun create(id: Int, name: String) = "franchise/$id/$name" + } + + object SeasonDetails : AppRoute("season_details/{tvId}/{seasonNumber}/{showTitle}") { + fun create(tvId: Int, seasonNumber: Int, showTitle: String) = + "season_details/$tvId/$seasonNumber/$showTitle" + } + + object Gallery : AppRoute("gallery/{id}/{isTv}") { + fun create(id: Int, isTv: Boolean) = "gallery/$id/$isTv" + } + + object Onboarding : AppRoute("onboarding") +} diff --git a/app/src/main/java/tech/salroid/filmy/core/navigation/TopLevelDestination.kt b/app/src/main/java/tech/salroid/filmy/core/navigation/TopLevelDestination.kt new file mode 100644 index 00000000..47ae4bdc --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/core/navigation/TopLevelDestination.kt @@ -0,0 +1,42 @@ +package tech.salroid.filmy.core.navigation + +import tech.salroid.filmy.R + +data class TopLevelDestination( + val graphRoute: String, + val startDestinationRoute: String, + val label: String, + val iconRes: Int, + val contentDescription: String +) + +val TopLevelDestinations = listOf( + TopLevelDestination( + graphRoute = AppRoute.MoviesGraph.route, + startDestinationRoute = AppRoute.Movies.route, + label = "Movies", + iconRes = R.drawable.outline_movie_24, + contentDescription = "Movies" + ), + TopLevelDestination( + graphRoute = AppRoute.ShowsGraph.route, + startDestinationRoute = AppRoute.Shows.route, + label = "Shows", + iconRes = R.drawable.ic_tv, + contentDescription = "Shows" + ), + TopLevelDestination( + graphRoute = AppRoute.CollectionGraph.route, + startDestinationRoute = AppRoute.Collection.route, + label = "Collection", + iconRes = R.drawable.ic_collections_bookmark_24dp, + contentDescription = "Collection" + ), + TopLevelDestination( + graphRoute = AppRoute.AccountGraph.route, + startDestinationRoute = AppRoute.Account.route, + label = "Account", + iconRes = R.drawable.ic_face, + contentDescription = "Account" + ) +) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/datasource/DiscoverMoviesPagingSource.kt b/app/src/main/java/tech/salroid/filmy/data/datasource/DiscoverMoviesPagingSource.kt new file mode 100644 index 00000000..55dedb72 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/datasource/DiscoverMoviesPagingSource.kt @@ -0,0 +1,51 @@ +package tech.salroid.filmy.data.datasource + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import tech.salroid.filmy.data.local.db.entity.Movie +import tech.salroid.filmy.data.local.model.discover.DiscoverFilters +import tech.salroid.filmy.data.network.MoviesApiService + +class DiscoverMoviesPagingSource( + private val apiService: MoviesApiService, + private val filters: DiscoverFilters +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val currentLoadingPageKey = params.key ?: 1 + val response = apiService.discoverMovies( + withGenres = filters.genreIds.takeIf { it.isNotEmpty() }?.joinToString("|"), + year = filters.year, + minRating = filters.minRating, + withKeywords = filters.keywordId?.toString(), + sortBy = filters.sortBy.apiValue, + page = currentLoadingPageKey + ) + + val results = response.results + val totalPages = response.totalPages ?: 0 + + val prevKey = + if (currentLoadingPageKey == 1) null else currentLoadingPageKey - 1 + + val nextKey = + if (currentLoadingPageKey >= totalPages || results.isEmpty()) null else currentLoadingPageKey + 1 + + LoadResult.Page( + data = results, + prevKey = prevKey, + nextKey = nextKey + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/data/datasource/DiscoverTvPagingSource.kt b/app/src/main/java/tech/salroid/filmy/data/datasource/DiscoverTvPagingSource.kt new file mode 100644 index 00000000..810004a8 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/datasource/DiscoverTvPagingSource.kt @@ -0,0 +1,51 @@ +package tech.salroid.filmy.data.datasource + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import tech.salroid.filmy.data.local.model.TvShow +import tech.salroid.filmy.data.local.model.discover.DiscoverFilters +import tech.salroid.filmy.data.network.MoviesApiService + +class DiscoverTvPagingSource( + private val apiService: MoviesApiService, + private val filters: DiscoverFilters +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val currentLoadingPageKey = params.key ?: 1 + val response = apiService.discoverTv( + withGenres = filters.genreIds.takeIf { it.isNotEmpty() }?.joinToString("|"), + year = filters.year, + minRating = filters.minRating, + withKeywords = filters.keywordId?.toString(), + sortBy = filters.sortBy.apiValue, + page = currentLoadingPageKey + ) + + val results = response.results + val totalPages = response.totalPages ?: 0 + + val prevKey = + if (currentLoadingPageKey == 1) null else currentLoadingPageKey - 1 + + val nextKey = + if (currentLoadingPageKey >= totalPages || results.isEmpty()) null else currentLoadingPageKey + 1 + + LoadResult.Page( + data = results, + prevKey = prevKey, + nextKey = nextKey + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/data/datasource/MoviesPagingSource.kt b/app/src/main/java/tech/salroid/filmy/data/datasource/MoviesPagingSource.kt index 7def4284..be108863 100644 --- a/app/src/main/java/tech/salroid/filmy/data/datasource/MoviesPagingSource.kt +++ b/app/src/main/java/tech/salroid/filmy/data/datasource/MoviesPagingSource.kt @@ -21,14 +21,18 @@ class MoviesPagingSource( } val results = response.results + val totalPages = response.totalPages ?: 0 val prevKey = if (currentLoadingPageKey == 1) null else currentLoadingPageKey - 1 + val nextKey = + if (currentLoadingPageKey >= totalPages || results.isEmpty()) null else currentLoadingPageKey + 1 + LoadResult.Page( data = results, prevKey = prevKey, - nextKey = currentLoadingPageKey.plus(1) + nextKey = nextKey ) } catch (e: Exception) { LoadResult.Error(e) diff --git a/app/src/main/java/tech/salroid/filmy/data/datasource/PeoplePagingSource.kt b/app/src/main/java/tech/salroid/filmy/data/datasource/PeoplePagingSource.kt new file mode 100644 index 00000000..bc382b7f --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/datasource/PeoplePagingSource.kt @@ -0,0 +1,42 @@ +package tech.salroid.filmy.data.datasource + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import tech.salroid.filmy.data.local.model.Person +import tech.salroid.filmy.data.network.MoviesApiService + +class PeoplePagingSource( + private val apiService: MoviesApiService +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val currentLoadingPageKey = params.key ?: 1 + val response = apiService.getPopularPeople(currentLoadingPageKey) + + val results = response.results + val totalPages = response.totalPages ?: 0 + + val prevKey = + if (currentLoadingPageKey == 1) null else currentLoadingPageKey - 1 + + val nextKey = + if (currentLoadingPageKey >= totalPages || results.isEmpty()) null else currentLoadingPageKey + 1 + + LoadResult.Page( + data = results, + prevKey = prevKey, + nextKey = nextKey + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/data/datasource/TvShowsPagingSource.kt b/app/src/main/java/tech/salroid/filmy/data/datasource/TvShowsPagingSource.kt index 2272fbc0..ac4d9463 100644 --- a/app/src/main/java/tech/salroid/filmy/data/datasource/TvShowsPagingSource.kt +++ b/app/src/main/java/tech/salroid/filmy/data/datasource/TvShowsPagingSource.kt @@ -21,14 +21,18 @@ class TvShowsPagingSource( } val results = response.results + val totalPages = response.totalPages ?: 0 val prevKey = if (currentLoadingPageKey == 1) null else currentLoadingPageKey - 1 + val nextKey = + if (currentLoadingPageKey >= totalPages || results.isEmpty()) null else currentLoadingPageKey + 1 + LoadResult.Page( data = results, prevKey = prevKey, - nextKey = currentLoadingPageKey.plus(1) + nextKey = nextKey ) } catch (e: Exception) { LoadResult.Error(e) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/db/Converters.kt b/app/src/main/java/tech/salroid/filmy/data/local/db/Converters.kt index 0fb7d05d..8074a1e4 100644 --- a/app/src/main/java/tech/salroid/filmy/data/local/db/Converters.kt +++ b/app/src/main/java/tech/salroid/filmy/data/local/db/Converters.kt @@ -104,4 +104,15 @@ class Converters { fun fromAvatar(avatar: Avatar?): String? { return Gson().toJson(avatar) } + + @TypeConverter + fun fromStringOfVideos(value: String?): Videos? { + val listType: Type = object : TypeToken() {}.type + return Gson().fromJson(value, listType) + } + + @TypeConverter + fun fromVideos(videos: Videos?): String? { + return Gson().toJson(videos) + } } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/local/db/FilmyDatabase.kt b/app/src/main/java/tech/salroid/filmy/data/local/db/FilmyDatabase.kt index 17a0a09e..7c2a5c1f 100644 --- a/app/src/main/java/tech/salroid/filmy/data/local/db/FilmyDatabase.kt +++ b/app/src/main/java/tech/salroid/filmy/data/local/db/FilmyDatabase.kt @@ -12,7 +12,7 @@ import tech.salroid.filmy.data.local.db.entity.Profile @Database( entities = [Movie::class, MovieDetails::class, Profile::class], - version = 2, + version = 5, exportSchema = false ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/db/dao/AccountDao.kt b/app/src/main/java/tech/salroid/filmy/data/local/db/dao/AccountDao.kt index 9fd7db87..2fec4fa4 100644 --- a/app/src/main/java/tech/salroid/filmy/data/local/db/dao/AccountDao.kt +++ b/app/src/main/java/tech/salroid/filmy/data/local/db/dao/AccountDao.kt @@ -1,6 +1,7 @@ package tech.salroid.filmy.data.local.db.dao import androidx.room.* +import kotlinx.coroutines.flow.Flow import tech.salroid.filmy.data.local.db.entity.Profile @Dao @@ -17,4 +18,7 @@ interface AccountDao { @Query("SELECT * FROM profile") fun getProfile(): List + + @Query("SELECT * FROM profile") + fun getProfileFlow(): Flow> } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/local/db/dao/MovieDetailsDao.kt b/app/src/main/java/tech/salroid/filmy/data/local/db/dao/MovieDetailsDao.kt index d2327bbc..fdfdd0c5 100644 --- a/app/src/main/java/tech/salroid/filmy/data/local/db/dao/MovieDetailsDao.kt +++ b/app/src/main/java/tech/salroid/filmy/data/local/db/dao/MovieDetailsDao.kt @@ -1,6 +1,7 @@ package tech.salroid.filmy.data.local.db.dao import androidx.room.* +import kotlinx.coroutines.flow.Flow import tech.salroid.filmy.data.local.db.entity.MovieDetails @Dao @@ -15,15 +16,24 @@ interface MovieDetailsDao { @Query("SELECT * FROM movie_details") fun getAllDetails(): List - @Query("SELECT * FROM movie_details WHERE favorite = 1") - fun getAllFavorites(): List + @Query("SELECT * FROM movie_details WHERE watched = 1") + fun getAllWatched(): Flow> @Query("SELECT * FROM movie_details WHERE watchlist = 1") - fun getAllWatchlist(): List + fun getAllWatchlist(): Flow> + + @Query("SELECT * FROM movie_details WHERE userRating IS NOT NULL") + fun getAllRated(): Flow> + + @Query("SELECT * FROM movie_details WHERE watched = 1 AND userRating IS NULL") + fun getWatchedUnrated(): Flow> @Query("SELECT * FROM movie_details WHERE id = :id AND type = :type") fun getDetailsOfType(id: Int, type: Int = 0): MovieDetails? + @Query("SELECT * FROM movie_details WHERE id = :id AND type = :type") + fun getDetailsFlow(id: Int, type: Int = 0): Flow + @Update fun updateDetails(movie: MovieDetails): Int -} \ No newline at end of file +} diff --git a/app/src/main/java/tech/salroid/filmy/data/local/db/entity/MovieDetails.kt b/app/src/main/java/tech/salroid/filmy/data/local/db/entity/MovieDetails.kt index acc6be32..c7a3dab1 100644 --- a/app/src/main/java/tech/salroid/filmy/data/local/db/entity/MovieDetails.kt +++ b/app/src/main/java/tech/salroid/filmy/data/local/db/entity/MovieDetails.kt @@ -35,7 +35,7 @@ data class MovieDetails( @SerializedName("original_language") var originalLanguage: String? = null, - @SerializedName("original_title") + @SerializedName(value = "original_title", alternate = ["original_name"]) var originalTitle: String? = null, @SerializedName("overview") @@ -53,7 +53,7 @@ data class MovieDetails( @SerializedName("production_countries") var productionCountries: ArrayList = arrayListOf(), - @SerializedName("release_date") + @SerializedName(value = "release_date", alternate = ["first_air_date"]) var releaseDate: String? = null, @SerializedName("revenue") @@ -71,7 +71,7 @@ data class MovieDetails( @SerializedName("tagline") var tagline: String? = null, - @SerializedName("title") + @SerializedName(value = "title", alternate = ["name"]) var title: String? = null, @SerializedName("video") @@ -86,9 +86,14 @@ data class MovieDetails( @SerializedName("trailers") var trailers: Trailers? = Trailers(), + @SerializedName("videos") + var videos: Videos? = Videos(), + var type: Int = 0, - var favorite: Boolean = false, + var watched: Boolean = false, + + var watchlist: Boolean = false, - var watchlist: Boolean = false + var userRating: Float? = null ) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/CombinedCreditsResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/CombinedCreditsResponse.kt new file mode 100644 index 00000000..f58f1092 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/CombinedCreditsResponse.kt @@ -0,0 +1,52 @@ +package tech.salroid.filmy.data.local.model + +import com.google.gson.annotations.SerializedName +import java.io.Serializable + +data class CombinedCreditsResponse( + @SerializedName("cast") + var cast: List = emptyList(), + + @SerializedName("crew") + var crew: List = emptyList(), + + @SerializedName("id") + var id: Int? = null +) + +data class CombinedCredit( + @SerializedName("id") + var id: Int? = null, + + @SerializedName("title") + var title: String? = null, + + @SerializedName("name") + var name: String? = null, + + @SerializedName("poster_path") + var posterPath: String? = null, + + @SerializedName("release_date") + var releaseDate: String? = null, + + @SerializedName("first_air_date") + var firstAirDate: String? = null, + + @SerializedName("character") + var character: String? = null, + + @SerializedName("job") + var job: String? = null, + + @SerializedName("media_type") + var mediaType: String? = null, + + @SerializedName("popularity") + var popularity: Double? = null +) : Serializable { + val displayTitle: String? get() = title ?: name + val displayDate: String? get() = releaseDate ?: firstAirDate + val isTv: Boolean get() = mediaType == "tv" + val role: String? get() = character?.takeIf { it.isNotBlank() } ?: job?.takeIf { it.isNotBlank() } +} diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/ContentRatingsResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/ContentRatingsResponse.kt new file mode 100644 index 00000000..a9782740 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/ContentRatingsResponse.kt @@ -0,0 +1,19 @@ +package tech.salroid.filmy.data.local.model + +import com.google.gson.annotations.SerializedName + +data class ContentRatingsResponse( + @SerializedName("id") + var id: Int? = null, + + @SerializedName("results") + var results: List = emptyList(), +) + +data class ContentRating( + @SerializedName("iso_3166_1") + var iso31661: String? = null, + + @SerializedName("rating") + var rating: String? = null, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/ExternalIdsResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/ExternalIdsResponse.kt new file mode 100644 index 00000000..366d70d5 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/ExternalIdsResponse.kt @@ -0,0 +1,17 @@ +package tech.salroid.filmy.data.local.model + +import com.google.gson.annotations.SerializedName + +data class ExternalIdsResponse( + @SerializedName("imdb_id") + var imdbId: String? = null, + + @SerializedName("facebook_id") + var facebookId: String? = null, + + @SerializedName("instagram_id") + var instagramId: String? = null, + + @SerializedName("twitter_id") + var twitterId: String? = null +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/ImagesResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/ImagesResponse.kt new file mode 100644 index 00000000..79c543e8 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/ImagesResponse.kt @@ -0,0 +1,19 @@ +package tech.salroid.filmy.data.local.model + +import com.google.gson.annotations.SerializedName + +data class ImagesResponse( + @SerializedName("backdrops") + var backdrops: List = emptyList(), + + @SerializedName("posters") + var posters: List = emptyList() +) + +data class ImageItem( + @SerializedName("file_path") + var filePath: String? = null, + + @SerializedName("vote_average") + var voteAverage: Double? = null +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/Keyword.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/Keyword.kt new file mode 100644 index 00000000..8ba60858 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/Keyword.kt @@ -0,0 +1,11 @@ +package tech.salroid.filmy.data.local.model + +import com.google.gson.annotations.SerializedName + +data class Keyword( + @SerializedName("id") + var id: Int, + + @SerializedName("name") + var name: String? = null +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/MovieKeywordsResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/MovieKeywordsResponse.kt new file mode 100644 index 00000000..dda6ea35 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/MovieKeywordsResponse.kt @@ -0,0 +1,8 @@ +package tech.salroid.filmy.data.local.model + +import com.google.gson.annotations.SerializedName + +data class MovieKeywordsResponse( + @SerializedName("keywords") + var keywords: List = emptyList() +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/Person.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/Person.kt new file mode 100644 index 00000000..00b03b8f --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/Person.kt @@ -0,0 +1,48 @@ +package tech.salroid.filmy.data.local.model + +import com.google.gson.annotations.SerializedName + +data class Person( + @SerializedName("id") + var id: Int, + + @SerializedName("name") + var name: String? = null, + + @SerializedName("profile_path") + var profilePath: String? = null, + + @SerializedName("known_for_department") + var knownForDepartment: String? = null, + + @SerializedName("popularity") + var popularity: Double? = null, + + @SerializedName("known_for") + var knownFor: List = emptyList() +) + +data class KnownForItem( + @SerializedName("id") + var id: Int? = null, + + @SerializedName(value = "title", alternate = ["name"]) + var title: String? = null, + + @SerializedName("media_type") + var mediaType: String? = null +) + +data class PeopleResponse( + @SerializedName("page") + var page: Int? = null, + + @SerializedName("results") + var results: List = emptyList(), + + @SerializedName("total_pages") + var totalPages: Int? = null, + + @SerializedName("total_results") + var totalResults: Int? = null +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/ReleaseDatesResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/ReleaseDatesResponse.kt new file mode 100644 index 00000000..b0365712 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/ReleaseDatesResponse.kt @@ -0,0 +1,27 @@ +package tech.salroid.filmy.data.local.model + +import com.google.gson.annotations.SerializedName + +data class ReleaseDatesResponse( + @SerializedName("id") + var id: Int? = null, + + @SerializedName("results") + var results: List = emptyList(), +) + +data class CountryReleaseDates( + @SerializedName("iso_3166_1") + var iso31661: String? = null, + + @SerializedName("release_dates") + var releaseDates: List = emptyList(), +) + +data class ReleaseDateEntry( + @SerializedName("certification") + var certification: String? = null, + + @SerializedName("type") + var type: Int? = null, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/SearchResult.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/SearchResult.kt index 1b0fa79f..26dcbdfa 100644 --- a/app/src/main/java/tech/salroid/filmy/data/local/model/SearchResult.kt +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/SearchResult.kt @@ -14,7 +14,7 @@ data class SearchResult( var genreIds: ArrayList = arrayListOf(), @SerializedName("id") - var id: Int? = null, + var id: Int, @SerializedName("original_language") var originalLanguage: String? = null, @@ -44,5 +44,17 @@ data class SearchResult( var voteAverage: Double? = null, @SerializedName("vote_count") - var voteCount: Int? = null + var voteCount: Int? = null, + + @SerializedName("media_type") + var mediaType: String? = null, + + @SerializedName("name") + var name: String? = null, + + @SerializedName("first_air_date") + var firstAirDate: String? = null, + + @SerializedName("profile_path") + var profilePath: String? = null ) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/TvKeywordsResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/TvKeywordsResponse.kt new file mode 100644 index 00000000..43ce8745 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/TvKeywordsResponse.kt @@ -0,0 +1,8 @@ +package tech.salroid.filmy.data.local.model + +import com.google.gson.annotations.SerializedName + +data class TvKeywordsResponse( + @SerializedName("results") + var results: List = emptyList() +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/TvShow.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/TvShow.kt index 96de2e2d..8a6f24e5 100644 --- a/app/src/main/java/tech/salroid/filmy/data/local/model/TvShow.kt +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/TvShow.kt @@ -11,7 +11,7 @@ data class TvShow( var popularity: Double? = null, @SerializedName("id") - var id: Int? = null, + var id: Int, @SerializedName("backdrop_path") var backdropPath: String? = null, diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/Videos.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/Videos.kt new file mode 100644 index 00000000..300c2f85 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/Videos.kt @@ -0,0 +1,23 @@ +package tech.salroid.filmy.data.local.model + +import com.google.gson.annotations.SerializedName + +data class Videos( + @SerializedName("results") + val results: List = emptyList() +) + +data class VideoResult( + @SerializedName("id") + val id: String? = null, + @SerializedName("key") + val key: String? = null, + @SerializedName("name") + val name: String? = null, + @SerializedName("site") + val site: String? = null, + @SerializedName("size") + val size: Int? = null, + @SerializedName("type") + val type: String? = null +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/CreateListRequest.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/CreateListRequest.kt new file mode 100644 index 00000000..e5063f35 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/CreateListRequest.kt @@ -0,0 +1,14 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName + +data class CreateListRequest( + @SerializedName("name") + var name: String, + + @SerializedName("description") + var description: String = "", + + @SerializedName("language") + var language: String = "en", +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/CreateListResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/CreateListResponse.kt new file mode 100644 index 00000000..c60f1c35 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/CreateListResponse.kt @@ -0,0 +1,17 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName + +data class CreateListResponse( + @SerializedName("list_id") + var listId: Int? = null, + + @SerializedName("success") + var success: Boolean? = null, + + @SerializedName("status_code") + var statusCode: Int? = null, + + @SerializedName("status_message") + var statusMessage: String? = null, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/FavoriteRequest.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/FavoriteRequest.kt new file mode 100644 index 00000000..f0216634 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/FavoriteRequest.kt @@ -0,0 +1,14 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName + +data class FavoriteRequest( + @SerializedName("media_type") + var mediaType: String, + + @SerializedName("media_id") + var mediaId: Int, + + @SerializedName("favorite") + var favorite: Boolean, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/ListItemRequest.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/ListItemRequest.kt new file mode 100644 index 00000000..a8a4ad40 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/ListItemRequest.kt @@ -0,0 +1,8 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName + +data class ListItemRequest( + @SerializedName("media_id") + var mediaId: Int, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/RatedResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/RatedResponse.kt new file mode 100644 index 00000000..8552082f --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/RatedResponse.kt @@ -0,0 +1,22 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName + +data class RatedResponse( + @SerializedName("page") + var page: Int? = null, + + @SerializedName("results") + var results: List = emptyList(), + + @SerializedName("total_pages") + var totalPages: Int? = null, +) + +data class RatedItem( + @SerializedName("id") + var id: Int, + + @SerializedName("rating") + var rating: Float? = null, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/RatingRequest.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/RatingRequest.kt new file mode 100644 index 00000000..c0e7dbc8 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/RatingRequest.kt @@ -0,0 +1,8 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName + +data class RatingRequest( + @SerializedName("value") + var value: Float +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbList.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbList.kt new file mode 100644 index 00000000..05bc49b8 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbList.kt @@ -0,0 +1,20 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName + +data class TmdbList( + @SerializedName("id") + var id: Int, + + @SerializedName("name") + var name: String? = null, + + @SerializedName("description") + var description: String? = null, + + @SerializedName("item_count") + var itemCount: Int? = null, + + @SerializedName("poster_path") + var posterPath: String? = null, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbListDetailsResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbListDetailsResponse.kt new file mode 100644 index 00000000..87ddf956 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbListDetailsResponse.kt @@ -0,0 +1,24 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName +import tech.salroid.filmy.data.local.db.entity.Movie + +data class TmdbListDetailsResponse( + @SerializedName("id") + var id: Int? = null, + + @SerializedName("name") + var name: String? = null, + + @SerializedName("description") + var description: String? = null, + + @SerializedName("items") + var items: List = emptyList(), + + @SerializedName("page") + var page: Int? = null, + + @SerializedName("total_pages") + var totalPages: Int? = null, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbListsResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbListsResponse.kt new file mode 100644 index 00000000..f41d2651 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbListsResponse.kt @@ -0,0 +1,17 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName + +data class TmdbListsResponse( + @SerializedName("page") + var page: Int? = null, + + @SerializedName("results") + var results: List = emptyList(), + + @SerializedName("total_pages") + var totalPages: Int? = null, + + @SerializedName("total_results") + var totalResults: Int? = null, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbStatusResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbStatusResponse.kt new file mode 100644 index 00000000..09583cd1 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/TmdbStatusResponse.kt @@ -0,0 +1,11 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName + +data class TmdbStatusResponse( + @SerializedName("status_code") + var statusCode: Int? = null, + + @SerializedName("status_message") + var statusMessage: String? = null, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/account/WatchlistRequest.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/account/WatchlistRequest.kt new file mode 100644 index 00000000..2edec1f2 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/account/WatchlistRequest.kt @@ -0,0 +1,14 @@ +package tech.salroid.filmy.data.local.model.account + +import com.google.gson.annotations.SerializedName + +data class WatchlistRequest( + @SerializedName("media_type") + var mediaType: String, + + @SerializedName("media_id") + var mediaId: Int, + + @SerializedName("watchlist") + var watchlist: Boolean, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/collection/CollectionDetailsResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/collection/CollectionDetailsResponse.kt new file mode 100644 index 00000000..b87da6eb --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/collection/CollectionDetailsResponse.kt @@ -0,0 +1,23 @@ +package tech.salroid.filmy.data.local.model.collection + +import com.google.gson.annotations.SerializedName + +data class CollectionDetailsResponse( + @SerializedName("id") + var id: Int? = null, + + @SerializedName("name") + var name: String? = null, + + @SerializedName("overview") + var overview: String? = null, + + @SerializedName("poster_path") + var posterPath: String? = null, + + @SerializedName("backdrop_path") + var backdropPath: String? = null, + + @SerializedName("parts") + var parts: List = emptyList(), +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/collection/CollectionMovie.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/collection/CollectionMovie.kt new file mode 100644 index 00000000..dd37826d --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/collection/CollectionMovie.kt @@ -0,0 +1,17 @@ +package tech.salroid.filmy.data.local.model.collection + +import com.google.gson.annotations.SerializedName + +data class CollectionMovie( + @SerializedName("id") + var id: Int, + + @SerializedName("name") + var title: String? = null, + + @SerializedName("poster_path") + var posterPath: String? = null, + + @SerializedName("release_date") + var releaseDate: String? = null, +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/discover/DiscoverFilters.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/discover/DiscoverFilters.kt new file mode 100644 index 00000000..439f0cb2 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/discover/DiscoverFilters.kt @@ -0,0 +1,10 @@ +package tech.salroid.filmy.data.local.model.discover + +data class DiscoverFilters( + val genreIds: Set = emptySet(), + val year: Int? = null, + val minRating: Float? = null, + val sortBy: DiscoverSort = DiscoverSort.POPULARITY_DESC, + val keywordId: Int? = null, + val keywordName: String? = null +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/discover/DiscoverSort.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/discover/DiscoverSort.kt new file mode 100644 index 00000000..a6143081 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/discover/DiscoverSort.kt @@ -0,0 +1,8 @@ +package tech.salroid.filmy.data.local.model.discover + +enum class DiscoverSort(val apiValue: String) { + POPULARITY_DESC("popularity.desc"), + VOTE_AVERAGE_DESC("vote_average.desc"), + RELEASE_DATE_DESC("primary_release_date.desc"), + RELEASE_DATE_ASC("primary_release_date.asc") +} diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/discover/GenreResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/discover/GenreResponse.kt new file mode 100644 index 00000000..33786f4d --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/discover/GenreResponse.kt @@ -0,0 +1,9 @@ +package tech.salroid.filmy.data.local.model.discover + +import com.google.gson.annotations.SerializedName +import tech.salroid.filmy.data.local.model.Genre + +data class GenreResponse( + @SerializedName("genres") + var genres: List = emptyList() +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/tv/Episode.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/tv/Episode.kt new file mode 100644 index 00000000..126ae2fb --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/tv/Episode.kt @@ -0,0 +1,33 @@ +package tech.salroid.filmy.data.local.model.tv + +import com.google.gson.annotations.SerializedName + +data class Episode( + + @SerializedName("id") + var id: Int? = null, + + @SerializedName("name") + var name: String? = null, + + @SerializedName("overview") + var overview: String? = null, + + @SerializedName("still_path") + var stillPath: String? = null, + + @SerializedName("air_date") + var airDate: String? = null, + + @SerializedName("episode_number") + var episodeNumber: Int? = null, + + @SerializedName("season_number") + var seasonNumber: Int? = null, + + @SerializedName("runtime") + var runtime: Int? = null, + + @SerializedName("vote_average") + var voteAverage: Double? = null +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/tv/SeasonDetailsResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/tv/SeasonDetailsResponse.kt new file mode 100644 index 00000000..dfedfbe1 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/tv/SeasonDetailsResponse.kt @@ -0,0 +1,24 @@ +package tech.salroid.filmy.data.local.model.tv + +import com.google.gson.annotations.SerializedName + +data class SeasonDetailsResponse( + + @SerializedName("id") + var id: Int? = null, + + @SerializedName("name") + var name: String? = null, + + @SerializedName("overview") + var overview: String? = null, + + @SerializedName("poster_path") + var posterPath: String? = null, + + @SerializedName("season_number") + var seasonNumber: Int? = null, + + @SerializedName("episodes") + var episodes: List = emptyList() +) diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/tv/TvDetails.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/tv/TvDetails.kt index 86f5633f..4f77863b 100644 --- a/app/src/main/java/tech/salroid/filmy/data/local/model/tv/TvDetails.kt +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/tv/TvDetails.kt @@ -5,6 +5,8 @@ import tech.salroid.filmy.data.local.model.Genre import tech.salroid.filmy.data.local.model.ProductionCompanies import tech.salroid.filmy.data.local.model.ProductionCountries import tech.salroid.filmy.data.local.model.SpokenLanguages +import tech.salroid.filmy.data.local.model.Trailers +import tech.salroid.filmy.data.local.model.Videos data class TvDetails( @@ -99,5 +101,11 @@ data class TvDetails( var voteAverage: Double? = null, @SerializedName("vote_count") - var voteCount: Int? = null + var voteCount: Int? = null, + + @SerializedName("trailers") + var trailers: Trailers? = Trailers(), + + @SerializedName("videos") + var videos: Videos? = Videos() ) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/watch_providers/WatchProviderResponse.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/watch_providers/WatchProviderResponse.kt index d8b17103..d622b2f3 100644 --- a/app/src/main/java/tech/salroid/filmy/data/local/model/watch_providers/WatchProviderResponse.kt +++ b/app/src/main/java/tech/salroid/filmy/data/local/model/watch_providers/WatchProviderResponse.kt @@ -8,5 +8,5 @@ data class WatchProviderResponse( var id: Int? = null, @SerializedName("results") - var results: WatchProviderResults? = WatchProviderResults() + var results: Map = emptyMap() ) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/local/model/watch_providers/WatchProviderResults.kt b/app/src/main/java/tech/salroid/filmy/data/local/model/watch_providers/WatchProviderResults.kt deleted file mode 100644 index 03766fbb..00000000 --- a/app/src/main/java/tech/salroid/filmy/data/local/model/watch_providers/WatchProviderResults.kt +++ /dev/null @@ -1,145 +0,0 @@ -package tech.salroid.filmy.data.local.model.watch_providers - -import com.google.gson.annotations.SerializedName -import tech.salroid.filmy.data.local.model.watch_providers.* - -data class WatchProviderResults( - - @SerializedName("AR") - var AR: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("AT") - var AT: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("AU") - var AU: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("BE") - var BE: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("BR") - var BR: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("CA") - var CA: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("CH") - var CH: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("CL") - var CL: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("CO") - var CO: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("CZ") - var CZ: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("DE") - var DE: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("DK") - var DK: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("EC") - var EC: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("EE") - var EE: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("ES") - var ES: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("FI") - var FI: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("FR") - var FR: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("GB") - var GB: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("GR") - var GR: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("HU") - var HU: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("ID") - var ID: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("IE") - var IE: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("IN") - var IN: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("IT") - var IT: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("JP") - var JP: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("KR") - var KR: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("LT") - var LT: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("LV") - var LV: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("MX") - var MX: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("MY") - var MY: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("NL") - var NL: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("NO") - var NO: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("NZ") - var NZ: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("PE") - var PE: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("PH") - var PH: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("PL") - var PL: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("PT") - var PT: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("RO") - var RO: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("RU") - var RU: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("SE") - var SE: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("SG") - var SG: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("TH") - var TH: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("TR") - var TR: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("US") - var US: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("VE") - var VE: WatchProviderCountry? = WatchProviderCountry(), - - @SerializedName("ZA") - var ZA: WatchProviderCountry? = WatchProviderCountry() -) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/model/MoviePreview.kt b/app/src/main/java/tech/salroid/filmy/data/model/MoviePreview.kt new file mode 100644 index 00000000..4cdcac23 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/model/MoviePreview.kt @@ -0,0 +1,11 @@ +package tech.salroid.filmy.data.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class MoviePreview( + val id: Int, + val title: String, + val posterUrl: String, + val readableReleaseDate: String +) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/model/SearchPreview.kt b/app/src/main/java/tech/salroid/filmy/data/model/SearchPreview.kt new file mode 100644 index 00000000..62fe2486 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/model/SearchPreview.kt @@ -0,0 +1,12 @@ +package tech.salroid.filmy.data.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class SearchPreview( + val id: Int, + val title: String, + val posterUrl: String, + val readableReleaseDate: String, + val mediaType: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/model/TvShowPreview.kt b/app/src/main/java/tech/salroid/filmy/data/model/TvShowPreview.kt new file mode 100644 index 00000000..58742686 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/data/model/TvShowPreview.kt @@ -0,0 +1,11 @@ +package tech.salroid.filmy.data.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class TvShowPreview( + val id: Int, + val title: String, + val posterUrl: String, + val firstAirReadableDate: String +) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/network/AccountApiHelper.kt b/app/src/main/java/tech/salroid/filmy/data/network/AccountApiHelper.kt index d0b849cb..c8e7bb52 100644 --- a/app/src/main/java/tech/salroid/filmy/data/network/AccountApiHelper.kt +++ b/app/src/main/java/tech/salroid/filmy/data/network/AccountApiHelper.kt @@ -2,6 +2,13 @@ package tech.salroid.filmy.data.network import kotlinx.coroutines.flow.Flow import tech.salroid.filmy.data.local.db.entity.Profile +import tech.salroid.filmy.data.local.model.MoviesResponse +import tech.salroid.filmy.data.local.model.TvShowResponse +import tech.salroid.filmy.data.local.model.account.CreateListResponse +import tech.salroid.filmy.data.local.model.account.RatedResponse +import tech.salroid.filmy.data.local.model.account.TmdbListDetailsResponse +import tech.salroid.filmy.data.local.model.account.TmdbListsResponse +import tech.salroid.filmy.data.local.model.account.TmdbStatusResponse import tech.salroid.filmy.data.local.model.login.* interface AccountApiHelper { @@ -10,4 +17,39 @@ interface AccountApiHelper { fun getSession(accessTokenData: AccessTokenData): Flow fun deleteSession(sessionId: String): Flow fun getProfile(sessionId: String): Flow + + fun markAsFavorite( + accountId: Int, + sessionId: String, + mediaType: String, + mediaId: Int, + favorite: Boolean + ): Flow + + fun markAsWatchlist( + accountId: Int, + sessionId: String, + mediaType: String, + mediaId: Int, + watchlist: Boolean + ): Flow + + fun getFavoriteMovies(accountId: Int, sessionId: String, page: Int): Flow + fun getFavoriteTv(accountId: Int, sessionId: String, page: Int): Flow + fun getWatchlistMovies(accountId: Int, sessionId: String, page: Int): Flow + fun getWatchlistTv(accountId: Int, sessionId: String, page: Int): Flow + + fun createList(sessionId: String, name: String, description: String): Flow + fun getLists(accountId: Int, sessionId: String, page: Int): Flow + fun getListDetails(listId: Int, sessionId: String, page: Int): Flow + fun addToList(listId: Int, sessionId: String, mediaId: Int): Flow + fun removeFromList(listId: Int, sessionId: String, mediaId: Int): Flow + fun deleteList(listId: Int, sessionId: String): Flow + + fun rateMovie(movieId: Int, sessionId: String, value: Float): Flow + fun deleteMovieRating(movieId: Int, sessionId: String): Flow + fun rateTv(tvId: Int, sessionId: String, value: Float): Flow + fun deleteTvRating(tvId: Int, sessionId: String): Flow + fun getRatedMovies(accountId: Int, sessionId: String, page: Int): Flow + fun getRatedTv(accountId: Int, sessionId: String, page: Int): Flow } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/network/AccountApiHelperImpl.kt b/app/src/main/java/tech/salroid/filmy/data/network/AccountApiHelperImpl.kt index 6ed65e5d..540403bd 100644 --- a/app/src/main/java/tech/salroid/filmy/data/network/AccountApiHelperImpl.kt +++ b/app/src/main/java/tech/salroid/filmy/data/network/AccountApiHelperImpl.kt @@ -3,6 +3,18 @@ package tech.salroid.filmy.data.network import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import tech.salroid.filmy.data.local.db.entity.Profile +import tech.salroid.filmy.data.local.model.MoviesResponse +import tech.salroid.filmy.data.local.model.TvShowResponse +import tech.salroid.filmy.data.local.model.account.CreateListRequest +import tech.salroid.filmy.data.local.model.account.CreateListResponse +import tech.salroid.filmy.data.local.model.account.FavoriteRequest +import tech.salroid.filmy.data.local.model.account.ListItemRequest +import tech.salroid.filmy.data.local.model.account.RatedResponse +import tech.salroid.filmy.data.local.model.account.RatingRequest +import tech.salroid.filmy.data.local.model.account.TmdbListDetailsResponse +import tech.salroid.filmy.data.local.model.account.TmdbListsResponse +import tech.salroid.filmy.data.local.model.account.TmdbStatusResponse +import tech.salroid.filmy.data.local.model.account.WatchlistRequest import tech.salroid.filmy.data.local.model.login.* class AccountApiHelperImpl(private val accountApiService: AccountApiService) : AccountApiHelper { @@ -24,11 +36,130 @@ class AccountApiHelperImpl(private val accountApiService: AccountApiService) : A override fun deleteSession(sessionId: String): Flow = flow { - emit(accountApiService.deleteSession(sessionId)) + emit(accountApiService.deleteSession(DeleteSession(sessionId = sessionId))) } override fun getProfile(sessionId: String): Flow = flow { emit(accountApiService.getProfile(sessionId)) } + + override fun markAsFavorite( + accountId: Int, + sessionId: String, + mediaType: String, + mediaId: Int, + favorite: Boolean + ): Flow = + flow { + emit( + accountApiService.markAsFavorite( + accountId, + sessionId, + FavoriteRequest(mediaType = mediaType, mediaId = mediaId, favorite = favorite) + ) + ) + } + + override fun markAsWatchlist( + accountId: Int, + sessionId: String, + mediaType: String, + mediaId: Int, + watchlist: Boolean + ): Flow = + flow { + emit( + accountApiService.markAsWatchlist( + accountId, + sessionId, + WatchlistRequest(mediaType = mediaType, mediaId = mediaId, watchlist = watchlist) + ) + ) + } + + override fun getFavoriteMovies(accountId: Int, sessionId: String, page: Int): Flow = + flow { + emit(accountApiService.getFavoriteMovies(accountId, sessionId, page)) + } + + override fun getFavoriteTv(accountId: Int, sessionId: String, page: Int): Flow = + flow { + emit(accountApiService.getFavoriteTv(accountId, sessionId, page)) + } + + override fun getWatchlistMovies(accountId: Int, sessionId: String, page: Int): Flow = + flow { + emit(accountApiService.getWatchlistMovies(accountId, sessionId, page)) + } + + override fun getWatchlistTv(accountId: Int, sessionId: String, page: Int): Flow = + flow { + emit(accountApiService.getWatchlistTv(accountId, sessionId, page)) + } + + override fun createList(sessionId: String, name: String, description: String): Flow = + flow { + emit( + accountApiService.createList( + sessionId, + CreateListRequest(name = name, description = description) + ) + ) + } + + override fun getLists(accountId: Int, sessionId: String, page: Int): Flow = + flow { + emit(accountApiService.getLists(accountId, sessionId, page)) + } + + override fun getListDetails(listId: Int, sessionId: String, page: Int): Flow = + flow { + emit(accountApiService.getListDetails(listId, sessionId, page)) + } + + override fun addToList(listId: Int, sessionId: String, mediaId: Int): Flow = + flow { + emit(accountApiService.addToList(listId, sessionId, ListItemRequest(mediaId = mediaId))) + } + + override fun removeFromList(listId: Int, sessionId: String, mediaId: Int): Flow = + flow { + emit(accountApiService.removeFromList(listId, sessionId, ListItemRequest(mediaId = mediaId))) + } + + override fun deleteList(listId: Int, sessionId: String): Flow = + flow { + emit(accountApiService.deleteList(listId, sessionId)) + } + + override fun rateMovie(movieId: Int, sessionId: String, value: Float): Flow = + flow { + emit(accountApiService.rateMovie(movieId, sessionId, RatingRequest(value = value))) + } + + override fun deleteMovieRating(movieId: Int, sessionId: String): Flow = + flow { + emit(accountApiService.deleteMovieRating(movieId, sessionId)) + } + + override fun rateTv(tvId: Int, sessionId: String, value: Float): Flow = + flow { + emit(accountApiService.rateTv(tvId, sessionId, RatingRequest(value = value))) + } + + override fun deleteTvRating(tvId: Int, sessionId: String): Flow = + flow { + emit(accountApiService.deleteTvRating(tvId, sessionId)) + } + + override fun getRatedMovies(accountId: Int, sessionId: String, page: Int): Flow = + flow { + emit(accountApiService.getRatedMovies(accountId, sessionId, page)) + } + + override fun getRatedTv(accountId: Int, sessionId: String, page: Int): Flow = + flow { + emit(accountApiService.getRatedTv(accountId, sessionId, page)) + } } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/network/AccountApiService.kt b/app/src/main/java/tech/salroid/filmy/data/network/AccountApiService.kt index 85ab6854..d8b14d6b 100644 --- a/app/src/main/java/tech/salroid/filmy/data/network/AccountApiService.kt +++ b/app/src/main/java/tech/salroid/filmy/data/network/AccountApiService.kt @@ -4,9 +4,21 @@ import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.Path import retrofit2.http.Query import tech.salroid.filmy.data.local.db.entity.Profile -import tech.salroid.filmy.data.local.model.* +import tech.salroid.filmy.data.local.model.MoviesResponse +import tech.salroid.filmy.data.local.model.TvShowResponse +import tech.salroid.filmy.data.local.model.account.CreateListRequest +import tech.salroid.filmy.data.local.model.account.CreateListResponse +import tech.salroid.filmy.data.local.model.account.FavoriteRequest +import tech.salroid.filmy.data.local.model.account.ListItemRequest +import tech.salroid.filmy.data.local.model.account.RatedResponse +import tech.salroid.filmy.data.local.model.account.RatingRequest +import tech.salroid.filmy.data.local.model.account.TmdbListDetailsResponse +import tech.salroid.filmy.data.local.model.account.TmdbListsResponse +import tech.salroid.filmy.data.local.model.account.TmdbStatusResponse +import tech.salroid.filmy.data.local.model.account.WatchlistRequest import tech.salroid.filmy.data.local.model.login.* interface AccountApiService { @@ -21,8 +33,130 @@ interface AccountApiService { suspend fun getSession(@Body request: AccessTokenData): SessionDataResponse @DELETE("authentication/session") - suspend fun deleteSession(@Query("session_id") sessionId: String): DeleteSession + suspend fun deleteSession(@Body request: DeleteSession): DeleteSession @GET("account") suspend fun getProfile(@Query("session_id") sessionId: String): Profile + + @POST("account/{account_id}/favorite") + suspend fun markAsFavorite( + @Path("account_id") accountId: Int, + @Query("session_id") sessionId: String, + @Body request: FavoriteRequest + ): TmdbStatusResponse + + @POST("account/{account_id}/watchlist") + suspend fun markAsWatchlist( + @Path("account_id") accountId: Int, + @Query("session_id") sessionId: String, + @Body request: WatchlistRequest + ): TmdbStatusResponse + + @GET("account/{account_id}/favorite/movies") + suspend fun getFavoriteMovies( + @Path("account_id") accountId: Int, + @Query("session_id") sessionId: String, + @Query("page") page: Int + ): MoviesResponse + + @GET("account/{account_id}/favorite/tv") + suspend fun getFavoriteTv( + @Path("account_id") accountId: Int, + @Query("session_id") sessionId: String, + @Query("page") page: Int + ): TvShowResponse + + @GET("account/{account_id}/watchlist/movies") + suspend fun getWatchlistMovies( + @Path("account_id") accountId: Int, + @Query("session_id") sessionId: String, + @Query("page") page: Int + ): MoviesResponse + + @GET("account/{account_id}/watchlist/tv") + suspend fun getWatchlistTv( + @Path("account_id") accountId: Int, + @Query("session_id") sessionId: String, + @Query("page") page: Int + ): TvShowResponse + + @POST("list") + suspend fun createList( + @Query("session_id") sessionId: String, + @Body request: CreateListRequest + ): CreateListResponse + + @GET("account/{account_id}/lists") + suspend fun getLists( + @Path("account_id") accountId: Int, + @Query("session_id") sessionId: String, + @Query("page") page: Int + ): TmdbListsResponse + + @GET("list/{list_id}") + suspend fun getListDetails( + @Path("list_id") listId: Int, + @Query("session_id") sessionId: String, + @Query("page") page: Int + ): TmdbListDetailsResponse + + @POST("list/{list_id}/add_item") + suspend fun addToList( + @Path("list_id") listId: Int, + @Query("session_id") sessionId: String, + @Body request: ListItemRequest + ): TmdbStatusResponse + + @POST("list/{list_id}/remove_item") + suspend fun removeFromList( + @Path("list_id") listId: Int, + @Query("session_id") sessionId: String, + @Body request: ListItemRequest + ): TmdbStatusResponse + + @DELETE("list/{list_id}") + suspend fun deleteList( + @Path("list_id") listId: Int, + @Query("session_id") sessionId: String + ): TmdbStatusResponse + + @POST("movie/{movie_id}/rating") + suspend fun rateMovie( + @Path("movie_id") movieId: Int, + @Query("session_id") sessionId: String, + @Body request: RatingRequest + ): TmdbStatusResponse + + @DELETE("movie/{movie_id}/rating") + suspend fun deleteMovieRating( + @Path("movie_id") movieId: Int, + @Query("session_id") sessionId: String + ): TmdbStatusResponse + + @POST("tv/{tv_id}/rating") + suspend fun rateTv( + @Path("tv_id") tvId: Int, + @Query("session_id") sessionId: String, + @Body request: RatingRequest + ): TmdbStatusResponse + + @DELETE("tv/{tv_id}/rating") + suspend fun deleteTvRating( + @Path("tv_id") tvId: Int, + @Query("session_id") sessionId: String + ): TmdbStatusResponse + + @GET("account/{account_id}/rated/movies") + suspend fun getRatedMovies( + @Path("account_id") accountId: Int, + @Query("session_id") sessionId: String, + @Query("page") page: Int + ): RatedResponse + + @GET("account/{account_id}/rated/tv") + suspend fun getRatedTv( + @Path("account_id") accountId: Int, + @Query("session_id") sessionId: String, + @Query("page") page: Int + ): RatedResponse } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiHelper.kt b/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiHelper.kt index f843866f..a5b78fb6 100644 --- a/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiHelper.kt +++ b/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiHelper.kt @@ -5,12 +5,27 @@ import kotlinx.coroutines.flow.Flow import tech.salroid.filmy.data.local.db.entity.Movie import tech.salroid.filmy.data.local.db.entity.MovieDetails import tech.salroid.filmy.data.local.model.* +import tech.salroid.filmy.data.local.model.collection.CollectionDetailsResponse +import tech.salroid.filmy.data.local.model.discover.DiscoverFilters +import tech.salroid.filmy.data.local.model.discover.GenreResponse +import tech.salroid.filmy.data.local.model.tv.SeasonDetailsResponse import tech.salroid.filmy.data.local.model.tv.TvDetails import tech.salroid.filmy.data.local.model.watch_providers.WatchProviderResponse interface MoviesApiHelper { fun getMovies(type: String, isTrending: Boolean): Flow> + + fun getMoviesFlow(type: String, isTrending: Boolean): Flow + fun getTvShows(type: String, isTrending: Boolean): Flow> + + fun getTvShowsFlow(type: String, isTrending: Boolean): Flow + + fun discoverMovies(filters: DiscoverFilters): Flow> + fun discoverTv(filters: DiscoverFilters): Flow> + fun getMovieGenres(): Flow + fun getTvGenres(): Flow + fun getMovieDetails(id: String): Flow fun getTvShowDetails(id: String): Flow fun getOMDBRatings(id: String): Flow @@ -23,9 +38,24 @@ interface MoviesApiHelper { fun getCastCrewDetails(id: String): Flow fun getCastCrewMovies(id: String): Flow fun getCastCrewTvShows(id: String): Flow + fun getCombinedCredits(id: String): Flow + fun getPersonExternalIds(id: String): Flow fun searchMovies(query: String): Flow + fun searchMulti(query: String): Flow fun getReviews(id: String): Flow fun getTvReviews(id: String): Flow fun getWatchProviders(id: String): Flow fun getWatchProvidersTv(id: String): Flow + fun getPeople(): Flow> + + fun getMovieCertification(id: String): Flow + fun getTvCertification(id: String): Flow + fun getCollectionDetails(id: Int): Flow + fun getSeasonDetails(tvId: String, seasonNumber: Int): Flow + fun getTvExternalIds(tvId: String): Flow + fun getMovieExternalIds(movieId: String): Flow + fun getMovieImages(movieId: String): Flow + fun getTvImages(tvId: String): Flow + fun getMovieKeywords(movieId: String): Flow> + fun getTvKeywords(tvId: String): Flow> } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiHelperImpl.kt b/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiHelperImpl.kt index 6ba89c5e..6da3f9cd 100644 --- a/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiHelperImpl.kt +++ b/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiHelperImpl.kt @@ -6,11 +6,18 @@ import androidx.paging.PagingData import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import tech.salroid.filmy.BuildConfig.OMDB_API_KEY +import tech.salroid.filmy.data.datasource.DiscoverMoviesPagingSource +import tech.salroid.filmy.data.datasource.DiscoverTvPagingSource import tech.salroid.filmy.data.datasource.MoviesPagingSource +import tech.salroid.filmy.data.datasource.PeoplePagingSource import tech.salroid.filmy.data.datasource.TvShowsPagingSource import tech.salroid.filmy.data.local.db.entity.Movie import tech.salroid.filmy.data.local.db.entity.MovieDetails import tech.salroid.filmy.data.local.model.* +import tech.salroid.filmy.data.local.model.collection.CollectionDetailsResponse +import tech.salroid.filmy.data.local.model.discover.DiscoverFilters +import tech.salroid.filmy.data.local.model.discover.GenreResponse +import tech.salroid.filmy.data.local.model.tv.SeasonDetailsResponse import tech.salroid.filmy.data.local.model.tv.TvDetails import tech.salroid.filmy.data.local.model.watch_providers.WatchProviderResponse import tech.salroid.filmy.data.network.MoviesApiService.Companion.BASE_URL_OMDB @@ -31,6 +38,13 @@ class MoviesApiHelperImpl(private val apiService: MoviesApiService) : MoviesApiH } ).flow + override fun getMoviesFlow( + type: String, + isTrending: Boolean + ): Flow = flow { + emit(apiService.getTrendingMovies("day", 1)) + } + override fun getTvShows(type: String, isTrending: Boolean): Flow> = Pager( config = PagingConfig( pageSize = 20, @@ -45,8 +59,15 @@ class MoviesApiHelperImpl(private val apiService: MoviesApiService) : MoviesApiH } ).flow + override fun getTvShowsFlow( + type: String, + isTrending: Boolean + ): Flow = flow { + emit(apiService.getTrendingTvShows("day", 1)) + } + override fun getMovieDetails(id: String): Flow = flow { - emit(apiService.getMovieDetails(id)) + emit(apiService.getMovieDetails(movieId = id)) } override fun getTvShowDetails(id: String): Flow = flow { @@ -95,10 +116,22 @@ class MoviesApiHelperImpl(private val apiService: MoviesApiService) : MoviesApiH emit(apiService.getCastCrewTvShows(id)) } + override fun getCombinedCredits(id: String): Flow = flow { + emit(apiService.getCombinedCredits(id)) + } + + override fun getPersonExternalIds(id: String): Flow = flow { + emit(apiService.getPersonExternalIds(id)) + } + override fun searchMovies(query: String): Flow = flow { emit(apiService.searchMovies(query)) } + override fun searchMulti(query: String): Flow = flow { + emit(apiService.searchMulti(query)) + } + override fun getReviews(id: String): Flow = flow { emit(apiService.getReviews(id)) } @@ -114,4 +147,82 @@ class MoviesApiHelperImpl(private val apiService: MoviesApiService) : MoviesApiH override fun getWatchProvidersTv(id: String): Flow = flow { emit(apiService.getWatchProvidersTv(id)) } + + override fun discoverMovies(filters: DiscoverFilters): Flow> = Pager( + config = PagingConfig( + pageSize = 20, + enablePlaceholders = false + ), + pagingSourceFactory = { + DiscoverMoviesPagingSource(apiService = apiService, filters = filters) + } + ).flow + + override fun discoverTv(filters: DiscoverFilters): Flow> = Pager( + config = PagingConfig( + pageSize = 20, + enablePlaceholders = false + ), + pagingSourceFactory = { + DiscoverTvPagingSource(apiService = apiService, filters = filters) + } + ).flow + + override fun getMovieGenres(): Flow = flow { + emit(apiService.getMovieGenres()) + } + + override fun getTvGenres(): Flow = flow { + emit(apiService.getTvGenres()) + } + + override fun getPeople(): Flow> = Pager( + config = PagingConfig( + pageSize = 20, + enablePlaceholders = false + ), + pagingSourceFactory = { + PeoplePagingSource(apiService = apiService) + } + ).flow + + override fun getMovieCertification(id: String): Flow = flow { + emit(apiService.getMovieReleaseDates(id)) + } + + override fun getTvCertification(id: String): Flow = flow { + emit(apiService.getTvContentRatings(id)) + } + + override fun getCollectionDetails(id: Int): Flow = flow { + emit(apiService.getCollectionDetails(id)) + } + + override fun getSeasonDetails(tvId: String, seasonNumber: Int): Flow = flow { + emit(apiService.getSeasonDetails(tvId, seasonNumber)) + } + + override fun getTvExternalIds(tvId: String): Flow = flow { + emit(apiService.getTvExternalIds(tvId)) + } + + override fun getMovieExternalIds(movieId: String): Flow = flow { + emit(apiService.getMovieExternalIds(movieId)) + } + + override fun getMovieImages(movieId: String): Flow = flow { + emit(apiService.getMovieImages(movieId)) + } + + override fun getTvImages(tvId: String): Flow = flow { + emit(apiService.getTvImages(tvId)) + } + + override fun getMovieKeywords(movieId: String): Flow> = flow { + emit(apiService.getMovieKeywords(movieId).keywords) + } + + override fun getTvKeywords(tvId: String): Flow> = flow { + emit(apiService.getTvKeywords(tvId).results) + } } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiService.kt b/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiService.kt index 37db2879..ae0a2e45 100644 --- a/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiService.kt +++ b/app/src/main/java/tech/salroid/filmy/data/network/MoviesApiService.kt @@ -6,6 +6,9 @@ import retrofit2.http.Query import retrofit2.http.Url import tech.salroid.filmy.data.local.db.entity.MovieDetails import tech.salroid.filmy.data.local.model.* +import tech.salroid.filmy.data.local.model.collection.CollectionDetailsResponse +import tech.salroid.filmy.data.local.model.discover.GenreResponse +import tech.salroid.filmy.data.local.model.tv.SeasonDetailsResponse import tech.salroid.filmy.data.local.model.tv.TvDetails import tech.salroid.filmy.data.local.model.watch_providers.WatchProviderResponse @@ -41,11 +44,17 @@ interface MoviesApiService { ): TvShowResponse - @GET("movie/{movie_id}?append_to_response=trailers") - suspend fun getMovieDetails(@Path("movie_id") movieId: String?): MovieDetails + @GET("movie/{movie_id}?append_to_response=videos") + suspend fun getMovieDetails( + @Path("movie_id") movieId: String?, + @Query("include_video_language") includeVideoLanguage: String = "en,null" + ): MovieDetails - @GET("tv/{show_id}?append_to_response=trailers") - suspend fun getTvShowDetails(@Path("show_id") showId: String?): TvDetails + @GET("tv/{show_id}?append_to_response=videos") + suspend fun getTvShowDetails( + @Path("show_id") showId: String?, + @Query("include_video_language") includeVideoLanguage: String = "en,null" + ): TvDetails @GET suspend fun getOMDBRatings( @@ -83,9 +92,18 @@ interface MoviesApiService { @GET("person/{person_id}/tv_credits") suspend fun getCastCrewTvShows(@Path("person_id") personId: String): CastCrewMoviesResponse + @GET("person/{person_id}/combined_credits") + suspend fun getCombinedCredits(@Path("person_id") personId: String): CombinedCreditsResponse + + @GET("person/{person_id}/external_ids") + suspend fun getPersonExternalIds(@Path("person_id") personId: String): ExternalIdsResponse + @GET("search/movie") suspend fun searchMovies(@Query("query") query: String): SearchResultResponse + @GET("search/multi") + suspend fun searchMulti(@Query("query") query: String): SearchResultResponse + @GET("movie/{movie_id}/reviews") suspend fun getReviews(@Path("movie_id") movieId: String): ReviewResponse @@ -97,4 +115,66 @@ interface MoviesApiService { @GET("tv/{tv_id}/watch/providers") suspend fun getWatchProvidersTv(@Path("tv_id") movieId: String): WatchProviderResponse + + @GET("genre/movie/list") + suspend fun getMovieGenres(): GenreResponse + + @GET("genre/tv/list") + suspend fun getTvGenres(): GenreResponse + + @GET("discover/movie") + suspend fun discoverMovies( + @Query("with_genres") withGenres: String?, + @Query("primary_release_year") year: Int?, + @Query("vote_average.gte") minRating: Float?, + @Query("with_keywords") withKeywords: String?, + @Query("sort_by") sortBy: String, + @Query("page") page: Int + ): MoviesResponse + + @GET("discover/tv") + suspend fun discoverTv( + @Query("with_genres") withGenres: String?, + @Query("first_air_date_year") year: Int?, + @Query("vote_average.gte") minRating: Float?, + @Query("with_keywords") withKeywords: String?, + @Query("sort_by") sortBy: String, + @Query("page") page: Int + ): TvShowResponse + + @GET("person/popular") + suspend fun getPopularPeople(@Query("page") page: Int): PeopleResponse + + @GET("movie/{movie_id}/release_dates") + suspend fun getMovieReleaseDates(@Path("movie_id") movieId: String): ReleaseDatesResponse + + @GET("tv/{tv_id}/content_ratings") + suspend fun getTvContentRatings(@Path("tv_id") tvId: String): ContentRatingsResponse + + @GET("collection/{collection_id}") + suspend fun getCollectionDetails(@Path("collection_id") collectionId: Int): CollectionDetailsResponse + + @GET("tv/{tv_id}/season/{season_number}") + suspend fun getSeasonDetails( + @Path("tv_id") tvId: String, + @Path("season_number") seasonNumber: Int + ): SeasonDetailsResponse + + @GET("tv/{tv_id}/external_ids") + suspend fun getTvExternalIds(@Path("tv_id") tvId: String): ExternalIdsResponse + + @GET("movie/{movie_id}/external_ids") + suspend fun getMovieExternalIds(@Path("movie_id") movieId: String): ExternalIdsResponse + + @GET("movie/{movie_id}/images") + suspend fun getMovieImages(@Path("movie_id") movieId: String): ImagesResponse + + @GET("tv/{tv_id}/images") + suspend fun getTvImages(@Path("tv_id") tvId: String): ImagesResponse + + @GET("movie/{movie_id}/keywords") + suspend fun getMovieKeywords(@Path("movie_id") movieId: String): MovieKeywordsResponse + + @GET("tv/{tv_id}/keywords") + suspend fun getTvKeywords(@Path("tv_id") tvId: String): TvKeywordsResponse } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/di/AppModule.kt b/app/src/main/java/tech/salroid/filmy/di/AppModule.kt index 3769515b..e4d8b733 100644 --- a/app/src/main/java/tech/salroid/filmy/di/AppModule.kt +++ b/app/src/main/java/tech/salroid/filmy/di/AppModule.kt @@ -10,6 +10,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -17,13 +18,14 @@ import retrofit2.converter.gson.GsonConverterFactory import tech.salroid.filmy.BuildConfig import tech.salroid.filmy.data.local.db.FilmyDatabase import tech.salroid.filmy.data.network.* +import tech.salroid.filmy.utility.PreferenceHelper @Module @InstallIn(SingletonComponent::class) object AppModule { @Provides - fun provideOkhttpClient(): OkHttpClient = OkHttpClient.Builder() + fun provideOkhttpClient(appPref: SharedPreferences): OkHttpClient = OkHttpClient.Builder() .addInterceptor { val original = it.request() val originalUrl = original.url @@ -45,6 +47,18 @@ object AppModule { } it.proceed(it.request().newBuilder().url(v4Url).build()) + }.addInterceptor { chain -> + val original = chain.request() + val request = if (original.url.host == "api.themoviedb.org") { + val country = appPref.getString(PreferenceHelper.COUNTRY_KEY, null) ?: "US" + original.newBuilder() + .url(original.url.newBuilder().addQueryParameter("region", country).build()) + .build() + } else { + original + } + chain.proceed(request) + }.addInterceptor(HttpLoggingInterceptor { Log.d("OkHttp", it) }.apply { @@ -79,12 +93,15 @@ object AppModule { } @Provides + @Singleton fun provideMoviesDatabase(@ApplicationContext appContext: Context): FilmyDatabase { return Room.databaseBuilder( appContext, FilmyDatabase::class.java, "filmy" - ).build() + ) + .fallbackToDestructiveMigration(true) + .build() } @Provides diff --git a/app/src/main/java/tech/salroid/filmy/ui/FilmyApp.kt b/app/src/main/java/tech/salroid/filmy/ui/FilmyApp.kt new file mode 100644 index 00000000..e6f91c3a --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/FilmyApp.kt @@ -0,0 +1,159 @@ +package tech.salroid.filmy.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.window.core.layout.WindowSizeClass +import kotlinx.coroutines.flow.distinctUntilChanged +import tech.salroid.filmy.core.navigation.AppNavHost +import tech.salroid.filmy.core.navigation.AppNavigationBar +import tech.salroid.filmy.core.navigation.AppRoute +import tech.salroid.filmy.core.navigation.TopLevelDestinations +import tech.salroid.filmy.ui.search.SearchViewModel +import tech.salroid.filmy.utility.PreferenceHelper + +@Composable +fun FilmyApp( + windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo( + supportLargeAndXLargeWidth = true + ).windowSizeClass, + searchViewModel: SearchViewModel = viewModel(), + throughShortcut: Boolean = false +) { + val navController = rememberNavController() + val snackBarHostState = remember { SnackbarHostState() } + val textFieldState = remember { TextFieldState() } + val context = LocalContext.current + val startDestination = remember { + when { + PreferenceHelper.isColdStart(context) -> AppRoute.Onboarding.route + throughShortcut -> AppRoute.CollectionGraph.route + else -> AppRoute.MoviesGraph.route + } + } + + val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() + val searchUiState by searchViewModel.uiState.collectAsStateWithLifecycle() + val recentSearches by searchViewModel.recentSearches.collectAsStateWithLifecycle() + + var isSearchExpanded by rememberSaveable { mutableStateOf(false) } + + // VM - UI + LaunchedEffect(searchQuery) { + if (textFieldState.text.toString() != searchQuery) { + textFieldState.setTextAndPlaceCursorAtEnd(searchQuery) + } + } + + // TextFieldState → VM + LaunchedEffect(textFieldState) { + snapshotFlow { textFieldState.text.toString() } + .distinctUntilChanged() + .collect(searchViewModel::onSearchQueryChange) + } + + CompositionLocalProvider( + LocalWindowSizeClass provides windowSizeClass + ) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + snackbarHost = { SnackbarHost(snackBarHostState) } + ) { paddingValues -> + Box(modifier = Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding()) + ) { + AppNavHost( + navController = navController, + textFieldState = textFieldState, + searchUiState = searchUiState, + isSearchExpanded = isSearchExpanded, + onSearchExpandedChange = { isSearchExpanded = it }, + onSearch = { + searchViewModel.onSearchQueryChange(it) + searchViewModel.commitSearch(it) + }, + recentSearches = recentSearches, + onRecentSearchClick = { searchViewModel.onSearchQueryChange(it) }, + onRemoveRecentSearch = { searchViewModel.removeRecentSearch(it) }, + onClearRecentSearches = { searchViewModel.clearRecentSearches() }, + bottomPadding = 80.dp, // Height of NavigationBar + modifier = Modifier.fillMaxSize(), + startDestination = startDestination + ) + + AnimatedVisibility( + visible = shouldShowNavigationBar(navController), + enter = fadeIn(animationSpec = ChromeMotionSpec) + slideInVertically( + animationSpec = ChromeOffsetMotionSpec, + initialOffsetY = { it } + ), + exit = fadeOut(animationSpec = ChromeMotionSpec) + slideOutVertically( + animationSpec = ChromeOffsetMotionSpec, + targetOffsetY = { it } + ), + modifier = Modifier.align(Alignment.BottomCenter) + ) { + AppNavigationBar(navController) + } + } + } + } +} + +@Composable +private fun shouldShowNavigationBar(navController: NavController): Boolean { + val backStackEntry by navController.currentBackStackEntryAsState() + val destinationRoute = backStackEntry?.destination?.route + + return TopLevelDestinations + .any { it.startDestinationRoute == destinationRoute } +} + +val LocalWindowSizeClass = staticCompositionLocalOf { + error("WindowSizeClass not provided") +} + +private val ChromeMotionSpec = tween( + durationMillis = 500, + easing = FastOutSlowInEasing +) + +private val ChromeOffsetMotionSpec = tween( + durationMillis = 500, + easing = FastOutSlowInEasing +) diff --git a/app/src/main/java/tech/salroid/filmy/ui/account/AccountScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/account/AccountScreen.kt new file mode 100644 index 00000000..3f27077c --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/account/AccountScreen.kt @@ -0,0 +1,573 @@ +package tech.salroid.filmy.ui.account + +import android.content.Context +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContract +import androidx.appcompat.app.AppCompatDelegate +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import coil3.compose.AsyncImage +import tech.salroid.filmy.BuildConfig +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.db.entity.Profile +import tech.salroid.filmy.ui.common.components.CountrySelectionList +import tech.salroid.filmy.ui.home.LoginViewModel +import tech.salroid.filmy.utility.FilmyUtility +import tech.salroid.filmy.utility.PreferenceHelper +import androidx.core.net.toUri +import androidx.compose.ui.tooling.preview.Preview +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun AccountScreen( + onAboutClick: () -> Unit, + onLicenseClick: () -> Unit, + onMyListsClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: LoginViewModel = hiltViewModel() +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val uiStateProfile by viewModel.uiStateProfile.collectAsState() + val uiStateToken by viewModel.uiStateToken.collectAsState() + val isLoggingOut by viewModel.isLoggingOut.collectAsState() + val isAuthenticating by viewModel.isAuthenticating.collectAsState() + + var showLogoutDialog by remember { mutableStateOf(false) } + + val customTabLauncher = rememberLauncherForActivityResult( + contract = object : ActivityResultContract() { + override fun createIntent(context: Context, input: String): Intent { + val customTabsIntent = CustomTabsIntent.Builder().build().intent + customTabsIntent.data = input.toUri() + return customTabsIntent + } + + override fun parseResult(resultCode: Int, intent: Intent?): Int = resultCode + } + ) { } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + if (viewModel.requestToken != null && viewModel.sessionId == null) { + viewModel.getAccessToken() + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect(uiStateToken) { + uiStateToken?.requestToken?.let { token -> + if (viewModel.accessToken == null) { + val url = "https://www.themoviedb.org/auth/access?request_token=$token" + customTabLauncher.launch(url) + } + } + } + + AccountScreenContent( + profile = uiStateProfile, + isLoading = isAuthenticating || isLoggingOut, + showLoginCard = true, + onLoginClick = { viewModel.getRequestToken() }, + onLogoutClick = { showLogoutDialog = true }, + onAboutClick = onAboutClick, + onLicenseClick = onLicenseClick, + onMyListsClick = onMyListsClick, + modifier = modifier + ) + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + text = { Text(stringResource(R.string.logout_confirmation)) }, + confirmButton = { + Button(onClick = { showLogoutDialog = false }) { + Text(stringResource(android.R.string.cancel)) + } + }, + dismissButton = { + TextButton( + onClick = { + showLogoutDialog = false + viewModel.logout() + } + ) { + Text(stringResource(R.string.yes)) + } + } + ) + } +} + +@Composable +fun AccountScreenContent( + profile: Profile?, + isLoading: Boolean, + showLoginCard: Boolean = false, + onLoginClick: () -> Unit, + onLogoutClick: () -> Unit, + onAboutClick: () -> Unit, + onLicenseClick: () -> Unit, + onMyListsClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + Column( + modifier = modifier + .fillMaxSize() + .statusBarsPadding() + .verticalScroll(rememberScrollState()) + ) { + if (showLoginCard) { + LoginCard( + profile = profile, + onLoginClick = onLoginClick, + onLogoutClick = onLogoutClick, + isLoading = isLoading + ) + } + + PreferencesSection( + context = context, + onAboutClick = onAboutClick, + onLicenseClick = onLicenseClick, + onMyListsClick = onMyListsClick + ) + } +} + +@Composable +fun PreferencesSection( + context: Context, + onAboutClick: () -> Unit, + onLicenseClick: () -> Unit, + onMyListsClick: () -> Unit = {} +) { + var showThemeDialog by remember { mutableStateOf(false) } + var showRegionDialog by remember { mutableStateOf(false) } + + val modeNightNo = stringResource(R.string.mode_night_no) + val modeNightYes = stringResource(R.string.mode_night_yes) + + val currentThemeMode = PreferenceHelper.getCurrentThemeMode(context) + val currentThemeSummary = when (currentThemeMode) { + modeNightNo -> stringResource(R.string.summary_light) + modeNightYes -> stringResource(R.string.summary_dark) + else -> stringResource(R.string.summary_system_default) + } + + var selectedCountry by remember { mutableStateOf(PreferenceHelper.getSelectedCountry(context)) } + val currentRegionSummary = remember(selectedCountry) { + java.util.Locale("", selectedCountry).displayCountry + } + + Column(modifier = Modifier.padding(8.dp)) { + PreferenceItem( + title = stringResource(R.string.theme), + summary = currentThemeSummary, + icon = painterResource(R.drawable.dark_mode), + onClick = { showThemeDialog = true } + ) + PreferenceItem( + title = "Region", + summary = currentRegionSummary, + icon = painterResource(R.drawable.ic_language_24dp), + onClick = { showRegionDialog = true } + ) + PreferenceItem( + title = stringResource(R.string.my_lists), + icon = painterResource(R.drawable.ic_collections_bookmark_24dp), + onClick = onMyListsClick + ) + PreferenceItem( + title = stringResource(R.string.license), + icon = painterResource(R.drawable.ic_article), + onClick = onLicenseClick + ) + PreferenceItem( + title = stringResource(R.string.version), + summary = BuildConfig.VERSION_NAME, + icon = painterResource(R.drawable.ic_document), + onClick = {} + ) + PreferenceItem( + title = stringResource(R.string.shareappdetails), + icon = painterResource(R.drawable.twotone_share_24), + onClick = { FilmyUtility.startSharingIntent(context) } + ) + PreferenceItem( + title = stringResource(R.string.about), + icon = painterResource(R.drawable.ic_face), + onClick = onAboutClick + ) + } + + if (showThemeDialog) { + ThemeSelectionDialog( + currentThemeMode = currentThemeMode, + onDismiss = { showThemeDialog = false }, + onThemeSelected = { selectedValue -> + PreferenceHelper.setThemeMode(context, selectedValue) + val nightMode = when (selectedValue) { + modeNightNo -> AppCompatDelegate.MODE_NIGHT_NO + modeNightYes -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + AppCompatDelegate.setDefaultNightMode(nightMode) + showThemeDialog = false + } + ) + } + + if (showRegionDialog) { + RegionSelectionDialog( + selectedCountry = selectedCountry, + onDismiss = { showRegionDialog = false }, + onCountrySelected = { code -> + PreferenceHelper.setSelectedCountry(context, code) + selectedCountry = code + showRegionDialog = false + } + ) + } +} + +@Composable +fun RegionSelectionDialog( + selectedCountry: String, + onDismiss: () -> Unit, + onCountrySelected: (String) -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Region", + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + CountrySelectionList( + selected = selectedCountry, + onSelect = { onCountrySelected(it.code) }, + modifier = Modifier.heightIn(max = 400.dp) + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +@Composable +fun PreferenceItem( + title: String, + summary: String? = null, + icon: Painter? = null, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(16.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.alpha(0.6f) + ) + } + } + } +} + +@Composable +fun ThemeSelectionDialog( + currentThemeMode: String?, + onDismiss: () -> Unit, + onThemeSelected: (String) -> Unit +) { + val entries = stringArrayResource(R.array.themeEntries) + val values = stringArrayResource(R.array.themeValues) + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.theme), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Column(Modifier.selectableGroup()) { + entries.forEachIndexed { index, item -> + val value = values[index] + Row( + Modifier + .fillMaxWidth() + .height(56.dp) + .selectable( + selected = (value == currentThemeMode), + onClick = { onThemeSelected(value) }, + role = Role.RadioButton + ) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (value == currentThemeMode), + onClick = null + ) + Text( + text = item, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +@Composable +fun LoginCard( + profile: Profile?, + onLoginClick: () -> Unit, + onLogoutClick: () -> Unit, + isLoading: Boolean, + modifier: Modifier = Modifier +) { + ElevatedCard( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp) + .dropShadow( + shape = RoundedCornerShape(12.dp), + shadow = Shadow( + radius = 16.dp, + spread = 0.dp, + offset = DpOffset(0.dp, 4.dp), + alpha = 0.06f, + color = MaterialTheme.colorScheme.scrim + ) + ), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val avatarUrl = profile?.let { + it.avatar?.tmdb?.avatarPath?.let { path -> + stringResource(R.string.member_profile_url, path) + } ?: it.avatar?.gravatar?.getCompleteUrl() + } + + AsyncImage( + model = avatarUrl, + contentDescription = null, + placeholder = painterResource(R.drawable.default_avatar), + error = painterResource(R.drawable.default_avatar), + modifier = Modifier + .size(62.dp) + .padding(4.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1.0f) + ) { + Text( + text = profile?.name ?: stringResource(R.string.login_message), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.alpha(0.9f) + ) + Text( + text = profile?.username ?: stringResource(R.string.login_message_benefit), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.alpha(0.6f) + ) + + if (profile == null) { + Button( + onClick = onLoginClick, + modifier = Modifier + .padding(top = 8.dp) + .width(100.dp), + contentPadding = PaddingValues(horizontal = 8.dp), + enabled = !isLoading + ) { + Text(text = stringResource(R.string.login_now)) + } + } + } + + if (profile != null) { + IconButton( + onClick = onLogoutClick, + enabled = !isLoading + ) { + Icon( + painter = painterResource(R.drawable.ic_logout), + contentDescription = "Logout", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + if (isLoading) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun AccountScreenPreview() { + AppTheme { + AccountScreenContent( + profile = Profile( + name = "John Doe", + username = "johndoe123" + ), + isLoading = false, + showLoginCard = true, + onLoginClick = {}, + onLogoutClick = {}, + onAboutClick = {}, + onLicenseClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun AccountScreenLoadingPreview() { + AppTheme { + AccountScreenContent( + profile = null, + isLoading = true, + showLoginCard = true, + onLoginClick = {}, + onLogoutClick = {}, + onAboutClick = {}, + onLicenseClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun AccountScreenLoggedOutPreview() { + AppTheme { + AccountScreenContent( + profile = null, + isLoading = false, + showLoginCard = true, + onLoginClick = {}, + onLogoutClick = {}, + onAboutClick = {}, + onLicenseClick = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/adapters/CastCrewAdapter.kt b/app/src/main/java/tech/salroid/filmy/ui/adapters/CastCrewAdapter.kt index b86dc79d..741a5fa3 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/adapters/CastCrewAdapter.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/adapters/CastCrewAdapter.kt @@ -44,6 +44,7 @@ class CastCrewAdapter( castCrew.cast.profilePath } + is CastCrew.CrewData -> { binding.memberName.text = castCrew.crew.name binding.memberDescription.text = castCrew.crew.job diff --git a/app/src/main/java/tech/salroid/filmy/ui/adapters/CollectionsPagerAdapter.kt b/app/src/main/java/tech/salroid/filmy/ui/adapters/CollectionsPagerAdapter.kt index 53aa87c4..a7c36ad9 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/adapters/CollectionsPagerAdapter.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/adapters/CollectionsPagerAdapter.kt @@ -9,7 +9,7 @@ class CollectionsPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragmen override fun getItemCount(): Int = 2 override fun createFragment(position: Int): Fragment = CollectionTypeFragment.newInstance( - if (position == 0) CollectionTypeFragment.CollectionType.FAVORITE else + if (position == 0) CollectionTypeFragment.CollectionType.WATCHED else CollectionTypeFragment.CollectionType.WATCHLIST ) } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/cast_crew/AllCastCrewScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/cast_crew/AllCastCrewScreen.kt new file mode 100644 index 00000000..e0132e92 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/cast_crew/AllCastCrewScreen.kt @@ -0,0 +1,193 @@ +package tech.salroid.filmy.ui.cast_crew + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.Cast +import tech.salroid.filmy.data.local.model.CastAndCrewResponse +import tech.salroid.filmy.data.local.model.Crew +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun AllCastCrewScreen( + id: Int, + isTv: Boolean, + title: String, + viewModel: CastCrewViewModel = hiltViewModel(), + onMemberClick: (Int) -> Unit, + onBackClick: () -> Unit +) { + val castAndCrew by viewModel.uiStateCastAndCrew.collectAsState() + + LaunchedEffect(id, isTv) { + if (isTv) { + viewModel.getCastAndCrewTv(id.toString()) + } else { + viewModel.getCastAndCrew(id.toString()) + } + } + + AllCastCrewScreenContent( + title = title, + castAndCrew = castAndCrew, + onMemberClick = onMemberClick, + onBackClick = onBackClick + ) +} + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class +) +@Composable +fun AllCastCrewScreenContent( + title: String, + castAndCrew: CastAndCrewResponse?, + onMemberClick: (Int) -> Unit, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + castAndCrew?.let { data -> + val allMembers = data.cast.map { + Member( + it.id ?: 0, + it.name ?: "", + it.character ?: "", + it.profilePath + ) + } + + data.crew.map { + Member( + it.id ?: 0, + it.name ?: "", + it.job ?: "", + it.profilePath + ) + } + + if (allMembers.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text("No cast or crew found.") + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(100.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.padding(paddingValues), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(allMembers) { member -> + MemberItem(member, onClick = { onMemberClick(member.id) }) + } + } + } + } ?: run { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + LoadingIndicator() + } + } + } +} + +data class Member(val id: Int, val name: String, val role: String, val profilePath: String?) + +@Composable +fun MemberItem(member: Member, onClick: () -> Unit) { + Column( + modifier = Modifier + .width(100.dp) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AsyncImage( + model = "https://image.tmdb.org/t/p/w185${member.profilePath}", + contentDescription = null, + modifier = Modifier + .size(70.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.default_avatar), + error = painterResource(R.drawable.default_avatar) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = member.name, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + Text( + text = member.role, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } +} + +@Preview(showBackground = true) +@Composable +fun AllCastCrewScreenPreview() { + val sampleCast = arrayListOf( + Cast(id = 1, name = "Dave Bautista", character = "Eric", profilePath = null), + Cast(id = 2, name = "John Cena", character = "Peacemaker", profilePath = null), + Cast(id = 3, name = "The Rock", character = "Black Adam", profilePath = null) + ) + val sampleCrew = arrayListOf( + Crew(id = 4, name = "Christopher Nolan", job = "Director", profilePath = null), + Crew(id = 5, name = "Hans Zimmer", job = "Composer", profilePath = null) + ) + val sampleData = CastAndCrewResponse(id = 1, cast = sampleCast, crew = sampleCrew) + + AppTheme { + AllCastCrewScreenContent( + title = "Inception", + castAndCrew = sampleData, + onMemberClick = {}, + onBackClick = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/cast_crew/CastCrewDetailsScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/cast_crew/CastCrewDetailsScreen.kt new file mode 100644 index 00000000..e406c58c --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/cast_crew/CastCrewDetailsScreen.kt @@ -0,0 +1,351 @@ +package tech.salroid.filmy.ui.cast_crew + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.CastCrewDetailsResponse +import tech.salroid.filmy.data.local.model.CombinedCredit +import tech.salroid.filmy.data.local.model.CombinedCreditsResponse +import tech.salroid.filmy.data.local.model.ExternalIdsResponse +import tech.salroid.filmy.ui.common.components.ErrorWidget +import tech.salroid.filmy.ui.common.components.PreviewItem +import tech.salroid.filmy.ui.movies.details.components.ExternalLinksSection +import tech.salroid.filmy.ui.movies.details.components.FullReadSheet +import tech.salroid.filmy.ui.theme.AppTheme +import tech.salroid.filmy.utility.openUrl +import tech.salroid.filmy.utility.toReadableDate + +@Composable +fun CastCrewDetailsScreen( + memberId: Int, + isTv: Boolean, + viewModel: CastCrewViewModel = hiltViewModel(), + onMovieClick: (Int, Boolean, String) -> Unit, + onViewAllMoviesClick: (Int, Boolean, String) -> Unit, + onBackClick: () -> Unit +) { + val details by viewModel.uiStateCastCrewDetails.collectAsStateWithLifecycle() + val combinedCredits by viewModel.uiStateCombinedCredits.collectAsStateWithLifecycle() + val externalIds by viewModel.uiStateExternalIds.collectAsStateWithLifecycle() + val isError by viewModel.uiStateError.collectAsStateWithLifecycle() + + fun load() { + viewModel.getCastCrewDetails(memberId.toString()) + viewModel.getCombinedCredits(memberId.toString()) + viewModel.getPersonExternalIds(memberId.toString()) + } + + LaunchedEffect(memberId) { + load() + } + + CastCrewDetailsContent( + details = details, + combinedCredits = combinedCredits, + externalIds = externalIds, + isError = isError, + onRetryClick = ::load, + memberId = memberId, + onMovieClick = onMovieClick, + onViewAllMoviesClick = onViewAllMoviesClick, + onBackClick = onBackClick + ) +} + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class +) +@Composable +fun CastCrewDetailsContent( + details: CastCrewDetailsResponse?, + combinedCredits: CombinedCreditsResponse?, + externalIds: ExternalIdsResponse?, + isError: Boolean, + onRetryClick: () -> Unit, + memberId: Int, + onMovieClick: (Int, Boolean, String) -> Unit, + onViewAllMoviesClick: (Int, Boolean, String) -> Unit, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + CastCrewDetailsBody( + paddingValues = paddingValues, + details = details, + combinedCredits = combinedCredits, + externalIds = externalIds, + isError = isError, + onRetryClick = onRetryClick, + memberId = memberId, + onMovieClick = onMovieClick, + onViewAllMoviesClick = onViewAllMoviesClick, + onBackClick = onBackClick + ) + } +} + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class +) +@Composable +fun CastCrewDetailsBody( + paddingValues: PaddingValues, + details: CastCrewDetailsResponse?, + combinedCredits: CombinedCreditsResponse?, + externalIds: ExternalIdsResponse?, + isError: Boolean = false, + onRetryClick: () -> Unit = {}, + memberId: Int, + onMovieClick: (Int, Boolean, String) -> Unit, + onViewAllMoviesClick: (Int, Boolean, String) -> Unit, + onBackClick: () -> Unit = {} +) { + var showFullBiography by remember { mutableStateOf(false) } + val context = LocalContext.current + + if (details == null && isError) { + ErrorWidget( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + message = "Couldn't load this person. Check your connection.", + onRetryClick = onRetryClick + ) + return + } + + details?.let { member -> + // A person can have both cast and crew credits (e.g. an actor who also + // directed) - de-duped by title+media type, cast role preferred over + // crew job when both exist for the same title, sorted by popularity so + // their most notable work leads. + val credits = remember(combinedCredits) { + val all = (combinedCredits?.cast.orEmpty() + combinedCredits?.crew.orEmpty()) + all.distinctBy { "${it.id}-${it.mediaType}" } + .sortedByDescending { it.popularity ?: 0.0 } + } + val movieCount = credits.count { !it.isTv } + val tvCount = credits.count { it.isTv } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AsyncImage( + model = "https://image.tmdb.org/t/p/w500${member.profilePath}", + contentDescription = null, + modifier = Modifier + .size(120.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.default_avatar), + error = painterResource(R.drawable.default_avatar) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = member.name ?: "", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + member.knownForDepartment?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + member.birthday?.let { + Text( + text = it.toReadableDate(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + member.placeOfBirth?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + if (!member.biography.isNullOrEmpty()) { + Text( + text = stringResource(R.string.details), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = member.biography ?: "", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Justify, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.clickable { showFullBiography = true } + ) + } + + if (showFullBiography) { + FullReadSheet( + title = member.name ?: "", + content = member.biography ?: "", + onDismiss = { showFullBiography = false } + ) + } + + if (credits.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Known For", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + credits.take(6).chunked(3).forEach { rowCredits -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + rowCredits.forEach { credit -> + MemberCreditItem( + credit = credit, + modifier = Modifier.weight(1f), + onClick = { onMovieClick(credit.id ?: 0, credit.isTv, credit.displayTitle ?: "") } + ) + } + repeat(3 - rowCredits.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + + if (movieCount > 5 || tvCount > 5) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + if (movieCount > 5) { + TextButton(onClick = { onViewAllMoviesClick(memberId, false, member.name ?: "") }) { + Text(stringResource(R.string.movies) + " (" + movieCount + ")") + } + } + if (tvCount > 5) { + TextButton(onClick = { onViewAllMoviesClick(memberId, true, member.name ?: "") }) { + Text(stringResource(R.string.tv_shows) + " (" + tvCount + ")") + } + } + } + } + } + + ExternalLinksSection( + homepage = member.homepage?.takeIf { it.isNotBlank() }, + imdbId = member.imdbId ?: externalIds?.imdbId, + facebookId = externalIds?.facebookId, + instagramId = externalIds?.instagramId, + twitterId = externalIds?.twitterId, + onLinkClick = { context.openUrl(it) } + ) + } + } ?: run { + PersonSkeletonLoader( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) + } +} + +@Composable +fun MemberCreditItem(credit: CombinedCredit, modifier: Modifier = Modifier, onClick: () -> Unit) { + Box(modifier = modifier) { + PreviewItem( + title = credit.displayTitle ?: "", + posterUrl = "https://image.tmdb.org/t/p/w342${credit.posterPath}", + readableDate = credit.displayDate?.toReadableDate() ?: "", + subtitle = credit.role, + onItemClick = onClick + ) + } +} + +@Preview(showBackground = true) +@Composable +fun CastCrewDetailsScreenPreview() { + val sampleDetails = CastCrewDetailsResponse( + name = "Brad Pitt", + profilePath = null, + birthday = "1963-12-18", + placeOfBirth = "Shawnee, Oklahoma, USA", + knownForDepartment = "Acting", + biography = "William Bradley Pitt is an American actor and film producer. He is the recipient of various accolades, including two Academy Awards, a British Academy Film Award, two Golden Globe Awards, and a Primetime Emmy Award." + ) + val sampleCredits = CombinedCreditsResponse( + cast = listOf( + CombinedCredit(id = 1, title = "Fight Club", posterPath = null, character = "Tyler Durden", mediaType = "movie", popularity = 90.0), + CombinedCredit(id = 2, title = "Seven", posterPath = null, character = "Detective Mills", mediaType = "movie", popularity = 80.0), + CombinedCredit(id = 3, title = "Inglourious Basterds", posterPath = null, character = "Lt. Aldo Raine", mediaType = "movie", popularity = 75.0) + ) + ) + AppTheme { + CastCrewDetailsBody( + paddingValues = PaddingValues(0.dp), + details = sampleDetails, + combinedCredits = sampleCredits, + externalIds = null, + memberId = 1, + onMovieClick = { _, _, _ -> }, + onViewAllMoviesClick = { _, _, _ -> } + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/cast_crew/CastCrewViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/cast_crew/CastCrewViewModel.kt index 9f6c5bd7..206f33cd 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/cast_crew/CastCrewViewModel.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/cast_crew/CastCrewViewModel.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.launch import tech.salroid.filmy.data.local.model.CastAndCrewResponse import tech.salroid.filmy.data.local.model.CastCrewDetailsResponse import tech.salroid.filmy.data.local.model.CastCrewMoviesResponse +import tech.salroid.filmy.data.local.model.CombinedCreditsResponse +import tech.salroid.filmy.data.local.model.ExternalIdsResponse import tech.salroid.filmy.ui.home.MoviesRepository import javax.inject.Inject @@ -20,11 +22,19 @@ class CastCrewViewModel @Inject constructor( private val _uiStateCastAndCrew = MutableStateFlow(null) private val _uiStateCastCrewDetails = MutableStateFlow(null) private val _uiStateCastCrewMovies = MutableStateFlow(null) + private val _uiStateCombinedCredits = MutableStateFlow(null) + private val _uiStateExternalIds = MutableStateFlow(null) + private val _uiStateError = MutableStateFlow(false) val uiStateCastAndCrew: StateFlow = _uiStateCastAndCrew.asStateFlow() val uiStateCastCrewDetails: StateFlow = _uiStateCastCrewDetails.asStateFlow() val uiStateCastCrewMovies: StateFlow = _uiStateCastCrewMovies.asStateFlow() + val uiStateCombinedCredits: StateFlow = + _uiStateCombinedCredits.asStateFlow() + val uiStateExternalIds: StateFlow = _uiStateExternalIds.asStateFlow() + /** True when the core person details fetch failed - there's no local cache to fall back on. */ + val uiStateError: StateFlow = _uiStateError.asStateFlow() fun getCastAndCrew(movieId: String) { viewModelScope.launch { @@ -51,11 +61,13 @@ class CastCrewViewModel @Inject constructor( } fun getCastCrewDetails(memberId: String) { + _uiStateError.value = false viewModelScope.launch { moviesRepository.getCastCrewDetails(memberId) .flowOn(Dispatchers.IO) .catch { it.printStackTrace() + _uiStateError.emit(true) }.collect { castCrewDetails -> _uiStateCastCrewDetails.emit(castCrewDetails) } @@ -85,4 +97,28 @@ class CastCrewViewModel @Inject constructor( } } } -} \ No newline at end of file + + fun getCombinedCredits(memberId: String) { + viewModelScope.launch { + moviesRepository.getCombinedCredits(memberId) + .flowOn(Dispatchers.IO) + .catch { + it.printStackTrace() + }.collect { combinedCredits -> + _uiStateCombinedCredits.emit(combinedCredits) + } + } + } + + fun getPersonExternalIds(memberId: String) { + viewModelScope.launch { + moviesRepository.getPersonExternalIds(memberId) + .flowOn(Dispatchers.IO) + .catch { + it.printStackTrace() + }.collect { externalIds -> + _uiStateExternalIds.emit(externalIds) + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/cast_crew/PersonSkeletonLoader.kt b/app/src/main/java/tech/salroid/filmy/ui/cast_crew/PersonSkeletonLoader.kt new file mode 100644 index 00000000..cb87d5e7 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/cast_crew/PersonSkeletonLoader.kt @@ -0,0 +1,109 @@ +package tech.salroid.filmy.ui.cast_crew + +import androidx.compose.animation.core.FastOutSlowInEasing +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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun PersonSkeletonLoader(modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "person_shimmer") + val alpha by transition.animateFloat( + initialValue = 0.04f, + targetValue = 0.12f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + val shimmerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .background(shimmerColor) + ) + Spacer(modifier = Modifier.height(16.dp)) + Spacer( + modifier = Modifier + .width(160.dp) + .height(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerColor) + ) + Spacer(modifier = Modifier.height(8.dp)) + Spacer( + modifier = Modifier + .width(120.dp) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerColor) + ) + Spacer(modifier = Modifier.height(24.dp)) + repeat(3) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerColor) + ) + Spacer(modifier = Modifier.height(8.dp)) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + repeat(3) { + Spacer( + modifier = Modifier + .weight(1f) + .height(170.dp) + .clip(RoundedCornerShape(8.dp)) + .background(shimmerColor) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PersonSkeletonLoaderPreview() { + AppTheme { + PersonSkeletonLoader() + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionScreen.kt new file mode 100644 index 00000000..db129a4e --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionScreen.kt @@ -0,0 +1,246 @@ +package tech.salroid.filmy.ui.collections + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.db.entity.MovieDetails +import tech.salroid.filmy.ui.collections.components.CollectionItem +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun CollectionScreen( + modifier: Modifier = Modifier, + viewModel: CollectionsViewModel = hiltViewModel(), + onMovieClick: (Int, Int) -> Unit, +) { + val watched by viewModel.watched.collectAsStateWithLifecycle() + val watchlist by viewModel.watchlist.collectAsStateWithLifecycle() + val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.trySync() + } + + var showDialog by remember { mutableStateOf(false) } + var selectedMovie by remember { mutableStateOf(null) } + val pagerState = rememberPagerState(pageCount = { 2 }) + + if (showDialog && selectedMovie != null) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { + Text( + stringResource(R.string.remove_from_collection), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + stringResource( + if (pagerState.currentPage == 0) R.string.remove_fav_message + else R.string.remove_watchlist_message + ) + ) + }, + confirmButton = { + TextButton(onClick = { + val movie = selectedMovie + if (movie != null) { + if (pagerState.currentPage == 0) { + viewModel.removeWatched(movie) + } else { + viewModel.removeWatchlist(movie) + } + } + showDialog = false + }) { + Text(stringResource(R.string.remove)) + } + }, + dismissButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + CollectionScreenContent( + modifier = modifier, + watched = watched, + watchlist = watchlist, + isSyncing = isSyncing, + pagerState = pagerState, + onMovieClick = onMovieClick, + onMovieLongClick = { movie -> + selectedMovie = movie + showDialog = true + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CollectionScreenContent( + watched: List, + watchlist: List, + pagerState: androidx.compose.foundation.pager.PagerState, + onMovieClick: (Int, Int) -> Unit, + modifier: Modifier = Modifier, + isSyncing: Boolean = false, + onMovieLongClick: (MovieDetails) -> Unit = {} +) { + val tabs = listOf(stringResource(R.string.favourite), stringResource(R.string.watchlist)) + val coroutineScope = rememberCoroutineScope() + + Column( + modifier = modifier + .fillMaxSize() + .statusBarsPadding() + ) { + PrimaryTabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + indicator = { + TabRowDefaults.PrimaryIndicator( + modifier = Modifier.tabIndicatorOffset(pagerState.currentPage), + width = 120.dp, + shape = RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp) + ) + }, + divider = {} + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { + Text( + title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (pagerState.currentPage == index) { + FontWeight.Bold + } else { + FontWeight.Medium + } + ) + }, + icon = { + Icon( + painter = painterResource(if (index == 0) R.drawable.ic_check else R.drawable.ic_round_bookmark_added_24), + contentDescription = title + ) + }, + selectedContentColor = MaterialTheme.colorScheme.primary, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + AnimatedVisibility(visible = isSyncing, enter = fadeIn(), exit = fadeOut()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.syncing_tmdb_account), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { page -> + val listToDisplay = if (page == 0) watched else watchlist + val currentList = remember(listToDisplay) { listToDisplay.reversed() } + + val emptyMessage = if (page == 0) + stringResource(R.string.your_fav_movies_appear_here) + else + stringResource(R.string.your_watchlist_movies_appear_here) + + if (currentList.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = emptyMessage, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(32.dp) + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + items(currentList, key = { "${it.id}_${it.type}" }) { movie -> + CollectionItem( + movie, + onClick = { onMovieClick(movie.id, movie.type) }, + onLongClick = { onMovieLongClick(movie) } + ) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun CollectionScreenPreview() { + val pagerState = rememberPagerState(pageCount = { 2 }) + AppTheme { + CollectionScreenContent( + watched = listOf( + MovieDetails( + id = 1, + title = "SpiderMan: India?", + posterPath = "/path.jpg", + releaseDate = "2023-01-22", + overview = "Sample overview" + ) + ), + watchlist = emptyList(), + pagerState = pagerState, + onMovieClick = { _, _ -> } + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionTypeFragment.kt b/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionTypeFragment.kt index 29c4b2e8..42667b3e 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionTypeFragment.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionTypeFragment.kt @@ -31,10 +31,10 @@ class CollectionTypeFragment : Fragment() { private var adapter: CollectionsAdapter? = null private var _binding: FragmentCollectionMoviesBinding? = null private val binding get() = _binding!! - private var currentCollectionType = CollectionType.FAVORITE + private var currentCollectionType = CollectionType.WATCHED enum class CollectionType { - FAVORITE, + WATCHED, WATCHLIST } @@ -71,29 +71,21 @@ class CollectionTypeFragment : Fragment() { override fun onResume() { super.onResume() - viewModel.getFavorites() - viewModel.getWatchLists() + //viewModel.getFavorites() + //viewModel.getWatchLists() } private fun collectUiStates() { lifecycleScope.launch { when (currentCollectionType) { - CollectionType.FAVORITE -> { + CollectionType.WATCHED -> { viewModel.uiStateFavorites.collect { - it?.let { - showMovies(it) - } ?: run { - binding.emptyContainer.isVisible = true - } + showMovies(it) } } CollectionType.WATCHLIST -> { viewModel.uiStateWatchlist.collect { - it?.let { - showMovies(it) - } ?: run { - binding.emptyContainerWatch.isVisible = true - } + showMovies(it) } } } @@ -104,7 +96,7 @@ class CollectionTypeFragment : Fragment() { adapter?.submitList(movies.reversed()) when (currentCollectionType) { - CollectionType.FAVORITE -> binding.emptyContainer.isVisible = movies.isEmpty() + CollectionType.WATCHED -> binding.emptyContainer.isVisible = movies.isEmpty() CollectionType.WATCHLIST -> binding.emptyContainerWatch.isVisible = movies.isEmpty() } } @@ -126,7 +118,7 @@ class CollectionTypeFragment : Fragment() { MaterialAlertDialogBuilder(requireContext()).run { setAdapter(adapter) { _: DialogInterface?, _: Int -> when (currentCollectionType) { - CollectionType.FAVORITE -> movie.favorite = false + CollectionType.WATCHED -> movie.watched = false CollectionType.WATCHLIST -> movie.watchlist = false } viewModel.updateMovieDetailsInDb(movie, position, currentCollectionType) diff --git a/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionViewModel.kt new file mode 100644 index 00000000..b777f659 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionViewModel.kt @@ -0,0 +1,3 @@ +package tech.salroid.filmy.ui.collections + +// Redundant file. Use CollectionsViewModel instead. diff --git a/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionsFragment.kt b/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionsFragment.kt index 7fbfb1e9..162f2eb7 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionsFragment.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionsFragment.kt @@ -37,7 +37,7 @@ class CollectionsFragment : Fragment() { 0 -> { tab.text = getString(R.string.favorite) tab.icon = - ContextCompat.getDrawable(requireContext(), R.drawable.ic_round_favorite_24) + ContextCompat.getDrawable(requireContext(), R.drawable.ic_check) } 1 -> { tab.text = getString(R.string.watchlist) diff --git a/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionsViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionsViewModel.kt index 108d5216..0c381371 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionsViewModel.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/collections/CollectionsViewModel.kt @@ -7,34 +7,85 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import tech.salroid.filmy.data.local.db.entity.MovieDetails +import tech.salroid.filmy.ui.home.AccountSyncRepository import tech.salroid.filmy.ui.home.MoviesRepository import javax.inject.Inject @HiltViewModel class CollectionsViewModel @Inject constructor( - private val moviesRepository: MoviesRepository + private val moviesRepository: MoviesRepository, + private val accountSyncRepository: AccountSyncRepository ) : ViewModel() { - private val _uiStateFavorite = MutableStateFlow?>(null) - private val _uiStateWatchlist = MutableStateFlow?>(null) + private val _isSyncing = MutableStateFlow(false) + val isSyncing: StateFlow = _isSyncing.asStateFlow() - val uiStateFavorites: StateFlow?> = _uiStateFavorite.asStateFlow() - val uiStateWatchlist: StateFlow?> = _uiStateWatchlist.asStateFlow() + val watched: StateFlow> = moviesRepository.getWatched() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) - fun getFavorites() { + val watchlist: StateFlow> = moviesRepository.getWatchlist() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList() + ) + + /** + * Called from the screen every time it becomes visible (not just once per + * ViewModel lifetime) - this ViewModel is preserved across bottom-nav tab + * switches (saveState/restoreState), so relying on `init` here would mean a + * pre-login visit "uses up" the one-time trigger and a post-login revisit + * would never actually sync. [AccountSyncRepository.syncIfNeeded] itself still + * only does real work once per app process, so calling this repeatedly is safe. + */ + fun trySync() { viewModelScope.launch(Dispatchers.IO) { - val movies = moviesRepository.getFavorites() - if (movies.isNotEmpty()) { - _uiStateFavorite.emit(movies) - } + _isSyncing.emit(true) + accountSyncRepository.syncIfNeeded() + _isSyncing.emit(false) } } + // Legacy support for Fragments + val uiStateFavorites: StateFlow> = watched + val uiStateWatchlist: StateFlow> = watchlist + + fun getWatchedList() { + // No-op, now reactive via watched StateFlow + } + fun getWatchLists() { + // No-op, now reactive via watchlist StateFlow + } + + /** + * Optimistically removes [movie] from Watched locally, then pushes the change + * to TMDB. If that push fails (while logged in), the removal is rolled back + * so local state doesn't drift from the real TMDB account. + */ + fun removeWatched(movie: MovieDetails) { viewModelScope.launch(Dispatchers.IO) { - val movies = moviesRepository.getWatchlist() - if (movies.isNotEmpty()) { - _uiStateWatchlist.emit(movies) + val updated = movie.copy(watched = false) + moviesRepository.addMovieDetailsToLocal(updated) + val pushed = accountSyncRepository.pushItemState(updated) + if (!pushed) { + moviesRepository.addMovieDetailsToLocal(movie) + } + } + } + + /** Same as [removeWatched], but for Watchlist. */ + fun removeWatchlist(movie: MovieDetails) { + viewModelScope.launch(Dispatchers.IO) { + val updated = movie.copy(watchlist = false) + moviesRepository.addMovieDetailsToLocal(updated) + val pushed = accountSyncRepository.pushItemState(updated) + if (!pushed) { + moviesRepository.addMovieDetailsToLocal(movie) } } } @@ -46,19 +97,6 @@ class CollectionsViewModel @Inject constructor( ) { viewModelScope.launch(Dispatchers.IO) { moviesRepository.updateMovieDetails(movie) - - when (currentCollectionType) { - CollectionTypeFragment.CollectionType.FAVORITE -> { - val currentList = uiStateFavorites.value?.toMutableList() ?: mutableListOf() - currentList.removeAt(position) - _uiStateFavorite.emit(currentList) - } - CollectionTypeFragment.CollectionType.WATCHLIST -> { - val currentList = uiStateWatchlist.value?.toMutableList() ?: mutableListOf() - currentList.removeAt(position) - _uiStateWatchlist.emit(currentList) - } - } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/collections/components/CollectionItem.kt b/app/src/main/java/tech/salroid/filmy/ui/collections/components/CollectionItem.kt new file mode 100644 index 00000000..0a6e9d22 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/collections/components/CollectionItem.kt @@ -0,0 +1,171 @@ +package tech.salroid.filmy.ui.collections.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import tech.salroid.filmy.utility.toReadableDate +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.db.entity.MovieDetails +import androidx.compose.ui.tooling.preview.Preview +import tech.salroid.filmy.ui.theme.AppTheme + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CollectionItem( + movie: MovieDetails, + onClick: () -> Unit, + onLongClick: () -> Unit = {} +) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .dropShadow( + shape = RoundedCornerShape(12.dp), + shadow = Shadow( + radius = 16.dp, + spread = 0.dp, + offset = DpOffset(0.dp, 4.dp), + alpha = 0.06f, + color = MaterialTheme.colorScheme.scrim + ) + ) + .clip(RoundedCornerShape(12.dp)) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) { + AsyncImage( + model = stringResource(R.string.movie_poster_url, movie.posterPath ?: ""), + contentDescription = movie.title, + modifier = Modifier + .width(105.dp) + .height(150.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.movie_skeleton), + error = painterResource(R.drawable.poster_error_placeholder) + ) + Column( + modifier = Modifier + .padding(start = 16.dp) + .fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 4.dp) + ) { + Text( + modifier = Modifier.weight(1f, fill = false), + text = movie.title ?: "", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (movie.type == 1) { + Spacer(modifier = Modifier.width(8.dp)) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "SHOW", + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + Text( + text = movie.releaseDate?.toReadableDate() ?: "", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 2.dp) + ) + Text( + text = movie.overview ?: "", + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 10.dp) + .alpha(0.7f) + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun CollectionItemPreview() { + AppTheme { + CollectionItem( + movie = MovieDetails( + id = 1, + title = "SpiderMan: India?", + posterPath = "", + releaseDate = "2023-01-22", + overview = "A small, wealthy family in New York City gets progressively torn apart by secrets, lies, and the theft that orchestrates all of it." + ), + onClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun CollectionItemTvPreview() { + AppTheme { + CollectionItem( + movie = MovieDetails( + id = 1, + title = "Breaking Bad", + posterPath = "", + releaseDate = "2008-01-20", + overview = "High school chemistry teacher Walter White is diagnosed with inoperable lung cancer. He turns to manufacturing and selling methamphetamine in order to secure his family's financial future.", + type = 1 + ), + onClick = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/AppBranding.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/AppBranding.kt new file mode 100644 index 00000000..8d9af2ab --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/AppBranding.kt @@ -0,0 +1,62 @@ +package tech.salroid.filmy.ui.common.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import tech.salroid.filmy.R + +@Composable +fun AppBranding( + modifier: Modifier = Modifier, + trailingContent: @Composable () -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_movie_filter_24dp), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + modifier = Modifier.padding(top = 16.dp), + text = "Filmy", + style = MaterialTheme.typography.headlineSmall.copy( + fontSize = 21.sp, + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface + ) + } + trailingContent() + } +} + + +@Preview(showBackground = true) +@Composable +fun AppBrandingPreview() { + AppBranding() +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/CategorySelector.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/CategorySelector.kt new file mode 100644 index 00000000..544cea25 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/CategorySelector.kt @@ -0,0 +1,125 @@ +package tech.salroid.filmy.ui.common.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.data.local.db.entity.Movie +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun CategorySelector( + categories: List, + selected: T, + label: @Composable (T) -> String, + onSelected: (T) -> Unit, + modifier: Modifier = Modifier, + startExpanded: Boolean = false +) { + var expanded by remember { mutableStateOf(startExpanded) } + + Box(modifier = modifier) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable { expanded = true } + .padding( + horizontal = 10.dp, + vertical = 6.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label(selected), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + categories.forEach { category -> + DropdownMenuItem( + text = { Text(label(category)) }, + onClick = { + onSelected(category) + expanded = false + }, + trailingIcon = { + if (category == selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + } + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CategorySelectorPreview() { + AppTheme { + CategorySelector( + categories = Movie.MovieType.entries, + selected = Movie.MovieType.POPULAR, + label = { + it.name + .lowercase() + .replaceFirstChar(Char::uppercase) + .replace('_', ' ') + }, + onSelected = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun CategorySelectorExpandedPreview() { + AppTheme { + CategorySelector( + categories = Movie.MovieType.entries, + selected = Movie.MovieType.POPULAR, + label = { + it.name + .lowercase() + .replaceFirstChar(Char::uppercase) + .replace('_', ' ') + }, + onSelected = {}, + startExpanded = true + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/CountryPicker.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/CountryPicker.kt new file mode 100644 index 00000000..73228358 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/CountryPicker.kt @@ -0,0 +1,80 @@ +package tech.salroid.filmy.ui.common.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import java.util.Locale + +data class CountryOption(val code: String, val name: String) + +fun allCountryOptions(): List = Locale.getISOCountries() + .map { code -> CountryOption(code, Locale("", code).displayCountry) } + .filter { it.name.isNotBlank() } + .sortedBy { it.name } + +@Composable +fun CountrySelectionList( + selected: String, + onSelect: (CountryOption) -> Unit, + modifier: Modifier = Modifier +) { + val countries = remember { allCountryOptions() } + var query by remember { mutableStateOf("") } + val filtered = remember(query) { + if (query.isBlank()) countries else countries.filter { it.name.contains(query, ignoreCase = true) } + } + + Column(modifier = modifier) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text("Search country") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + LazyColumn(Modifier.selectableGroup()) { + items(filtered, key = { it.code }) { country -> + val isSelected = country.code == selected + Row( + Modifier + .fillMaxWidth() + .height(52.dp) + .selectable( + selected = isSelected, + onClick = { onSelect(country) }, + role = Role.RadioButton + ), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = isSelected, onClick = null) + Text( + text = country.name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/DetailsContent.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/DetailsContent.kt new file mode 100644 index 00000000..50d043b8 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/DetailsContent.kt @@ -0,0 +1,392 @@ +package tech.salroid.filmy.ui.common.components + +import android.app.Activity +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.LocalLifecycleOwner +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.common.model.DetailsActions +import tech.salroid.filmy.ui.common.model.MediaDetailsUiState +import tech.salroid.filmy.ui.common.model.PaletteColors +import tech.salroid.filmy.ui.movies.details.components.* +import tech.salroid.filmy.utility.themeSystemBars + +@Composable +fun DetailsContent( + state: MediaDetailsUiState, + actions: DetailsActions, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val activity = context as? Activity + val scrollState = rememberScrollState() + + val scrollThreshold = with(LocalDensity.current) { 200.dp.toPx() } + val visibilityModifier by remember { + derivedStateOf { + (1f - (scrollState.value / scrollThreshold)).coerceIn(0f, 1f) + } + } + + var paletteColors by remember(state.mediaId) { mutableStateOf(null) } + var showFullRead by remember { mutableStateOf(false) } + var showAllTrailers by remember { mutableStateOf(false) } + var fullReadContent by remember { mutableStateOf(Pair("", "")) } + + val iconColor = + if (visibilityModifier > 0.5f) Color.White else MaterialTheme.colorScheme.onSurface + + val isDark = isSystemInDarkTheme() + val defaultBackground = MaterialTheme.colorScheme.background + val scaffoldBackgroundColor = remember(paletteColors, isDark) { + val currentPaletteColors = paletteColors ?: return@remember defaultBackground + + val colorInt = if (isDark) { + currentPaletteColors.darkMutedRgb ?: currentPaletteColors.darkVibrantRgb + } else { + currentPaletteColors.lightMutedRgb ?: currentPaletteColors.lightVibrantRgb + } + + colorInt?.let { Color(it).copy(alpha = 0.15f).compositeOver(defaultBackground) } + ?: defaultBackground + } + + val containerColor = scaffoldBackgroundColor.copy(alpha = 1f - visibilityModifier) + + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(visibilityModifier, isDark) { + if (lifecycleOwner.lifecycle.currentState == Lifecycle.State.RESUMED) { + activity?.themeSystemBars( + lightStatusBar = visibilityModifier <= 0.5f, + isFullScreen = true, + transparentStatus = true + ) + } + } + + LifecycleResumeEffect(Unit) { + activity?.themeSystemBars( + lightStatusBar = visibilityModifier <= 0.5f, + isFullScreen = true, + transparentStatus = true + ) + onPauseOrDispose { + activity?.themeSystemBars( + lightStatusBar = !isDark, + isFullScreen = true, + transparentStatus = true + ) + } + } + + if (showFullRead) { + FullReadSheet( + title = fullReadContent.first, + content = fullReadContent.second, + onDismiss = { showFullRead = false } + ) + } + + if (showAllTrailers) { + AllTrailersSheet( + title = state.title, + trailers = state.youtubeTrailers ?: emptyList(), + onTrailerClick = actions.onTrailerClick, + onDismiss = { showAllTrailers = false } + ) + } + + DetailsScaffold( + state = state, + actions = actions, + scaffoldBackgroundColor = scaffoldBackgroundColor, + containerColor = containerColor, + iconColor = iconColor, + scrollState = scrollState, + paletteColors = paletteColors, + onPaletteGenerated = { paletteColors = it }, + onHeaderClick = { + fullReadContent = Pair(state.title, state.overview) + showFullRead = true + }, + onReviewClick = { title, content -> + fullReadContent = Pair(title, content) + showFullRead = true + }, + onAllTrailersClick = { showAllTrailers = true }, + modifier = modifier + ) +} + +@Composable +fun DetailsScaffold( + state: MediaDetailsUiState, + actions: DetailsActions, + scaffoldBackgroundColor: Color, + containerColor: Color, + iconColor: Color, + scrollState: ScrollState, + paletteColors: PaletteColors?, + onPaletteGenerated: (PaletteColors) -> Unit, + onHeaderClick: () -> Unit, + onReviewClick: (String, String) -> Unit, + onAllTrailersClick: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + containerColor = scaffoldBackgroundColor, + topBar = { + DetailsToolbar( + actions = actions, + iconColor = iconColor, + containerColor = containerColor, + onGalleryClick = { actions.onGalleryClick(state.mediaId, state.isTvShow) } + ) + } + ) { paddingValues -> + DetailsMainContent( + state = state, + actions = actions, + scrollState = scrollState, + paletteColors = paletteColors, + onPaletteGenerated = onPaletteGenerated, + onHeaderClick = onHeaderClick, + onReviewClick = onReviewClick, + onAllTrailersClick = onAllTrailersClick, + paddingValues = paddingValues, + modifier = modifier + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetailsToolbar( + actions: DetailsActions, + iconColor: Color, + containerColor: Color, + onGalleryClick: () -> Unit +) { + val isTransparent = containerColor.alpha < 0.5f + val buttonBackgroundColor = + if (isTransparent) Color.Black.copy(alpha = 0.35f) else Color.Transparent + + CenterAlignedTopAppBar( + title = { }, + navigationIcon = { + IconButton( + onClick = actions.onBackNavigation, + modifier = Modifier + .padding(start = 16.dp) + .size(32.dp) + .background(buttonBackgroundColor, CircleShape) + ) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = "Back", + tint = iconColor, + modifier = Modifier.size(24.dp) + ) + } + }, + actions = { + IconButton( + onClick = onGalleryClick, + modifier = Modifier + .padding(end = 16.dp) + .size(32.dp) + .background(buttonBackgroundColor, CircleShape) + ) { + Icon( + imageVector = Icons.Default.PhotoLibrary, + contentDescription = "Photos", + tint = iconColor, + modifier = Modifier.size(24.dp) + ) + } + IconButton( + onClick = actions.onShareClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .size(32.dp) + .background(buttonBackgroundColor, CircleShape) + ) { + Icon( + painterResource(R.drawable.twotone_share_24), + contentDescription = "Share", + tint = iconColor, + modifier = Modifier.size(24.dp) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + titleContentColor = iconColor, + navigationIconContentColor = iconColor, + actionIconContentColor = iconColor + ) + ) +} + +@Composable +fun DetailsMainContent( + state: MediaDetailsUiState, + actions: DetailsActions, + scrollState: ScrollState, + paletteColors: PaletteColors?, + onPaletteGenerated: (PaletteColors) -> Unit, + onHeaderClick: () -> Unit, + onReviewClick: (String, String) -> Unit, + onAllTrailersClick: () -> Unit, + paddingValues: PaddingValues, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(bottom = paddingValues.calculateBottomPadding()) + ) { + DetailsHeader( + state = state, + paletteColors = paletteColors, + onPaletteGenerated = onPaletteGenerated, + onHeaderClick = onHeaderClick + ) + + Column(modifier = Modifier.padding(16.dp)) { + + state.watchProviders?.let { + WatchProvidersSection(it, paletteColors) + } + + ActionsCard( + state = state, + actions = actions, + paletteColors = paletteColors + ) + + TrailersSection( + youtubeTrailers = state.youtubeTrailers, + paletteColors = paletteColors, + onTrailerClick = actions.onTrailerClick, + onPlusMoreClick = onAllTrailersClick + ) + + if (state.collectionId != null && state.collectionName != null) { + CollectionTeaserRow( + name = state.collectionName, + onClick = { + actions.onCollectionClick( + state.collectionId, + state.collectionName + ) + }, + paletteColors = paletteColors + ) + } + + if (state.ratings != null) { + RatingsSection(state.ratings, paletteColors) + } + + if (!state.awards.isNullOrBlank() || state.budget != null || state.revenue != null) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = rememberPaletteCardColor( + paletteColors = paletteColors, + fallback = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + if (!state.awards.isNullOrBlank()) { + Column(modifier = Modifier.weight(1f)) { + AwardsSection(state.awards) + } + } + if (state.budget != null || state.revenue != null) { + Column(modifier = Modifier.weight(1f)) { + BoxOfficeSection(state.budget, state.revenue) + } + } + } + } + } + + KeywordsSection(state.keywords, actions.onKeywordClick) + + if (state.isTvShow && !state.seasons.isNullOrEmpty()) { + SeasonsSection(state.seasons) { seasonNumber -> + actions.onSeasonClick(state.mediaId, seasonNumber, state.title) + } + } + + CastSection( + state.castAndCrew?.cast, + state.mediaId, + state.title, + state.isTvShow, + actions.onViewAllCastClick, + actions.onMemberClick + ) + CrewSection( + state.castAndCrew?.crew, + state.mediaId, + state.title, + state.isTvShow, + actions.onViewAllCastClick, + actions.onMemberClick + ) + ReviewsSection( + reviews = state.reviews, + onViewAllClick = { + actions.onViewAllReviewsClick(state.mediaId, state.isTvShow, state.title) + }, + onReviewClick = onReviewClick + ) + MediaSuggestionsSection( + title = if (state.isTvShow) "Similar Shows" else "Similar", + response = state.similarMedia, + onMediaClick = actions.onMediaClick + ) + MediaSuggestionsSection( + title = if (state.isTvShow) "Recommendations" else "Recommended", + response = state.recommendations, + onMediaClick = actions.onMediaClick + ) + + ExternalLinksSection( + homepage = state.homepage, + imdbId = state.imdbId, + facebookId = state.facebookId, + instagramId = state.instagramId, + twitterId = state.twitterId, + onLinkClick = actions.onLinkClick + ) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/DetailsSkeletonLoader.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/DetailsSkeletonLoader.kt new file mode 100644 index 00000000..a1fe8fa8 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/DetailsSkeletonLoader.kt @@ -0,0 +1,209 @@ +package tech.salroid.filmy.ui.common.components + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import android.app.Activity +import tech.salroid.filmy.ui.theme.AppTheme +import tech.salroid.filmy.utility.themeSystemBars + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DetailsSkeletonLoader( + modifier: Modifier = Modifier +) { + val transition = rememberInfiniteTransition(label = "shimmer") + val alpha by transition.animateFloat( + initialValue = 0.04f, + targetValue = 0.12f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + val shimmerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) + + val context = LocalContext.current + val activity = context as? Activity + + LaunchedEffect(Unit) { + activity?.themeSystemBars( + lightStatusBar = false, + isFullScreen = true, + transparentStatus = true + ) + } + + DisposableEffect(Unit) { + onDispose { + activity?.themeSystemBars( + lightStatusBar = true, + isFullScreen = false, + transparentStatus = false + ) + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { }, + modifier = modifier + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = paddingValues.calculateBottomPadding()) + ) { + Box( + modifier = Modifier.fillMaxWidth() + ) { + // Backdrop + /*Box( + modifier = Modifier + .fillMaxWidth() + .height(280.dp) + .background(shimmerColor) + )*/ + + // Info Card + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 220.dp) + .padding(horizontal = 8.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + ) { + Column( + modifier = Modifier + .padding(start = 156.dp, top = 16.dp, end = 16.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.8f) + .height(28.dp) + .background(shimmerColor, RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(12.dp)) + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(16.dp) + .background(shimmerColor, RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth(0.4f) + .height(16.dp) + .background(shimmerColor, RoundedCornerShape(4.dp)) + ) + } + Spacer(modifier = Modifier.height(32.dp)) + Box( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth(0.6f) + .height(20.dp) + .background(shimmerColor, RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(16.dp)) + repeat(4) { index -> + Box( + modifier = Modifier + .padding(horizontal = 20.dp) + .padding(top = 8.dp) + .fillMaxWidth(if (index == 3) 0.6f else 0.95f) + .height(14.dp) + .background(shimmerColor, RoundedCornerShape(4.dp)) + ) + } + } + } + + // Poster + Card( + modifier = Modifier + .padding(start = 24.dp, top = 135.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + shape = RoundedCornerShape(16.dp) + ) { + Box( + modifier = Modifier + .size(120.dp, 180.dp) + .background(shimmerColor) + ) + } + } + + // Below header items (Ratings, Cast, etc.) + Column(modifier = Modifier.padding(16.dp)) { + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = Modifier + .width(140.dp) + .height(20.dp) + .background(shimmerColor, RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + repeat(3) { + Box( + modifier = Modifier + .size(80.dp) + .background(shimmerColor, RoundedCornerShape(8.dp)) + ) + } + } + + Spacer(modifier = Modifier.height(40.dp)) + Box( + modifier = Modifier + .width(120.dp) + .height(20.dp) + .background(shimmerColor, RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + repeat(3) { + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(120f / 180f) + .background(shimmerColor, RoundedCornerShape(8.dp)) + ) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun DetailsSkeletonLoaderPreview() { + AppTheme { + DetailsSkeletonLoader() + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/ErrorWidget.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/ErrorWidget.kt new file mode 100644 index 00000000..a2f20927 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/ErrorWidget.kt @@ -0,0 +1,84 @@ +package tech.salroid.filmy.ui.common.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.R + +@Composable +fun ErrorWidget( + modifier: Modifier, + message: String, + onRetryClick: () -> Unit, + onBackClick: (() -> Unit)? = null +) { + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + message, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button( + modifier = Modifier.padding(top = 16.dp), + onClick = onRetryClick + ) { + Text( + "RETRY", + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold, + ), + ) + } + } + + if (onBackClick != null) { + IconButton( + onClick = onBackClick, + modifier = Modifier + .statusBarsPadding() + .padding(start = 16.dp, top = 16.dp) + .size(32.dp) + ) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = "Back", + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +@Composable +@Preview(showBackground = true) +fun ErrorWidgetPreview() { + ErrorWidget( + modifier = Modifier, + "Something went wrong! Check your internet connection also.", + onRetryClick = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/HomeTopBar.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/HomeTopBar.kt new file mode 100644 index 00000000..56fbfae0 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/HomeTopBar.kt @@ -0,0 +1,147 @@ +package tech.salroid.filmy.ui.common.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Constraints +import kotlinx.coroutines.launch +import tech.salroid.filmy.data.model.SearchPreview +import tech.salroid.filmy.ui.search.SearchScreenState +import tech.salroid.filmy.ui.search.component.AppSearchBar + +@Composable +fun HomeTopBar( + modifier: Modifier = Modifier, + textFieldState: TextFieldState, + searchUiState: SearchScreenState, + isSearchExpanded: Boolean, + onSearchExpandedChange: (Boolean) -> Unit, + onSearch: (String) -> Unit, + onSearchResultClick: (SearchPreview) -> Unit, + recentSearches: List = emptyList(), + onRecentSearchClick: (String) -> Unit = {}, + onRemoveRecentSearch: (String) -> Unit = {}, + onClearRecentSearches: () -> Unit = {}, + trailingContent: @Composable () -> Unit = {}, + content: @Composable ColumnScope.() -> Unit = {} +) { + var brandingHeightPx by remember { mutableFloatStateOf(0f) } + val collapseOffset = remember { Animatable(0f) } + val coroutineScope = rememberCoroutineScope() + + // Collapses/expands the branding row by consuming scroll delta before the + // list below sees it, so both animate together smoothly under the + // finger - scrolling down tucks it away, scrolling up brings it right + // back, while the search bar itself always stays pinned in place. + val nestedScrollConnection = remember(isSearchExpanded) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (isSearchExpanded) return Offset.Zero + + val heightOffsetLimit = -brandingHeightPx + val newOffset = (collapseOffset.value + available.y).coerceIn(heightOffsetLimit, 0f) + val consumed = newOffset - collapseOffset.value + if (consumed != 0f) { + coroutineScope.launch { collapseOffset.snapTo(newOffset) } + } + return Offset(0f, consumed) + } + } + } + + // Search taking over the screen already replaces the branding row's + // purpose - animate it fully out of the way while expanded, and restore + // it when collapsing back, matching the previous non-scroll behavior. + LaunchedEffect(isSearchExpanded, brandingHeightPx) { + collapseOffset.animateTo(if (isSearchExpanded) -brandingHeightPx else 0f) + } + + Column( + modifier = modifier + .fillMaxSize() + .statusBarsPadding() + .nestedScroll(nestedScrollConnection) + ) { + val collapsedHeightPx = if (brandingHeightPx == 0f) { + Float.MAX_VALUE + } else { + (brandingHeightPx + collapseOffset.value).coerceAtLeast(0f) + } + + CollapsibleHeader( + collapsedHeightPx = collapsedHeightPx, + offsetPx = collapseOffset.value, + onNaturalHeightMeasured = { brandingHeightPx = it }, + modifier = Modifier.fillMaxWidth() + ) { + AppBranding(trailingContent = trailingContent) + } + + AppSearchBar( + textFieldState = textFieldState, + onSearch = onSearch, + searchUiState = searchUiState, + expanded = isSearchExpanded, + onExpandedChange = onSearchExpandedChange, + onSearchResultClick = onSearchResultClick, + recentSearches = recentSearches, + onRecentSearchClick = onRecentSearchClick, + onRemoveRecentSearch = onRemoveRecentSearch, + onClearRecentSearches = onClearRecentSearches + ) + + content() + } +} + +/** + * Lays [content] out at its true, unconstrained natural height on every + * measurement pass - regardless of [collapsedHeightPx] - so that height + * never becomes a moving target as it shrinks. Only the space this layout + * itself reports to its parent (and thus reserves for siblings below it, + * like the search bar) follows [collapsedHeightPx]; [content] is placed at + * [offsetPx] and clipped to whatever of it still falls within that shrunk + * region, so it visually slides up and out as it collapses. + */ +@Composable +private fun CollapsibleHeader( + collapsedHeightPx: Float, + offsetPx: Float, + onNaturalHeightMeasured: (Float) -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Layout( + content = content, + modifier = modifier.clipToBounds() + ) { measurables, constraints -> + val placeable = measurables.first().measure( + constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity) + ) + if (placeable.height.toFloat() != 0f) { + onNaturalHeightMeasured(placeable.height.toFloat()) + } + val layoutHeight = collapsedHeightPx.toInt().coerceIn(0, placeable.height) + layout(placeable.width, layoutHeight) { + placeable.placeRelative(0, offsetPx.toInt()) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/LoadingWidget.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/LoadingWidget.kt new file mode 100644 index 00000000..fbfa738a --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/LoadingWidget.kt @@ -0,0 +1,28 @@ +package tech.salroid.filmy.ui.common.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun LoadingWidget(modifier: Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + LoadingIndicator() + } +} + + +@Composable +@Preview(showBackground = true) +fun LoadingWidgetPreview() { + LoadingWidget(modifier = Modifier) +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/LoginRequiredDialog.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/LoginRequiredDialog.kt new file mode 100644 index 00000000..a0226889 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/LoginRequiredDialog.kt @@ -0,0 +1,37 @@ +package tech.salroid.filmy.ui.common.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +@Composable +fun LoginRequiredDialog( + message: String, + onLogin: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + "Log in required", + style = MaterialTheme.typography.titleLarge + ) + }, + text = { Text(message) }, + confirmButton = { + Button(onClick = onLogin) { + Text("Log In") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/PreviewItem.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/PreviewItem.kt new file mode 100644 index 00000000..25488db5 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/PreviewItem.kt @@ -0,0 +1,259 @@ +package tech.salroid.filmy.ui.common.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.shape.CircleShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color.Companion.Transparent +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.movies.dummyShowPreview + +/** Current watchlist/watched state for a grid item's long-press quick-actions. */ +data class QuickActionState( + val isWatchlisted: Boolean, + val isWatched: Boolean +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PreviewItem( + modifier: Modifier = Modifier, + title: String, + posterUrl: String, + readableDate: String, + subtitle: String? = null, + contentDescription: String? = null, + onItemClick: () -> Unit, + fetchQuickActionState: (suspend () -> QuickActionState)? = null, + onToggleWatchlist: (() -> Unit)? = null, + onToggleWatched: (() -> Unit)? = null +) { + val hasQuickActions = onToggleWatchlist != null || onToggleWatched != null + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + + var isFlipped by remember { mutableStateOf(false) } + var quickActionState by remember { mutableStateOf(null) } + + LaunchedEffect(isFlipped) { + if (isFlipped && fetchQuickActionState != null) { + quickActionState = fetchQuickActionState() + } + } + + val rotation = animateFloatAsState( + targetValue = if (isFlipped) 180f else 0f, + animationSpec = tween(400), + label = "poster_flip" + ) + val showBack by remember { derivedStateOf { rotation.value > 90f } } + + Card( + shape = RoundedCornerShape(corner = CornerSize(8.dp)), + colors = CardDefaults.cardColors(containerColor = Transparent) + ) { + Column( + modifier = modifier.combinedClickable( + onClick = { + if (isFlipped) isFlipped = false else onItemClick() + }, + onLongClick = if (hasQuickActions) { + // combinedClickable already performs LongPress haptic itself + // when onLongClick fires - no need to call it again here. + { isFlipped = true } + } else null + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(170.dp) + .graphicsLayer { + rotationY = rotation.value + cameraDistance = 12f * density.density + } + .clip(RoundedCornerShape(corner = CornerSize(8.dp))) + ) { + if (!showBack) { + AsyncImage( + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + model = posterUrl, + contentDescription = contentDescription, + error = painterResource(R.drawable.poster_error_placeholder) + ) + } else { + val isWatchlisted = quickActionState?.isWatchlisted == true + val isWatched = quickActionState?.isWatched == true + + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { rotationY = 180f } + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + isFlipped = false + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(24.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + shape = CircleShape + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp) + ) + } + + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically) + ) { + if (onToggleWatchlist != null) { + IconButton( + onClick = { + haptic.performHapticFeedback( + if (!isWatchlisted) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff + ) + quickActionState = (quickActionState ?: QuickActionState(false, false)) + .copy(isWatchlisted = !isWatchlisted) + onToggleWatchlist() + } + ) { + Icon( + painter = painterResource( + if (isWatchlisted) R.drawable.ic_round_bookmark_added_24 + else R.drawable.ic_round_bookmark_add_24 + ), + contentDescription = if (isWatchlisted) "Remove from Watchlist" else "Add to Watchlist", + tint = if (isWatchlisted) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (onToggleWatched != null) { + IconButton( + onClick = { + haptic.performHapticFeedback( + if (!isWatched) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff + ) + quickActionState = (quickActionState ?: QuickActionState(false, false)) + .copy(isWatched = !isWatched) + onToggleWatched() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = if (isWatched) "Mark as Unwatched" else "Mark Watched", + tint = if (isWatched) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + Text( + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium + ), + text = title + ) + if (subtitle != null) { + Text( + modifier = Modifier + .padding(top = 2.dp) + .alpha(0.7f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + text = subtitle + ) + } + Text( + modifier = Modifier + .padding(top = 4.dp) + .alpha(0.8f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + text = readableDate + ) + } + } +} + +@Preview( + name = "PreviewItem - Preview", + showBackground = true +) +@Composable +fun MovieItemPreview() { + PreviewItem( + modifier = Modifier + .width(140.dp) + .padding(16.dp), + title = dummyShowPreview.title, + posterUrl = dummyShowPreview.posterUrl, + readableDate = dummyShowPreview.firstAirReadableDate, + onItemClick = { } + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/components/PreviewList.kt b/app/src/main/java/tech/salroid/filmy/ui/common/components/PreviewList.kt new file mode 100644 index 00000000..9a2f714b --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/components/PreviewList.kt @@ -0,0 +1,145 @@ +package tech.salroid.filmy.ui.common.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.window.core.layout.WindowSizeClass +import tech.salroid.filmy.ui.LocalWindowSizeClass +import tech.salroid.filmy.utility.toUserMessage + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PreviewList( + modifier: Modifier = Modifier, + items: Int, + key: ((index: Int) -> Any)? = null, + header: (@Composable () -> Unit)? = null, + stickyHeader: (@Composable () -> Unit)? = null, + content: @Composable (index: Int) -> Unit +) { + val windowSizeClass = LocalWindowSizeClass.current + val cells = when { + windowSizeClass.isWidthAtLeastBreakpoint( + WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND + ) -> 6 + + windowSizeClass.isWidthAtLeastBreakpoint( + WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND + ) -> 4 + + else -> 3 + } + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (header != null) { + item { header() } + } + if (stickyHeader != null) { + stickyHeader { stickyHeader() } + } + + val rows = (items + cells - 1) / cells + items(rows) { rowIndex -> + Row( + modifier = Modifier.padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + for (columnIndex in 0 until cells) { + val index = rowIndex * cells + columnIndex + if (index < items) { + Box(modifier = Modifier.weight(1f)) { + content(index) + } + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PaginatedPreviewList( + modifier: Modifier = Modifier, + items: LazyPagingItems, + header: (@Composable () -> Unit)? = null, + stickyHeader: (@Composable () -> Unit)? = null, + content: @Composable (T) -> Unit +) { + val windowSizeClass = LocalWindowSizeClass.current + val cells = when { + windowSizeClass.isWidthAtLeastBreakpoint( + WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND + ) -> 6 + + windowSizeClass.isWidthAtLeastBreakpoint( + WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND + ) -> 4 + + else -> 3 + } + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (header != null) { + item { header() } + } + if (stickyHeader != null) { + stickyHeader { stickyHeader() } + } + + val rowCount = (items.itemCount + cells - 1) / cells + items(rowCount) { rowIndex -> + Row( + modifier = Modifier.padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + for (columnIndex in 0 until cells) { + val index = rowIndex * cells + columnIndex + if (index < items.itemCount) { + val item = items[index] + if (item != null) { + Box(modifier = Modifier.weight(1f)) { + content(item) + } + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + + item { + when (val state = items.loadState.append) { + is LoadState.Loading -> { + LoadingWidget(modifier = Modifier.fillMaxWidth().padding(16.dp)) + } + is LoadState.Error -> { + ErrorWidget( + modifier = Modifier.fillMaxWidth().padding(16.dp), + message = state.error.toUserMessage(), + onRetryClick = { items.retry() } + ) + } + else -> {} + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/icons/MoreUp.kt b/app/src/main/java/tech/salroid/filmy/ui/common/icons/MoreUp.kt new file mode 100644 index 00000000..35c80e52 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/icons/MoreUp.kt @@ -0,0 +1,60 @@ +package tech.salroid.filmy.ui.common.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +@Suppress("CheckReturnValue") +public val MoreUp: ImageVector + get() { + if (_more_up != null) { + return _more_up!! + } + _more_up = + ImageVector.Builder( + name = "more_up", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(17f, 16f) + verticalLineTo(7f) + horizontalLineTo(8f) + verticalLineTo(5f) + horizontalLineTo(19f) + verticalLineTo(16f) + horizontalLineTo(17f) + close() + moveToRelative(-5f, 5f) + verticalLineTo(12f) + horizontalLineTo(3f) + verticalLineTo(10f) + horizontalLineTo(14f) + verticalLineTo(21f) + horizontalLineTo(12f) + close() + } + } + .build() + return _more_up!! + } + +private var _more_up: ImageVector? = null diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/model/DetailsActions.kt b/app/src/main/java/tech/salroid/filmy/ui/common/model/DetailsActions.kt new file mode 100644 index 00000000..eeaf5a55 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/model/DetailsActions.kt @@ -0,0 +1,20 @@ +package tech.salroid.filmy.ui.common.model + +data class DetailsActions( + val onWatchedToggle: () -> Unit, + val onWatchlistToggle: () -> Unit, + val onViewAllCastClick: (Int, Boolean, String) -> Unit, + val onViewAllReviewsClick: (Int, Boolean, String) -> Unit, + val onMemberClick: (Int, Boolean) -> Unit, + val onMediaClick: (Int) -> Unit, + val onTrailerClick: (String) -> Unit, + val onShareClick: () -> Unit, + val onBackNavigation: () -> Unit, + val onAddToListClick: () -> Unit = {}, + val onCollectionClick: (Int, String) -> Unit = { _, _ -> }, + val onSeasonClick: (Int, Int, String) -> Unit = { _, _, _ -> }, + val onRateClick: () -> Unit = {}, + val onGalleryClick: (Int, Boolean) -> Unit = { _, _ -> }, + val onKeywordClick: (Int, String) -> Unit = { _, _ -> }, + val onLinkClick: (String) -> Unit = {} +) diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/model/MediaDetailsUiState.kt b/app/src/main/java/tech/salroid/filmy/ui/common/model/MediaDetailsUiState.kt new file mode 100644 index 00000000..32f2ca58 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/model/MediaDetailsUiState.kt @@ -0,0 +1,44 @@ +package tech.salroid.filmy.ui.common.model + +import tech.salroid.filmy.data.local.model.CastAndCrewResponse +import tech.salroid.filmy.data.local.model.Keyword +import tech.salroid.filmy.data.local.model.SimilarMoviesResponse +import tech.salroid.filmy.data.local.model.Youtube + +data class MediaDetailsUiState( + val mediaId: Int, + val title: String, + val overview: String, + val tagline: String, + val backdropPath: String?, + val posterPath: String?, + val genres: String, + val runtimeText: String, + val releaseDateText: String, + val youtubeTrailers: List?, + val isWatched: Boolean, + val isWatchlist: Boolean, + val isTvShow: Boolean, + val imdbId: String? = null, + val reviews: ReviewResponseUiModel? = null, + val watchProviders: WatchProvidersUiModel? = null, + val castAndCrew: CastAndCrewResponse? = null, + val similarMedia: SimilarMoviesResponse? = null, + val recommendations: SimilarMoviesResponse? = null, + val ratings: RatingsUiModel? = null, + val certification: String? = null, + val collectionId: Int? = null, + val collectionName: String? = null, + val seasons: List? = null, + val userRating: Float? = null, + val awards: String? = null, + val budget: Long? = null, + val revenue: Long? = null, + val studios: List? = null, + val keywords: List? = null, + val backdropImages: List? = null, + val homepage: String? = null, + val facebookId: String? = null, + val instagramId: String? = null, + val twitterId: String? = null +) diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/model/PaletteColors.kt b/app/src/main/java/tech/salroid/filmy/ui/common/model/PaletteColors.kt new file mode 100644 index 00000000..098a29df --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/model/PaletteColors.kt @@ -0,0 +1,13 @@ +package tech.salroid.filmy.ui.common.model + +data class PaletteColors( + val vibrantRgb: Int? = null, + val vibrantTitleTextColor: Int? = null, + val vibrantBodyTextColor: Int? = null, + val darkVibrantRgb: Int? = null, + val darkVibrantBodyTextColor: Int? = null, + val lightVibrantRgb: Int? = null, + val lightMutedRgb: Int? = null, + val darkMutedRgb: Int? = null, + val mutedRgb: Int? = null +) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/model/RatingUiModel.kt b/app/src/main/java/tech/salroid/filmy/ui/common/model/RatingUiModel.kt new file mode 100644 index 00000000..90c33a08 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/model/RatingUiModel.kt @@ -0,0 +1,20 @@ +package tech.salroid.filmy.ui.common.model + +data class RatingsUiModel( + val ratings: List +) + +data class RatingSourceUiModel( + val source: RatingSource, + val value: String, + val url: String? = null +) + +enum class RatingSource { + USER, + TMDB, + IMDB, + ROTTEN_TOMATOES, + METACRITIC, + OTHER +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/model/ReviewUiModel.kt b/app/src/main/java/tech/salroid/filmy/ui/common/model/ReviewUiModel.kt new file mode 100644 index 00000000..e7fec48a --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/model/ReviewUiModel.kt @@ -0,0 +1,13 @@ +package tech.salroid.filmy.ui.common.model + +data class ReviewResponseUiModel( + val results: List +) + +data class ReviewUiModel( + val id: String, + val author: String, + val content: String, + val createdAt: String, + val authorAvatarUrl: String? +) diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/model/SeasonUiModel.kt b/app/src/main/java/tech/salroid/filmy/ui/common/model/SeasonUiModel.kt new file mode 100644 index 00000000..bc093002 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/model/SeasonUiModel.kt @@ -0,0 +1,10 @@ +package tech.salroid.filmy.ui.common.model + +data class SeasonUiModel( + val id: Int, + val seasonNumber: Int, + val name: String, + val episodeCount: Int, + val posterPath: String?, + val airDate: String? +) diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/model/StudioUiModel.kt b/app/src/main/java/tech/salroid/filmy/ui/common/model/StudioUiModel.kt new file mode 100644 index 00000000..ad157856 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/model/StudioUiModel.kt @@ -0,0 +1,7 @@ +package tech.salroid.filmy.ui.common.model + +data class StudioUiModel( + val id: Int, + val name: String, + val logoPath: String? +) diff --git a/app/src/main/java/tech/salroid/filmy/ui/common/model/WatchProvidersUiModel.kt b/app/src/main/java/tech/salroid/filmy/ui/common/model/WatchProvidersUiModel.kt new file mode 100644 index 00000000..a3157661 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/common/model/WatchProvidersUiModel.kt @@ -0,0 +1,12 @@ +package tech.salroid.filmy.ui.common.model + +data class WatchProvidersUiModel( + val link: String?, + val providers: List +) + +data class ProviderUiModel( + val id: Int, + val name: String, + val logoPath: String +) diff --git a/app/src/main/java/tech/salroid/filmy/ui/component/FilmyTopNavigationBarItem.kt b/app/src/main/java/tech/salroid/filmy/ui/component/FilmyTopNavigationBarItem.kt index 8d7a9ec5..58f8130d 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/component/FilmyTopNavigationBarItem.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/component/FilmyTopNavigationBarItem.kt @@ -7,12 +7,10 @@ import android.view.LayoutInflater import android.widget.FrameLayout import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes -import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.isVisible import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.textview.MaterialTextView import tech.salroid.filmy.R -import tech.salroid.filmy.utility.getColorFromAttr class FilmyTopNavigationBarItem @JvmOverloads constructor( context: Context, @@ -34,10 +32,10 @@ class FilmyTopNavigationBarItem @JvmOverloads constructor( //context.getColorFromAttr(R.attr.colorPrimaryContainer) } private val activeTextColor by lazy(LazyThreadSafetyMode.NONE) { - // context.getColorFromAttr(R.attr.colorPrimary) + // context.getColorFromAttr(R.attr.colorPrimary) } private val inActiveTextColor by lazy(LazyThreadSafetyMode.NONE) { - // context.getColorFromAttr(R.attr.colorOnSurface) + // context.getColorFromAttr(R.attr.colorOnSurface) } private var currentState: NavigationState = NavigationState.INACTIVE @@ -98,14 +96,14 @@ class FilmyTopNavigationBarItem @JvmOverloads constructor( fun makeItemActive() { currentState = NavigationState.ACTIVE navigationItemTitle.setTextAppearance(R.style.TextAppearance_Filmy_LabelMedium_Bold) - // navigationItemTitle.setTextColor(activeTextColor) + // navigationItemTitle.setTextColor(activeTextColor) alpha = 1.0f drawable?.let { navigationItemTitle.setCompoundDrawables(drawable, null, null, null) - // navigationItemTitle.compoundDrawables.getOrNull(0)?.let { - //DrawableCompat.setTint(it, activeTextColor) - // } + // navigationItemTitle.compoundDrawables.getOrNull(0)?.let { + //DrawableCompat.setTint(it, activeTextColor) + // } } //background = activeBackground diff --git a/app/src/main/java/tech/salroid/filmy/ui/details/MediaDetailsMapper.kt b/app/src/main/java/tech/salroid/filmy/ui/details/MediaDetailsMapper.kt new file mode 100644 index 00000000..01174f71 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/details/MediaDetailsMapper.kt @@ -0,0 +1,400 @@ +package tech.salroid.filmy.ui.details + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import tech.salroid.filmy.data.local.db.entity.MovieDetails +import tech.salroid.filmy.data.local.model.CastAndCrewResponse +import tech.salroid.filmy.data.local.model.ContentRatingsResponse +import tech.salroid.filmy.data.local.model.ExternalIdsResponse +import tech.salroid.filmy.data.local.model.ImagesResponse +import tech.salroid.filmy.data.local.model.Keyword +import tech.salroid.filmy.data.local.model.ProductionCompanies +import tech.salroid.filmy.data.local.model.RatingResponse +import tech.salroid.filmy.data.local.model.ReleaseDatesResponse +import tech.salroid.filmy.data.local.model.ReviewResponse +import tech.salroid.filmy.data.local.model.SimilarMoviesResponse +import tech.salroid.filmy.data.local.model.tv.Networks +import tech.salroid.filmy.data.local.model.tv.TvDetails +import tech.salroid.filmy.data.local.model.watch_providers.WatchProviderResponse +import tech.salroid.filmy.data.local.model.Videos +import tech.salroid.filmy.data.local.model.Youtube +import tech.salroid.filmy.ui.common.model.* +import tech.salroid.filmy.utility.PreferenceHelper +import tech.salroid.filmy.utility.parseHtml +import tech.salroid.filmy.utility.toReadableDate +import java.util.Locale +import javax.inject.Inject + +class MediaDetailsMapper @Inject constructor( + @param:ApplicationContext private val context: Context +) { + + fun map( + movie: MovieDetails?, + tv: TvDetails?, + reviews: ReviewResponse?, + watchProviders: WatchProviderResponse?, + cast: CastAndCrewResponse?, + similar: SimilarMoviesResponse?, + recommendations: SimilarMoviesResponse?, + ratings: RatingResponse?, + releaseDates: ReleaseDatesResponse? = null, + contentRatings: ContentRatingsResponse? = null, + keywords: List? = null, + images: ImagesResponse? = null, + externalIds: ExternalIdsResponse? = null + ): MediaDetailsUiState? { + val providersUiModel = mapWatchProviders(watchProviders) + val reviewsUiModel = mapReviews(reviews) + val ratingsUiModel = mapRatings(movie, tv, ratings) + val certification = mapCertification(releaseDates, contentRatings) + val awards = ratings?.awards?.takeIf { it.isNotBlank() && it != "N/A" } + val backdropImages = images?.backdrops?.mapNotNull { it.filePath }?.take(8)?.ifEmpty { null } + + if (movie != null && tv == null) { + val hours = movie.runtime?.div(60) ?: 0 + val mins = movie.runtime?.rem(60) ?: 0 + val runtimeText = if (hours > 0 || mins > 0) "${hours}h ${mins}m" else "" + val releaseDate = movie.releaseDate?.toReadableDate() ?: "" + val releaseDateText = if (runtimeText.isNotEmpty() && releaseDate.isNotEmpty()) " • $releaseDate" else releaseDate + + return MediaDetailsUiState( + mediaId = movie.id, + title = movie.title ?: "", + overview = (movie.overview ?: "").parseHtml(), + tagline = movie.tagline ?: "", + backdropPath = movie.backdropPath, + posterPath = movie.posterPath, + genres = movie.genres.joinToString(" / ") { it.name ?: "" }, + runtimeText = runtimeText, + releaseDateText = releaseDateText, + youtubeTrailers = mapTrailers(movie.trailers, movie.videos), + isWatched = movie.watched, + isWatchlist = movie.watchlist, + isTvShow = false, + imdbId = movie.imdbId ?: externalIds?.imdbId, + reviews = reviewsUiModel, + watchProviders = providersUiModel, + castAndCrew = cast, + similarMedia = similar, + recommendations = recommendations, + ratings = ratingsUiModel, + certification = certification, + collectionId = movie.belongsToCollection?.id, + collectionName = movie.belongsToCollection?.name, + userRating = movie.userRating, + awards = awards, + budget = movie.budget?.takeIf { it > 0 }, + revenue = movie.revenue?.takeIf { it > 0 }, + studios = mapStudios(movie.productionCompanies), + keywords = keywords, + backdropImages = backdropImages, + homepage = movie.homepage?.takeIf { it.isNotBlank() }, + facebookId = externalIds?.facebookId, + instagramId = externalIds?.instagramId, + twitterId = externalIds?.twitterId + ) + } else if (tv != null) { + val runtime = tv.episodeRunTime.firstOrNull() ?: 0 + val runtimeText = if (runtime > 0) "${runtime}m" else "" + val releaseDate = tv.firstAirDate?.toReadableDate() ?: "" + val releaseDateText = if (runtimeText.isNotEmpty() && releaseDate.isNotEmpty()) " • $releaseDate" else releaseDate + + return MediaDetailsUiState( + mediaId = tv.id ?: 0, + title = tv.name ?: "", + overview = (tv.overview ?: "").parseHtml(), + tagline = tv.tagline ?: "", + backdropPath = tv.backdropPath, + posterPath = tv.posterPath, + genres = tv.genres.joinToString(" / ") { it.name ?: "" }, + runtimeText = runtimeText, + releaseDateText = releaseDateText, + youtubeTrailers = mapTrailers(tv.trailers, tv.videos), + isWatched = movie?.watched ?: false, + isWatchlist = movie?.watchlist ?: false, + isTvShow = true, + imdbId = externalIds?.imdbId, + reviews = reviewsUiModel, + watchProviders = providersUiModel, + castAndCrew = cast, + similarMedia = similar, + recommendations = recommendations, + ratings = ratingsUiModel, + certification = certification, + seasons = mapSeasons(tv), + userRating = movie?.userRating, + awards = awards, + studios = mapNetworksAsStudios(tv.networks), + keywords = keywords, + backdropImages = backdropImages, + homepage = tv.homepage?.takeIf { it.isNotBlank() }, + facebookId = externalIds?.facebookId, + instagramId = externalIds?.instagramId, + twitterId = externalIds?.twitterId + ) + } + return null + } + + private fun mapSeasons(tv: TvDetails): List? { + val seasons = tv.seasons.mapNotNull { season -> + val id = season.id ?: return@mapNotNull null + val seasonNumber = season.seasonNumber ?: return@mapNotNull null + SeasonUiModel( + id = id, + seasonNumber = seasonNumber, + name = season.name ?: "Season $seasonNumber", + episodeCount = season.episodeCount ?: 0, + posterPath = season.posterPath, + airDate = season.airDate + ) + } + return seasons.ifEmpty { null } + } + + private fun mapStudios(productionCompanies: List): List? { + val studios = productionCompanies.mapNotNull { company -> + val id = company.id ?: return@mapNotNull null + StudioUiModel(id = id, name = company.name ?: return@mapNotNull null, logoPath = company.logoPath) + } + return studios.ifEmpty { null } + } + + private fun mapNetworksAsStudios(networks: List): List? { + val studios = networks.mapNotNull { network -> + val id = network.id ?: return@mapNotNull null + StudioUiModel(id = id, name = network.name ?: return@mapNotNull null, logoPath = network.logoPath) + } + return studios.ifEmpty { null } + } + + private fun mapCertification( + releaseDates: ReleaseDatesResponse?, + contentRatings: ContentRatingsResponse? + ): String? { + // Priority to the user's selected region, falling back to 'US'. + val preferred = PreferenceHelper.getSelectedCountry(context) + + releaseDates?.results?.let { results -> + val country = results.find { it.iso31661 == preferred } ?: results.find { it.iso31661 == "US" } + val certification = country?.releaseDates + ?.firstOrNull { !it.certification.isNullOrBlank() } + ?.certification + if (!certification.isNullOrBlank()) return certification + } + + contentRatings?.results?.let { results -> + val rating = (results.find { it.iso31661 == preferred } ?: results.find { it.iso31661 == "US" })?.rating + if (!rating.isNullOrBlank()) return rating + } + + return null + } + + private fun mapRatings( + movie: MovieDetails?, + tv: TvDetails?, + omdbRatings: RatingResponse? + ): RatingsUiModel? { + val ratingsList = mutableListOf() + + // Your Rating + movie?.userRating?.let { rating -> + ratingsList.add( + RatingSourceUiModel( + source = RatingSource.USER, + value = "$rating/10" + ) + ) + } + + // TMDB Rating + val tmdbVoteAverage = movie?.voteAverage ?: tv?.voteAverage + val id = movie?.id ?: tv?.id + val isTv = tv != null + val typePath = if (isTv) "tv" else "movie" + + if (tmdbVoteAverage != null && tmdbVoteAverage > 0) { + ratingsList.add( + RatingSourceUiModel( + source = RatingSource.TMDB, + value = String.format(Locale.getDefault(), "%.1f", tmdbVoteAverage), + url = "https://www.themoviedb.org/$typePath/$id" + ) + ) + } + + // External Ratings + omdbRatings?.ratings?.forEach { rating -> + val source = when (rating.source) { + "Internet Movie Database" -> RatingSource.IMDB + "Rotten Tomatoes" -> RatingSource.ROTTEN_TOMATOES + "Metacritic" -> RatingSource.METACRITIC + else -> RatingSource.OTHER + } + + val value = rating.value + if (!value.isNullOrEmpty() && value != "N/A") { + val url = when (source) { + RatingSource.IMDB -> omdbRatings.imdbID?.let { "https://www.imdb.com/title/$it" } + RatingSource.ROTTEN_TOMATOES -> omdbRatings.tomatoURL + RatingSource.METACRITIC -> { + val title = movie?.title ?: tv?.name + val smallTitle = title?.replace(' ', '-')?.lowercase() + ?.replace("[^\\d-a-z]".toRegex(), "") + "https://www.metacritic.com/movie/$smallTitle" + } + else -> null + } + ratingsList.add( + RatingSourceUiModel( + source = source, + value = value, + url = url + ) + ) + } + } + + return if (ratingsList.isNotEmpty()) RatingsUiModel(ratingsList) else null + } + + private fun mapTrailers(trailers: tech.salroid.filmy.data.local.model.Trailers?, videos: Videos?): List? { + val youtubeList = mutableListOf() + + // Prefer videos (modern TMDB API) + videos?.results?.forEach { video -> + if (video.site == "YouTube") { + youtubeList.add( + Youtube( + name = video.name, + size = video.size?.toString(), + source = video.key, + type = video.type + ) + ) + } + } + + // Add from old trailers if not already present or as fallback + trailers?.youtube?.forEach { youtube -> + if (youtubeList.none { it.source == youtube.source }) { + youtubeList.add(youtube) + } + } + + // Lead with an actual Trailer (falling back through Teaser/Clip/etc.) + // so the featured thumbnail and top of "all videos" aren't a Blooper/Clip. + return youtubeList.sortedBy { videoTypePriority(it.type) }.ifEmpty { null } + } + + private fun videoTypePriority(type: String?): Int = when (type) { + "Trailer" -> 0 + "Teaser" -> 1 + "Clip" -> 2 + "Featurette" -> 3 + "Behind the Scenes" -> 4 + "Bloopers" -> 5 + else -> 6 + } + + private fun mapReviews(reviews: ReviewResponse?): ReviewResponseUiModel? { + if (reviews?.results == null) return null + + val mappedResults = reviews.results.map { review -> + val avatarPath = review.authorDetails?.avatarPath + var finalAvatarUrl = if (avatarPath != null) "https://image.tmdb.org/t/p/w500$avatarPath" else null + + avatarPath?.let { + if (it.contains("www.gravatar.com")) { + finalAvatarUrl = it.subSequence(1, it.length - 1).toString() + } + } + + ReviewUiModel( + id = review.id ?: "", + author = review.author ?: "", + content = (review.content ?: "").parseHtml(), + createdAt = review.createdAt?.toReadableDate() ?: "", + authorAvatarUrl = finalAvatarUrl + ) + } + + return ReviewResponseUiModel(results = mappedResults) + } + + private fun mapWatchProviders(watchProviders: WatchProviderResponse?): WatchProvidersUiModel? { + val results = watchProviders?.results ?: return null + + // Priority to the user's selected region, falling back to 'US'. + val preferred = PreferenceHelper.getSelectedCountry(context) + val countryData = results[preferred] ?: results["US"] ?: return null + + val stream = countryData.flatrate + val buy = countryData.buy + val rent = countryData.rent + + val allProviders = mutableListOf() + + stream.forEach { + allProviders.add( + ProviderUiModel( + it.providerId ?: 0, + it.providerName ?: "", + it.logoPath ?: "" + ) + ) + } + buy.forEach { + allProviders.add( + ProviderUiModel( + it.providerId ?: 0, + it.providerName ?: "", + it.logoPath ?: "" + ) + ) + } + rent.forEach { + allProviders.add( + ProviderUiModel( + it.providerId ?: 0, + it.providerName ?: "", + it.logoPath ?: "" + ) + ) + } + + val distinctProviders = allProviders + .distinctBy { it.id } + .filter { it.logoPath.isNotEmpty() } + + if (distinctProviders.isEmpty() && countryData.link.isNullOrEmpty()) { + return null + } + + return WatchProvidersUiModel( + link = countryData.link, + providers = distinctProviders + ) + } +} + +fun TvDetails.toMovieDetails( + existing: MovieDetails?, + watched: Boolean, + watchlist: Boolean +): MovieDetails = (existing ?: MovieDetails(id = this.id ?: 0, type = 1)).copy( + title = this.name, + overview = this.overview, + tagline = this.tagline, + backdropPath = this.backdropPath, + posterPath = this.posterPath, + voteAverage = this.voteAverage, + voteCount = this.voteCount?.toLong(), + originalLanguage = this.originalLanguage, + releaseDate = this.firstAirDate +).apply { + this.watched = watched + this.watchlist = watchlist +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/details/MovieDetailsActivity.kt b/app/src/main/java/tech/salroid/filmy/ui/details/MovieDetailsActivity.kt index 6b50935f..709dd413 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/details/MovieDetailsActivity.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/details/MovieDetailsActivity.kt @@ -102,7 +102,7 @@ class MovieDetailsActivity : AppCompatActivity() { companion object { const val IMAGE_QUALITY_DEFAULT = "original" const val WATCHLIST = "watchlist" - const val FAVOURITES = "favorites" + const val WATCHED = "watched" } override fun onCreate(savedInstanceState: Bundle?) { @@ -151,7 +151,7 @@ class MovieDetailsActivity : AppCompatActivity() { it?.let { showMovieDetails(it) isWatchlist = it.watchlist - isFavourite = it.favorite + isFavourite = it.watched updateOptionsMenu() } } @@ -161,7 +161,7 @@ class MovieDetailsActivity : AppCompatActivity() { viewModel.uiStateAddToCollection.collect { (addedToCollection, message) -> if (addedToCollection) { if (message == WATCHLIST) isWatchlist = true - if (message == FAVOURITES) isFavourite = true + if (message == WATCHED) isFavourite = true binding.backdrop.showSnackBar("Movie added to $message") updateOptionsMenu() } @@ -172,7 +172,7 @@ class MovieDetailsActivity : AppCompatActivity() { if (updatedID > 0) { if (remove) { if (message == WATCHLIST) isWatchlist = false - if (message == FAVOURITES) isFavourite = false + if (message == WATCHED) isFavourite = false binding.backdrop.showSnackBar( "Movie removed from $message", @@ -180,7 +180,7 @@ class MovieDetailsActivity : AppCompatActivity() { ) } else { if (message == WATCHLIST) isWatchlist = true - if (message == FAVOURITES) isFavourite = true + if (message == WATCHED) isFavourite = true binding.backdrop.showSnackBar("Movie added to $message") } @@ -413,7 +413,7 @@ class MovieDetailsActivity : AppCompatActivity() { binding.main.visibility = View.INVISIBLE binding.breathingProgress.visibility = View.VISIBLE } - viewModel.getMovieDetails(movieId, type) + // viewModel.getMovieDetails(movieId, type) } } @@ -663,10 +663,10 @@ class MovieDetailsActivity : AppCompatActivity() { viewModel.getRatings(movieImdbId) // Get Reviews - viewModel.getReviews(movieId) + //viewModel.getReviews(movieId) // Get Watch Providers - viewModel.getWatchProviders(movieId) + //viewModel.getWatchProviders(movieId) // Get Cast, Crew and Similar Movies movieIdFinal?.let { @@ -778,7 +778,7 @@ class MovieDetailsActivity : AppCompatActivity() { else { var smallTitle = movieTitleHyphen?.lowercase() smallTitle = smallTitle?.replace("[^\\d-a-z]".toRegex(), "") - val url = "http://www.metacritic.com/movie/$smallTitle" + val url = "https://www.metacritic.com/movie/$smallTitle" if (metaScoreRating != null) { when { @@ -823,9 +823,9 @@ class MovieDetailsActivity : AppCompatActivity() { } private fun setWatchProviderInfo(watchProviderResponse: WatchProviderResponse) { - val watchProviderStream = watchProviderResponse.results?.IN?.flatrate?.firstOrNull() - val watchProviderBuy = watchProviderResponse.results?.IN?.buy?.firstOrNull() - val watchProviderRent = watchProviderResponse.results?.IN?.rent?.firstOrNull() + val watchProviderStream = watchProviderResponse.results["IN"]?.flatrate?.firstOrNull() + val watchProviderBuy = watchProviderResponse.results["IN"]?.buy?.firstOrNull() + val watchProviderRent = watchProviderResponse.results["IN"]?.rent?.firstOrNull() binding.watchProviderContainer.isVisible = watchProviderStream != null || watchProviderBuy != null || watchProviderRent != null @@ -899,9 +899,9 @@ class MovieDetailsActivity : AppCompatActivity() { } private fun addFavorite() { - movieDetails.favorite = true + movieDetails.watched = true movieDetails.type = type - viewModel.updateMovieDetailsInDb(movieDetails, FAVOURITES, false) + viewModel.updateMovieDetailsInDb(movieDetails, WATCHED, false) } private fun removeWatchlist() { @@ -911,9 +911,9 @@ class MovieDetailsActivity : AppCompatActivity() { } private fun removeFavorite() { - movieDetails.favorite = false + movieDetails.watched = false movieDetails.type = type - viewModel.updateMovieDetailsInDb(movieDetails, FAVOURITES, true) + viewModel.updateMovieDetailsInDb(movieDetails, WATCHED, true) } private fun openCustomTabIntent(url: String, color: Int) { diff --git a/app/src/main/java/tech/salroid/filmy/ui/details/MovieDetailsViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/details/MovieDetailsViewModel.kt index 15970908..8c767522 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/details/MovieDetailsViewModel.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/details/MovieDetailsViewModel.kt @@ -7,18 +7,32 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import tech.salroid.filmy.data.local.db.entity.MovieDetails +import tech.salroid.filmy.data.local.model.ContentRatingsResponse +import tech.salroid.filmy.data.local.model.ExternalIdsResponse +import tech.salroid.filmy.data.local.model.ImagesResponse +import tech.salroid.filmy.data.local.model.Keyword import tech.salroid.filmy.data.local.model.RatingResponse +import tech.salroid.filmy.data.local.model.ReleaseDatesResponse import tech.salroid.filmy.data.local.model.ReviewResponse +import tech.salroid.filmy.data.local.model.CastAndCrewResponse +import tech.salroid.filmy.data.local.model.SimilarMoviesResponse +import tech.salroid.filmy.data.local.model.account.TmdbList import tech.salroid.filmy.data.local.model.tv.TvDetails import tech.salroid.filmy.data.local.model.watch_providers.WatchProviderResponse -import tech.salroid.filmy.ui.details.MovieDetailsActivity.Companion.FAVOURITES +import tech.salroid.filmy.ui.details.MovieDetailsActivity.Companion.WATCHED import tech.salroid.filmy.ui.details.MovieDetailsActivity.Companion.WATCHLIST +import tech.salroid.filmy.ui.home.AccountRepository +import tech.salroid.filmy.ui.home.AccountSyncRepository import tech.salroid.filmy.ui.home.MoviesRepository +import tech.salroid.filmy.ui.common.model.MediaDetailsUiState import javax.inject.Inject @HiltViewModel class MovieDetailsViewModel @Inject constructor( - private val moviesRepository: MoviesRepository + private val moviesRepository: MoviesRepository, + private val mediaDetailsMapper: MediaDetailsMapper, + private val accountSyncRepository: AccountSyncRepository, + private val accountRepository: AccountRepository ) : ViewModel() { private val _uiStateMovieDetails = MutableStateFlow(null) @@ -26,9 +40,30 @@ class MovieDetailsViewModel @Inject constructor( private val _uiStateRatings = MutableStateFlow(null) private val _uiStateReviews = MutableStateFlow(null) private val _uiStateWatchProviders = MutableStateFlow(null) + + private val _uiStateCastAndCrew = MutableStateFlow(null) + private val _uiStateSimilar = MutableStateFlow(null) + private val _uiStateRecommendation = MutableStateFlow(null) + private val _uiStateReleaseDates = MutableStateFlow(null) + private val _uiStateContentRatings = MutableStateFlow(null) + private val _uiStateKeywords = MutableStateFlow>(emptyList()) + private val _uiStateImages = MutableStateFlow(null) + private val _uiStateExternalIds = MutableStateFlow(null) + private val _uiStateAddToCollection = MutableStateFlow>(Pair(false, "")) private val _uiStateUpdateCollection = MutableStateFlow(Triple(-1, "", false)) + /** True when the core details fetch failed and there's no local copy to fall back on. */ + private val _uiStateError = MutableStateFlow(false) + val uiStateError: StateFlow = _uiStateError.asStateFlow() + + private val _userLists = MutableStateFlow>(emptyList()) + val userLists: StateFlow> = _userLists.asStateFlow() + + /** listId -> whether the currently-open movie is a member of that list. */ + private val _listMembership = MutableStateFlow>(emptyMap()) + val listMembership: StateFlow> = _listMembership.asStateFlow() + val uiStateMovieDetails: StateFlow = _uiStateMovieDetails.asStateFlow() val uiStateTvDetails: StateFlow = _uiStateTvDetails.asStateFlow() val uiStateRatingResponse: StateFlow = _uiStateRatings.asStateFlow() @@ -40,58 +75,235 @@ class MovieDetailsViewModel @Inject constructor( val uiStateUpdateCollection: StateFlow> = _uiStateUpdateCollection.asStateFlow() + val mediaDetailsUiState: StateFlow = combine( + _uiStateMovieDetails, + _uiStateTvDetails, + _uiStateReviews, + _uiStateWatchProviders, + _uiStateCastAndCrew, + _uiStateSimilar, + _uiStateRecommendation, + _uiStateRatings, + _uiStateReleaseDates, + _uiStateContentRatings, + _uiStateKeywords, + _uiStateImages, + _uiStateExternalIds + ) { args -> + val movie = args[0] as MovieDetails? + val tv = args[1] as TvDetails? + val reviews = args[2] as ReviewResponse? + val watchProviders = args[3] as WatchProviderResponse? + val cast = args[4] as CastAndCrewResponse? + val similar = args[5] as SimilarMoviesResponse? + val recommendations = args[6] as SimilarMoviesResponse? + val ratings = args[7] as RatingResponse? + val releaseDates = args[8] as ReleaseDatesResponse? + val contentRatings = args[9] as ContentRatingsResponse? + @Suppress("UNCHECKED_CAST") + val keywords = args[10] as List + val images = args[11] as ImagesResponse? + val externalIds = args[12] as ExternalIdsResponse? + + mediaDetailsMapper.map( + movie = movie, + tv = tv, + reviews = reviews, + watchProviders = watchProviders, + cast = cast, + similar = similar, + recommendations = recommendations, + ratings = ratings, + releaseDates = releaseDates, + contentRatings = contentRatings, + keywords = keywords, + images = images, + externalIds = externalIds + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + fun fetchAllMovieDetails(movieId: String, movieType: Int, addToLocal: Boolean = false) { + _uiStateError.value = false + getMovieDetails(movieId, movieType, addToLocal) + getReviews(movieId) + getWatchProviders(movieId) + + viewModelScope.launch { + moviesRepository.getCastAndCrew(movieId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateCastAndCrew.emit(it) } + } + viewModelScope.launch { + moviesRepository.getSimilar(movieId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateSimilar.emit(it) } + } + viewModelScope.launch { + moviesRepository.getRecommendation(movieId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateRecommendation.emit(it) } + } + viewModelScope.launch { + moviesRepository.getMovieCertification(movieId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateReleaseDates.emit(it) } + } + viewModelScope.launch { + moviesRepository.getMovieKeywords(movieId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateKeywords.emit(it) } + } + viewModelScope.launch { + moviesRepository.getMovieImages(movieId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateImages.emit(it) } + } + viewModelScope.launch { + moviesRepository.getMovieExternalIds(movieId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateExternalIds.emit(it) } + } + } + + fun fetchAllTvDetails(showId: String, movieType: Int, addToLocal: Boolean = false) { + _uiStateError.value = false + getTvDetails(showId, movieType, addToLocal) + getTvReviews(showId) + getWatchProvidersTv(showId) + + viewModelScope.launch { + moviesRepository.getCastAndCrewTv(showId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateCastAndCrew.emit(it) } + } + viewModelScope.launch { + moviesRepository.getSimilarTv(showId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateSimilar.emit(it) } + } + viewModelScope.launch { + moviesRepository.getRecommendationTv(showId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateRecommendation.emit(it) } + } + viewModelScope.launch { + moviesRepository.getTvCertification(showId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateContentRatings.emit(it) } + } + viewModelScope.launch { + moviesRepository.getTvExternalIds(showId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { externalIds -> + _uiStateExternalIds.emit(externalIds) + externalIds.imdbId?.let { getRatings(it) } + } + } + viewModelScope.launch { + moviesRepository.getTvKeywords(showId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateKeywords.emit(it) } + } + viewModelScope.launch { + moviesRepository.getTvImages(showId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _uiStateImages.emit(it) } + } + } + fun getMovieDetails(movieId: String?, movieType: Int, addToLocal: Boolean = false) { - viewModelScope.launch(Dispatchers.IO) { - movieId?.toInt()?.let { - val movieDetails = moviesRepository.getMovieDetailsFromLocal(it, movieType) - val isWatchList = movieDetails?.watchlist ?: false - val isFavourite = movieDetails?.favorite ?: false - _uiStateMovieDetails.emit(movieDetails) - - movieId.let { - moviesRepository.getMovieDetailsFromNetwork(it) - .flowOn(Dispatchers.IO) - .catch { throwable -> - throwable.printStackTrace() - }.collect { details -> - val updatedDetails = details.copy() - updatedDetails.watchlist = isWatchList - updatedDetails.favorite = isFavourite - _uiStateMovieDetails.emit(updatedDetails) - - if (addToLocal) { - saveMovieDetailsInDb(updatedDetails) - } + movieId?.toInt()?.let { id -> + viewModelScope.launch(Dispatchers.IO) { + moviesRepository.getMovieDetailsFlow(id, movieType) + .collect { details -> + _uiStateMovieDetails.emit(details) + if (details?.imdbId != null) { + getRatings(details.imdbId) } - } + } + } + + viewModelScope.launch(Dispatchers.IO) { + moviesRepository.getMovieDetailsFromNetwork(movieId) + .catch { + it.printStackTrace() + if (moviesRepository.getMovieDetailsFromLocal(id, movieType) == null) { + _uiStateError.emit(true) + } + } + .collect { details -> + val local = moviesRepository.getMovieDetailsFromLocal(id, movieType) + val updated = details.copy( + watched = local?.watched ?: false, + watchlist = local?.watchlist ?: false, + userRating = local?.userRating, + type = movieType + ) + if (addToLocal || local != null) { + moviesRepository.addMovieDetailsToLocal(updated) + } else { + _uiStateMovieDetails.emit(updated) + } + + if (updated.imdbId != null) { + getRatings(updated.imdbId) + } + } } } } fun getTvDetails(showId: String?, movieType: Int, addToLocal: Boolean = false) { - viewModelScope.launch(Dispatchers.IO) { - showId?.toInt()?.let { - // val movieDetails = moviesRepository.getMovieDetailsFromLocal(it, movieType) - // val isWatchList = movieDetails?.watchlist ?: false - // val isFavourite = movieDetails?.favorite ?: false - //_uiStateMovieDetails.emit(movieDetails) - - showId.let { - moviesRepository.getTvShowDetailsFromNetwork(it) - .flowOn(Dispatchers.IO) - .catch { throwable -> - throwable.printStackTrace() - }.collect { details -> - val updatedDetails = details.copy() - // updatedDetails.watchlist = isWatchList - // updatedDetails.favorite = isFavourite - _uiStateTvDetails.emit(updatedDetails) - - // if (addToLocal) { - // saveMovieDetailsInDb(updatedDetails) - // } + showId?.toInt()?.let { id -> + viewModelScope.launch(Dispatchers.IO) { + moviesRepository.getMovieDetailsFlow(id, movieType) + .collect { details -> + _uiStateMovieDetails.emit(details) + } + } + + viewModelScope.launch(Dispatchers.IO) { + moviesRepository.getTvShowDetailsFromNetwork(showId) + .catch { + it.printStackTrace() + if (moviesRepository.getMovieDetailsFromLocal(id, movieType) == null) { + _uiStateError.emit(true) } - } + } + .collect { showDetails -> + _uiStateTvDetails.emit(showDetails) + + // Also update local DB if it's already in the collection + val local = moviesRepository.getMovieDetailsFromLocal(id, movieType) + if (local != null) { + val updated = local.copy( + title = showDetails.name, + overview = showDetails.overview, + tagline = showDetails.tagline, + backdropPath = showDetails.backdropPath, + posterPath = showDetails.posterPath, + voteAverage = showDetails.voteAverage, + voteCount = showDetails.voteCount?.toLong(), + originalLanguage = showDetails.originalLanguage, + releaseDate = showDetails.firstAirDate + ) + moviesRepository.addMovieDetailsToLocal(updated) + } + } } } } @@ -100,21 +312,22 @@ class MovieDetailsViewModel @Inject constructor( details: MovieDetails, type: Int = 0, isWatchlist: Boolean = false, - isFavourite: Boolean = false, + isWatched: Boolean = false, addedToCollection: Boolean = false, message: String? = null ) { - val movieDetails = details.copy() - details.watchlist = isWatchlist - details.favorite = isFavourite + viewModelScope.launch(Dispatchers.IO) { + val movieDetails = details.copy( + watchlist = isWatchlist, + watched = isWatched, + type = type + ) - if (addedToCollection) { - if (message == WATCHLIST) movieDetails.watchlist = true - if (message == FAVOURITES) movieDetails.favorite = true - } + if (addedToCollection) { + if (message == WATCHLIST) movieDetails.watchlist = true + if (message == WATCHED) movieDetails.watched = true + } - viewModelScope.launch(Dispatchers.IO) { - movieDetails.type = type moviesRepository.addMovieDetailsToLocal(movieDetails) if (addedToCollection) { _uiStateAddToCollection.emit(Pair(true, message)) @@ -122,20 +335,86 @@ class MovieDetailsViewModel @Inject constructor( } } + fun toggleWatched(movieDetails: MovieDetails) { + val updatedMovie = movieDetails.copy(watched = !movieDetails.watched) + updateMovieDetailsInDb(updatedMovie, WATCHED, !updatedMovie.watched, previous = movieDetails) + } + + fun toggleWatchlist(movieDetails: MovieDetails) { + val updatedMovie = movieDetails.copy(watchlist = !movieDetails.watchlist) + updateMovieDetailsInDb(updatedMovie, WATCHLIST, !updatedMovie.watchlist, previous = movieDetails) + } + + fun toggleWatchedTv(showDetails: TvDetails, currentMovieDetails: MovieDetails?) { + val movieDetails = showDetails.toMovieDetails( + existing = currentMovieDetails, + watched = !(currentMovieDetails?.watched ?: false), + watchlist = currentMovieDetails?.watchlist ?: false + ) + updateMovieDetailsInDb(movieDetails, WATCHED, !movieDetails.watched, previous = currentMovieDetails) + } + + fun toggleWatchlistTv(showDetails: TvDetails, currentMovieDetails: MovieDetails?) { + val movieDetails = showDetails.toMovieDetails( + existing = currentMovieDetails, + watched = currentMovieDetails?.watched ?: false, + watchlist = !(currentMovieDetails?.watchlist ?: false) + ) + updateMovieDetailsInDb(movieDetails, WATCHLIST, !movieDetails.watchlist, previous = currentMovieDetails) + } + + /** + * Optimistically writes [movieDetails]'s new [rating] locally, then pushes it to + * TMDB in the background, rolling back to the previous local row on failure. + * Pass `null` to remove an existing rating. + */ + fun rateMovie(movieDetails: MovieDetails, rating: Float?) { + val previous = movieDetails + val updated = movieDetails.copy(userRating = rating) + viewModelScope.launch(Dispatchers.IO) { + moviesRepository.addMovieDetailsToLocal(updated) + val pushed = accountSyncRepository.pushRating(updated) + if (!pushed) { + moviesRepository.addMovieDetailsToLocal(previous) + } + } + } + + fun rateTvShow(showDetails: TvDetails, currentMovieDetails: MovieDetails?, rating: Float?) { + val movieDetails = showDetails.toMovieDetails( + existing = currentMovieDetails, + watched = currentMovieDetails?.watched ?: false, + watchlist = currentMovieDetails?.watchlist ?: false + ) + rateMovie(movieDetails, rating) + } + + /** + * Optimistically writes [movieDetails] locally and reports it via + * [uiStateUpdateCollection] immediately, then pushes it to TMDB in the + * background. If that push fails (while logged in), the local row is rolled + * back to [previous] (or deleted entirely if there was no previous row) and a + * second collection-update event is emitted reflecting the reverted state. + */ fun updateMovieDetailsInDb( movieDetails: MovieDetails, message: String, - remove: Boolean + remove: Boolean, + previous: MovieDetails? = null ) { viewModelScope.launch(Dispatchers.IO) { - val updatedID = moviesRepository.updateMovieDetails(movieDetails) - _uiStateUpdateCollection.emit( - Triple( - updatedID, - message, - remove - ) - ) + moviesRepository.addMovieDetailsToLocal(movieDetails) + _uiStateUpdateCollection.emit(Triple(movieDetails.id, message, remove)) + + val pushed = accountSyncRepository.pushItemState(movieDetails) + if (!pushed) { + if (previous != null) { + moviesRepository.addMovieDetailsToLocal(previous) + } else { + moviesRepository.deleteMovieDetailsFromLocal(movieDetails) + } + _uiStateUpdateCollection.emit(Triple(movieDetails.id, message, !remove)) + } } } @@ -144,9 +423,8 @@ class MovieDetailsViewModel @Inject constructor( ratingID?.let { moviesRepository.getRatings(it) .flowOn(Dispatchers.IO) - .catch { - // error - }.collect { ratingResponse -> + .catch { } + .collect { ratingResponse -> _uiStateRatings.emit(ratingResponse) } } @@ -158,9 +436,8 @@ class MovieDetailsViewModel @Inject constructor( movieId?.let { moviesRepository.getReviews(it) .flowOn(Dispatchers.IO) - .catch { - // error - }.collect { reviewResponse -> + .catch { } + .collect { reviewResponse -> _uiStateReviews.emit(reviewResponse) } } @@ -172,9 +449,8 @@ class MovieDetailsViewModel @Inject constructor( movieId?.let { moviesRepository.getTvReviews(it) .flowOn(Dispatchers.IO) - .catch { - // error - }.collect { reviewResponse -> + .catch { } + .collect { reviewResponse -> _uiStateReviews.emit(reviewResponse) } } @@ -186,9 +462,8 @@ class MovieDetailsViewModel @Inject constructor( movieId?.let { moviesRepository.getWatchProviders(it) .flowOn(Dispatchers.IO) - .catch { - // error - }.collect { watchProvidersResponse -> + .catch { } + .collect { watchProvidersResponse -> _uiStateWatchProviders.emit(watchProvidersResponse) } } @@ -200,12 +475,83 @@ class MovieDetailsViewModel @Inject constructor( tvId?.let { moviesRepository.getWatchProvidersTv(it) .flowOn(Dispatchers.IO) - .catch { - // error - }.collect { watchProvidersResponse -> + .catch { } + .collect { watchProvidersResponse -> _uiStateWatchProviders.emit(watchProvidersResponse) } } } } + + /** Synchronous - just a SharedPreferences read, safe to call right before a UI decision. */ + fun isLoggedIn(): Boolean = accountRepository.isLoggedIn() + + /** + * Fetches the user's TMDB lists and, for each, checks whether [movieId] is + * already a member (TMDB has no cheaper "is this movie in this list" lookup, + * so this fetches each list's items once - fine for a personal number of lists). + */ + fun loadUserLists(movieId: Int) { + viewModelScope.launch(Dispatchers.IO) { + val sessionId = accountRepository.getSessionIdFromPref() ?: return@launch + val accountId = accountRepository.getProfileFromLocal()?.id ?: return@launch + try { + val lists = accountRepository.getLists(accountId, sessionId).first().results + _userLists.emit(lists) + + val membership = mutableMapOf() + lists.forEach { list -> + try { + val details = accountRepository.getListDetails(list.id, sessionId).first() + membership[list.id] = details.items.any { it.id == movieId } + } catch (e: Exception) { + e.printStackTrace() + } + } + _listMembership.emit(membership) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + /** + * Optimistically flips [listId]'s membership checkbox, then pushes the + * add/remove to TMDB in the background - reverting the checkbox if it fails. + */ + fun toggleListMembership(listId: Int, movieId: Int, currentlyIn: Boolean) { + _listMembership.value = _listMembership.value + (listId to !currentlyIn) + + viewModelScope.launch(Dispatchers.IO) { + val sessionId = accountRepository.getSessionIdFromPref() + if (sessionId == null) { + _listMembership.value = _listMembership.value + (listId to currentlyIn) + return@launch + } + try { + if (currentlyIn) { + accountRepository.removeFromList(listId, sessionId, movieId).first() + } else { + accountRepository.addToList(listId, sessionId, movieId).first() + } + } catch (e: Exception) { + e.printStackTrace() + _listMembership.value = _listMembership.value + (listId to currentlyIn) + } + } + } + + /** Creates a new list on TMDB and appends it to [userLists] on success. */ + fun createList(name: String) { + viewModelScope.launch(Dispatchers.IO) { + val sessionId = accountRepository.getSessionIdFromPref() ?: return@launch + try { + val response = accountRepository.createList(sessionId, name).first() + val listId = response.listId ?: return@launch + _userLists.value = _userLists.value + TmdbList(id = listId, name = name, itemCount = 0) + } catch (e: Exception) { + e.printStackTrace() + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/details/TvDetailsActivity.kt b/app/src/main/java/tech/salroid/filmy/ui/details/TvDetailsActivity.kt index 7586eba0..b0ae3d36 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/details/TvDetailsActivity.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/details/TvDetailsActivity.kt @@ -98,7 +98,7 @@ class TvDetailsActivity : AppCompatActivity() { companion object { const val IMAGE_QUALITY_DEFAULT = "original" const val WATCHLIST = "watchlist" - const val FAVOURITES = "favorites" + const val WATCHED = "watched" } override fun onCreate(savedInstanceState: Bundle?) { @@ -157,7 +157,7 @@ class TvDetailsActivity : AppCompatActivity() { viewModel.uiStateAddToCollection.collect { (addedToCollection, message) -> if (addedToCollection) { if (message == WATCHLIST) isWatchlist = true - if (message == FAVOURITES) isFavourite = true + if (message == WATCHED) isFavourite = true binding.backdrop.showSnackBar("Movie added to $message") updateOptionsMenu() } @@ -168,7 +168,7 @@ class TvDetailsActivity : AppCompatActivity() { if (updatedID > 0) { if (remove) { if (message == WATCHLIST) isWatchlist = false - if (message == FAVOURITES) isFavourite = false + if (message == WATCHED) isFavourite = false binding.backdrop.showSnackBar( "Movie removed from $message", @@ -176,7 +176,7 @@ class TvDetailsActivity : AppCompatActivity() { ) } else { if (message == WATCHLIST) isWatchlist = true - if (message == FAVOURITES) isFavourite = true + if (message == WATCHED) isFavourite = true binding.backdrop.showSnackBar("Movie added to $message") } @@ -793,7 +793,7 @@ class TvDetailsActivity : AppCompatActivity() { else { var smallTitle = movieTitleHyphen?.lowercase() smallTitle = smallTitle?.replace("[^\\d-a-z]".toRegex(), "") - val url = "http://www.metacritic.com/movie/$smallTitle" + val url = "https://www.metacritic.com/movie/$smallTitle" if (metaScoreRating != null) { when { @@ -832,9 +832,9 @@ class TvDetailsActivity : AppCompatActivity() { } private fun setWatchProviderInfo(watchProviderResponse: WatchProviderResponse) { - val watchProviderStream = watchProviderResponse.results?.IN?.flatrate?.firstOrNull() - val watchProviderBuy = watchProviderResponse.results?.IN?.buy?.firstOrNull() - val watchProviderRent = watchProviderResponse.results?.IN?.rent?.firstOrNull() + val watchProviderStream = watchProviderResponse.results["IN"]?.flatrate?.firstOrNull() + val watchProviderBuy = watchProviderResponse.results["IN"]?.buy?.firstOrNull() + val watchProviderRent = watchProviderResponse.results["IN"]?.rent?.firstOrNull() binding.watchProviderContainer.isVisible = watchProviderStream != null || watchProviderBuy != null || watchProviderRent != null diff --git a/app/src/main/java/tech/salroid/filmy/ui/discover/DiscoverRoute.kt b/app/src/main/java/tech/salroid/filmy/ui/discover/DiscoverRoute.kt new file mode 100644 index 00000000..0a56ebfc --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/discover/DiscoverRoute.kt @@ -0,0 +1,40 @@ +package tech.salroid.filmy.ui.discover + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems + +@Composable +fun DiscoverRoute( + isTv: Boolean, + onBackClick: () -> Unit, + onMovieClick: (Int) -> Unit, + onShowClick: (Int) -> Unit, + keywordId: Int? = null, + keywordName: String? = null, + viewModel: DiscoverViewModel = hiltViewModel() +) { + LaunchedEffect(isTv) { + viewModel.initialize(isTv, keywordId, keywordName) + } + + val filters by viewModel.filters.collectAsStateWithLifecycle() + val genres by viewModel.genres.collectAsStateWithLifecycle() + val movieItems = if (!isTv) viewModel.moviesResults.collectAsLazyPagingItems() else null + val showItems = if (isTv) viewModel.showsResults.collectAsLazyPagingItems() else null + + DiscoverScreen( + isTv = isTv, + movieItems = movieItems, + showItems = showItems, + filters = filters, + genres = genres, + onApplyFilters = viewModel::applyFilters, + onBackClick = onBackClick, + onMovieClick = onMovieClick, + onShowClick = onShowClick + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/discover/DiscoverScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/discover/DiscoverScreen.kt new file mode 100644 index 00000000..738faba4 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/discover/DiscoverScreen.kt @@ -0,0 +1,155 @@ +package tech.salroid.filmy.ui.discover + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.Genre +import tech.salroid.filmy.data.local.model.discover.DiscoverFilters +import tech.salroid.filmy.data.model.MoviePreview +import tech.salroid.filmy.data.model.TvShowPreview +import tech.salroid.filmy.ui.common.components.ErrorWidget +import tech.salroid.filmy.ui.common.components.LoadingWidget +import tech.salroid.filmy.ui.common.components.PaginatedPreviewList +import tech.salroid.filmy.ui.discover.components.DiscoverFilterSheet +import tech.salroid.filmy.ui.movies.components.MovieItem +import tech.salroid.filmy.ui.shows.components.ShowItem +import tech.salroid.filmy.utility.toUserMessage + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiscoverScreen( + isTv: Boolean, + movieItems: LazyPagingItems?, + showItems: LazyPagingItems?, + filters: DiscoverFilters, + genres: List, + onApplyFilters: (DiscoverFilters) -> Unit, + onBackClick: () -> Unit, + onMovieClick: (Int) -> Unit, + onShowClick: (Int) -> Unit +) { + var showFilterSheet by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource( + if (isTv) R.string.discover_shows_title else R.string.discover_movies_title + ) + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { showFilterSheet = true }) { + Icon(Icons.Default.Tune, contentDescription = stringResource(R.string.discover_filters)) + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + if (isTv) { + val shows = showItems ?: return@Box + when (val state = shows.loadState.refresh) { + is LoadState.Loading -> { + LoadingWidget(modifier = Modifier.fillMaxSize()) + } + is LoadState.Error -> { + ErrorWidget( + modifier = Modifier.fillMaxSize(), + message = state.error.toUserMessage(), + onRetryClick = { shows.retry() } + ) + } + else -> { + if (shows.itemCount == 0 && + shows.loadState.append is LoadState.NotLoading && + shows.loadState.append.endOfPaginationReached + ) { + ErrorWidget( + modifier = Modifier.fillMaxSize(), + message = stringResource(R.string.discover_no_results), + onRetryClick = { shows.refresh() } + ) + } else { + PaginatedPreviewList(items = shows) { show -> + ShowItem(show = show, onShowClick = { onShowClick(show.id) }) + } + } + } + } + } else { + val movies = movieItems ?: return@Box + when (val state = movies.loadState.refresh) { + is LoadState.Loading -> { + LoadingWidget(modifier = Modifier.fillMaxSize()) + } + is LoadState.Error -> { + ErrorWidget( + modifier = Modifier.fillMaxSize(), + message = state.error.toUserMessage(), + onRetryClick = { movies.retry() } + ) + } + else -> { + if (movies.itemCount == 0 && + movies.loadState.append is LoadState.NotLoading && + movies.loadState.append.endOfPaginationReached + ) { + ErrorWidget( + modifier = Modifier.fillMaxSize(), + message = stringResource(R.string.discover_no_results), + onRetryClick = { movies.refresh() } + ) + } else { + PaginatedPreviewList(items = movies) { movie -> + MovieItem(movie = movie, onMovieClick = { onMovieClick(movie.id) }) + } + } + } + } + } + } + } + + if (showFilterSheet) { + DiscoverFilterSheet( + initialFilters = filters, + genres = genres, + onApply = { newFilters -> + onApplyFilters(newFilters) + showFilterSheet = false + }, + onDismiss = { showFilterSheet = false } + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/discover/DiscoverViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/discover/DiscoverViewModel.kt new file mode 100644 index 00000000..485f53f3 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/discover/DiscoverViewModel.kt @@ -0,0 +1,80 @@ +package tech.salroid.filmy.ui.discover + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import tech.salroid.filmy.data.local.model.Genre +import tech.salroid.filmy.data.local.model.discover.DiscoverFilters +import tech.salroid.filmy.data.model.MoviePreview +import tech.salroid.filmy.data.model.TvShowPreview +import tech.salroid.filmy.ui.home.MoviesRepository +import tech.salroid.filmy.ui.movies.MoviePreviewMapper +import tech.salroid.filmy.ui.shows.TvShowsPreviewMapper +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class DiscoverViewModel @Inject constructor( + private val moviesRepository: MoviesRepository, + private val moviePreviewMapper: MoviePreviewMapper, + private val tvShowsPreviewMapper: TvShowsPreviewMapper +) : ViewModel() { + + private var initialized = false + var isTv: Boolean = false + private set + + private val _filters = MutableStateFlow(DiscoverFilters()) + val filters: StateFlow = _filters + + private val _genres = MutableStateFlow>(emptyList()) + val genres: StateFlow> = _genres + + val moviesResults: Flow> = _filters + .flatMapLatest { filters -> moviesRepository.discoverMovies(filters) } + .map { pagingData -> pagingData.map(moviePreviewMapper::map) } + .cachedIn(viewModelScope) + + val showsResults: Flow> = _filters + .flatMapLatest { filters -> moviesRepository.discoverTv(filters) } + .map { pagingData -> pagingData.map(tvShowsPreviewMapper::map) } + .cachedIn(viewModelScope) + + fun initialize(isTv: Boolean, keywordId: Int? = null, keywordName: String? = null) { + if (initialized) return + initialized = true + this.isTv = isTv + + if (keywordId != null && keywordName != null) { + _filters.value = DiscoverFilters(keywordId = keywordId, keywordName = keywordName) + } + + viewModelScope.launch { + try { + val response = if (isTv) { + moviesRepository.getTvGenres().first() + } else { + moviesRepository.getMovieGenres().first() + } + _genres.value = response.genres + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + fun applyFilters(newFilters: DiscoverFilters) { + _filters.value = newFilters + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/discover/components/DiscoverFilterSheet.kt b/app/src/main/java/tech/salroid/filmy/ui/discover/components/DiscoverFilterSheet.kt new file mode 100644 index 00000000..0b22e613 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/discover/components/DiscoverFilterSheet.kt @@ -0,0 +1,208 @@ +package tech.salroid.filmy.ui.discover.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Button +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import java.util.Calendar +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.Genre +import tech.salroid.filmy.data.local.model.discover.DiscoverFilters +import tech.salroid.filmy.data.local.model.discover.DiscoverSort +import tech.salroid.filmy.ui.common.components.CategorySelector + +private fun labelFor(sort: DiscoverSort): Int = when (sort) { + DiscoverSort.POPULARITY_DESC -> R.string.discover_sort_popularity + DiscoverSort.VOTE_AVERAGE_DESC -> R.string.discover_sort_rating + DiscoverSort.RELEASE_DATE_DESC -> R.string.discover_sort_newest + DiscoverSort.RELEASE_DATE_ASC -> R.string.discover_sort_oldest +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiscoverFilterSheet( + initialFilters: DiscoverFilters, + genres: List, + onApply: (DiscoverFilters) -> Unit, + onDismiss: () -> Unit +) { + var draftGenreIds by remember { mutableStateOf(initialFilters.genreIds) } + var draftYear by remember { mutableStateOf(initialFilters.year) } + var draftMinRating by remember { mutableStateOf(initialFilters.minRating ?: 0f) } + var draftSortBy by remember { mutableStateOf(initialFilters.sortBy) } + var draftKeywordId by remember { mutableStateOf(initialFilters.keywordId) } + var draftKeywordName by remember { mutableStateOf(initialFilters.keywordName) } + + val currentYear = remember { Calendar.getInstance().get(Calendar.YEAR) } + val yearOptions = remember { listOf(null) + (0..15).map { currentYear - it } } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.discover_filters), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (draftKeywordId != null && draftKeywordName != null) { + FilterChip( + selected = true, + onClick = { + draftKeywordId = null + draftKeywordName = null + }, + label = { Text(draftKeywordName ?: "") }, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + if (genres.isNotEmpty()) { + Text( + text = stringResource(R.string.discover_genre_label), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + genres.forEach { genre -> + val id = genre.id ?: return@forEach + val selected = id in draftGenreIds + FilterChip( + selected = selected, + onClick = { + draftGenreIds = if (selected) { + draftGenreIds - id + } else { + draftGenreIds + id + } + }, + label = { Text(genre.name ?: "") } + ) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column { + Text( + text = stringResource(R.string.discover_year_label), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + CategorySelector( + categories = yearOptions, + selected = draftYear, + label = { it?.toString() ?: stringResource(R.string.discover_any_year) }, + onSelected = { draftYear = it } + ) + } + + Column { + Text( + text = stringResource(R.string.discover_sort_label), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + CategorySelector( + categories = DiscoverSort.entries, + selected = draftSortBy, + label = { stringResource(labelFor(it)) }, + onSelected = { draftSortBy = it } + ) + } + } + + Text( + text = stringResource( + R.string.discover_min_rating_label + ) + ": " + String.format("%.1f", draftMinRating), + style = MaterialTheme.typography.titleMedium + ) + Slider( + value = draftMinRating, + onValueChange = { draftMinRating = it }, + valueRange = 0f..10f, + steps = 19, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + draftGenreIds = emptySet() + draftYear = null + draftMinRating = 0f + draftSortBy = DiscoverSort.POPULARITY_DESC + draftKeywordId = null + draftKeywordName = null + } + ) { + Text(stringResource(R.string.discover_reset)) + } + Button( + modifier = Modifier.weight(1f), + onClick = { + onApply( + DiscoverFilters( + genreIds = draftGenreIds, + year = draftYear, + minRating = draftMinRating.takeIf { it > 0f }, + sortBy = draftSortBy, + keywordId = draftKeywordId, + keywordName = draftKeywordName + ) + ) + } + ) { + Text(stringResource(R.string.discover_apply)) + } + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/franchise/FranchiseScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/franchise/FranchiseScreen.kt new file mode 100644 index 00000000..ba86acbe --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/franchise/FranchiseScreen.kt @@ -0,0 +1,158 @@ +package tech.salroid.filmy.ui.franchise + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.collection.CollectionMovie +import tech.salroid.filmy.ui.common.components.LoadingWidget +import tech.salroid.filmy.ui.common.components.PreviewList +import tech.salroid.filmy.utility.toReadableDate + +@Composable +fun FranchiseScreen( + collectionId: Int, + title: String, + viewModel: FranchiseViewModel = hiltViewModel(), + onMovieClick: (Int) -> Unit, + onBackClick: () -> Unit +) { + val collection by viewModel.collection.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + + LaunchedEffect(collectionId) { + viewModel.loadCollection(collectionId) + } + + FranchiseContent( + title = collection?.name ?: title, + items = collection?.parts ?: emptyList(), + isLoading = isLoading, + onMovieClick = onMovieClick, + onBackClick = onBackClick + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FranchiseContent( + title: String, + items: List, + isLoading: Boolean, + onMovieClick: (Int) -> Unit, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + when { + isLoading -> { + LoadingWidget(modifier = Modifier.padding(paddingValues).fillMaxSize()) + } + items.isEmpty() -> { + Box( + modifier = Modifier.padding(paddingValues).fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(stringResource(R.string.list_empty)) + } + } + else -> { + PreviewList( + modifier = Modifier + .padding(paddingValues) + .padding(top = 16.dp), + items = items.size + ) { index -> + val movie = items[index] + FranchiseMovieItem( + movie = movie, + onClick = { onMovieClick(movie.id) } + ) + } + } + } + } +} + +@Composable +private fun FranchiseMovieItem( + movie: CollectionMovie, + onClick: () -> Unit +) { + Card( + shape = RoundedCornerShape(corner = CornerSize(8.dp)), + colors = CardDefaults.cardColors(containerColor = Color.Transparent) + ) { + Column( + modifier = Modifier.clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .height(170.dp) + .clip(RoundedCornerShape(corner = CornerSize(8.dp))), + contentScale = ContentScale.Crop, + model = stringResource(R.string.movie_poster_url, movie.posterPath ?: ""), + contentDescription = movie.title + ) + Text( + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + text = movie.title ?: "" + ) + Text( + modifier = Modifier.padding(top = 4.dp).alpha(0.8f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + text = movie.releaseDate?.toReadableDate() ?: "" + ) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/franchise/FranchiseViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/franchise/FranchiseViewModel.kt new file mode 100644 index 00000000..49542c05 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/franchise/FranchiseViewModel.kt @@ -0,0 +1,38 @@ +package tech.salroid.filmy.ui.franchise + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import tech.salroid.filmy.data.local.model.collection.CollectionDetailsResponse +import tech.salroid.filmy.ui.home.MoviesRepository +import javax.inject.Inject + +@HiltViewModel +class FranchiseViewModel @Inject constructor( + private val moviesRepository: MoviesRepository +) : ViewModel() { + + private val _collection = MutableStateFlow(null) + val collection: StateFlow = _collection.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + fun loadCollection(collectionId: Int) { + viewModelScope.launch { + _isLoading.emit(true) + moviesRepository.getCollectionDetails(collectionId) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _collection.emit(it) } + _isLoading.emit(false) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/full/AllMoviesScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/full/AllMoviesScreen.kt new file mode 100644 index 00000000..b201682f --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/full/AllMoviesScreen.kt @@ -0,0 +1,143 @@ +package tech.salroid.filmy.ui.full + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.CastMovie +import tech.salroid.filmy.data.local.model.CastCrewMoviesResponse +import tech.salroid.filmy.ui.cast_crew.CastCrewViewModel +import tech.salroid.filmy.ui.common.components.PreviewItem +import tech.salroid.filmy.ui.common.components.PreviewList +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import tech.salroid.filmy.ui.LocalWindowSizeClass +import tech.salroid.filmy.ui.theme.AppTheme +import tech.salroid.filmy.utility.toReadableDate + +@Composable +fun AllMoviesScreen( + memberId: Int, + isTv: Boolean, + title: String, + viewModel: CastCrewViewModel = hiltViewModel(), + onMovieClick: (Int, String) -> Unit, + onBackClick: () -> Unit +) { + val moviesResponse by viewModel.uiStateCastCrewMovies.collectAsState() + + LaunchedEffect(memberId, isTv) { + if (isTv) { + viewModel.getCastCrewTvShows(memberId.toString()) + } else { + viewModel.getCastCrewMovies(memberId.toString()) + } + } + + AllMoviesContent( + title = title, + moviesResponse = moviesResponse, + onMovieClick = onMovieClick, + onBackClick = onBackClick + ) +} + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class +) +@Composable +fun AllMoviesContent( + title: String, + moviesResponse: CastCrewMoviesResponse?, + onMovieClick: (Int, String) -> Unit, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + moviesResponse?.let { data -> + if (data.castMovies.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text("No entries found.") + } + } else { + PreviewList( + modifier = Modifier + .padding(paddingValues) + .padding(top = 16.dp), + items = data.castMovies.size + ) { index -> + val movie = data.castMovies[index] + PreviewItem( + title = movie.title ?: movie.name ?: "", + posterUrl = "https://image.tmdb.org/t/p/w342${movie.posterPath}", + readableDate = movie.releaseDate?.toReadableDate() ?: "", + onItemClick = { onMovieClick(movie.id ?: 0, movie.title ?: "") } + ) + } + } + } ?: run { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + LoadingIndicator() + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun AllMoviesScreenPreview() { + val sampleMovies = CastCrewMoviesResponse( + castMovies = arrayListOf( + CastMovie(id = 1, title = "Fight Club", posterPath = null), + CastMovie(id = 2, title = "Seven", posterPath = null), + CastMovie(id = 3, title = "Inglourious Basterds", posterPath = null), + CastMovie(id = 4, title = "Once Upon a Time in Hollywood", posterPath = null), + CastMovie(id = 5, title = "Moneyball", posterPath = null), + CastMovie(id = 6, title = "Ad Astra", posterPath = null) + ) + ) + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) { + AppTheme { + AllMoviesContent( + title = "Brad Pitt", + moviesResponse = sampleMovies, + onMovieClick = { _, _ -> }, + onBackClick = {} + ) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/gallery/GalleryScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/gallery/GalleryScreen.kt new file mode 100644 index 00000000..7d53cd74 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/gallery/GalleryScreen.kt @@ -0,0 +1,217 @@ +package tech.salroid.filmy.ui.gallery + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.ImageItem +import tech.salroid.filmy.ui.common.components.LoadingWidget +import tech.salroid.filmy.ui.common.components.PreviewList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GalleryScreen( + mediaId: Int, + isTv: Boolean, + viewModel: GalleryViewModel = hiltViewModel(), + onBackClick: () -> Unit +) { + val images by viewModel.images.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + + var selectedTab by remember { mutableIntStateOf(0) } + var fullScreenIndex by remember { mutableStateOf(null) } + + LaunchedEffect(mediaId, isTv) { + viewModel.loadImages(mediaId, isTv) + } + + val currentList = if (selectedTab == 0) images?.backdrops.orEmpty() else images?.posters.orEmpty() + + if (fullScreenIndex != null) { + FullScreenImagePager( + images = currentList, + startIndex = fullScreenIndex ?: 0, + onDismiss = { fullScreenIndex = null } + ) + return + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Photos") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + when { + isLoading -> LoadingWidget(modifier = Modifier.fillMaxSize()) + currentList.isEmpty() && images != null -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.list_empty)) + } + } + else -> { + PreviewList( + modifier = Modifier.fillMaxSize().padding(top = 12.dp), + items = currentList.size, + header = { + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + SegmentedButton( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2) + ) { Text("Backdrops") } + SegmentedButton( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2) + ) { Text("Posters") } + } + } + ) { index -> + val image = currentList[index] + AsyncImage( + model = "https://image.tmdb.org/t/p/w500${image.filePath}", + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(if (selectedTab == 0) 16f / 9f else 2f / 3f) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { fullScreenIndex = index }, + contentScale = ContentScale.Crop + ) + } + } + } + } + } +} + +@Composable +private fun FullScreenImagePager( + images: List, + startIndex: Int, + onDismiss: () -> Unit +) { + val pagerState = rememberPagerState(initialPage = startIndex) { images.size } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + ZoomableImage(imageUrl = "https://image.tmdb.org/t/p/original${images[page].filePath}") + } + + IconButton( + onClick = onDismiss, + modifier = Modifier + .statusBarsPadding() + .padding(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = "Close", + tint = Color.White + ) + } + } +} + +@Composable +private fun ZoomableImage(imageUrl: String) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + + val transformableState = rememberTransformableState { zoomChange, panChange, _ -> + val newScale = (scale * zoomChange).coerceIn(1f, 5f) + scale = newScale + if (newScale > 1f) { + offsetX += panChange.x + offsetY += panChange.y + } else { + // Snap back to centered once zoomed out, so the pager's own + // swipe isn't fighting a leftover pan offset next time in. + offsetX = 0f + offsetY = 0f + } + } + + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offsetX, + translationY = offsetY + ) + .transformable( + state = transformableState, + // Only claim the pan gesture once actually zoomed in - + // otherwise a plain horizontal drag at 1x is a page swipe, + // and this modifier must not steal it from HorizontalPager. + canPan = { scale > 1f } + ), + contentScale = ContentScale.Fit + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/gallery/GalleryViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/gallery/GalleryViewModel.kt new file mode 100644 index 00000000..84be7cbd --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/gallery/GalleryViewModel.kt @@ -0,0 +1,38 @@ +package tech.salroid.filmy.ui.gallery + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import tech.salroid.filmy.data.local.model.ImagesResponse +import tech.salroid.filmy.ui.home.MoviesRepository +import javax.inject.Inject + +@HiltViewModel +class GalleryViewModel @Inject constructor( + private val moviesRepository: MoviesRepository +) : ViewModel() { + + private val _images = MutableStateFlow(null) + val images: StateFlow = _images.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + fun loadImages(id: Int, isTv: Boolean) { + viewModelScope.launch { + _isLoading.emit(true) + val flow = if (isTv) moviesRepository.getTvImages(id.toString()) else moviesRepository.getMovieImages(id.toString()) + flow.flowOn(Dispatchers.IO) + .catch { } + .collect { _images.emit(it) } + _isLoading.emit(false) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/home/AccountRepository.kt b/app/src/main/java/tech/salroid/filmy/ui/home/AccountRepository.kt index 4844fba4..72033fc2 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/home/AccountRepository.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/home/AccountRepository.kt @@ -2,8 +2,16 @@ package tech.salroid.filmy.ui.home import android.content.SharedPreferences import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import tech.salroid.filmy.data.local.db.FilmyDatabase import tech.salroid.filmy.data.local.db.entity.Profile +import tech.salroid.filmy.data.local.model.MoviesResponse +import tech.salroid.filmy.data.local.model.TvShowResponse +import tech.salroid.filmy.data.local.model.account.CreateListResponse +import tech.salroid.filmy.data.local.model.account.RatedResponse +import tech.salroid.filmy.data.local.model.account.TmdbListDetailsResponse +import tech.salroid.filmy.data.local.model.account.TmdbListsResponse +import tech.salroid.filmy.data.local.model.account.TmdbStatusResponse import tech.salroid.filmy.data.local.model.login.* import tech.salroid.filmy.data.network.AccountApiHelper import tech.salroid.filmy.utility.PreferenceHelper.SESSION_ID @@ -45,4 +53,81 @@ class AccountRepository @Inject constructor( fun getSessionIdFromPref(): String? = appPref.getString(SESSION_ID, null) fun getProfileFromLocal(): Profile? = filmyDatabase.accountDao().getProfile().firstOrNull() + + /** + * Reactive to the local `profile` table - unlike [getProfileFromLocal], + * this stays in sync regardless of which ViewModel instance (Account + * screen, MyLists, details screen, etc.) actually performed the + * login/logout that wrote to it. + */ + fun getProfileFlow(): Flow = + filmyDatabase.accountDao().getProfileFlow().map { it.firstOrNull() } + + fun isLoggedIn(): Boolean = getSessionIdFromPref() != null + + fun markFavorite( + accountId: Int, + sessionId: String, + mediaType: String, + mediaId: Int, + favorite: Boolean + ): Flow = + accountApiHelper.markAsFavorite(accountId, sessionId, mediaType, mediaId, favorite) + + fun markWatchlist( + accountId: Int, + sessionId: String, + mediaType: String, + mediaId: Int, + watchlist: Boolean + ): Flow = + accountApiHelper.markAsWatchlist(accountId, sessionId, mediaType, mediaId, watchlist) + + fun getFavoriteMovies(accountId: Int, sessionId: String, page: Int): Flow = + accountApiHelper.getFavoriteMovies(accountId, sessionId, page) + + fun getFavoriteTv(accountId: Int, sessionId: String, page: Int): Flow = + accountApiHelper.getFavoriteTv(accountId, sessionId, page) + + fun getWatchlistMovies(accountId: Int, sessionId: String, page: Int): Flow = + accountApiHelper.getWatchlistMovies(accountId, sessionId, page) + + fun getWatchlistTv(accountId: Int, sessionId: String, page: Int): Flow = + accountApiHelper.getWatchlistTv(accountId, sessionId, page) + + fun createList(sessionId: String, name: String, description: String = ""): Flow = + accountApiHelper.createList(sessionId, name, description) + + fun getLists(accountId: Int, sessionId: String, page: Int = 1): Flow = + accountApiHelper.getLists(accountId, sessionId, page) + + fun getListDetails(listId: Int, sessionId: String, page: Int = 1): Flow = + accountApiHelper.getListDetails(listId, sessionId, page) + + fun addToList(listId: Int, sessionId: String, mediaId: Int): Flow = + accountApiHelper.addToList(listId, sessionId, mediaId) + + fun removeFromList(listId: Int, sessionId: String, mediaId: Int): Flow = + accountApiHelper.removeFromList(listId, sessionId, mediaId) + + fun deleteList(listId: Int, sessionId: String): Flow = + accountApiHelper.deleteList(listId, sessionId) + + fun rateMovie(movieId: Int, sessionId: String, value: Float): Flow = + accountApiHelper.rateMovie(movieId, sessionId, value) + + fun deleteMovieRating(movieId: Int, sessionId: String): Flow = + accountApiHelper.deleteMovieRating(movieId, sessionId) + + fun rateTv(tvId: Int, sessionId: String, value: Float): Flow = + accountApiHelper.rateTv(tvId, sessionId, value) + + fun deleteTvRating(tvId: Int, sessionId: String): Flow = + accountApiHelper.deleteTvRating(tvId, sessionId) + + fun getRatedMovies(accountId: Int, sessionId: String, page: Int): Flow = + accountApiHelper.getRatedMovies(accountId, sessionId, page) + + fun getRatedTv(accountId: Int, sessionId: String, page: Int): Flow = + accountApiHelper.getRatedTv(accountId, sessionId, page) } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/home/AccountSyncRepository.kt b/app/src/main/java/tech/salroid/filmy/ui/home/AccountSyncRepository.kt new file mode 100644 index 00000000..597e6f67 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/home/AccountSyncRepository.kt @@ -0,0 +1,304 @@ +package tech.salroid.filmy.ui.home + +import kotlinx.coroutines.flow.first +import tech.salroid.filmy.data.local.db.entity.MovieDetails +import tech.salroid.filmy.data.local.model.MoviesResponse +import tech.salroid.filmy.data.local.model.TvShowResponse +import tech.salroid.filmy.ui.details.toMovieDetails +import javax.inject.Inject +import javax.inject.Singleton + +/** (id, type) keys TMDB already has favorited/watchlisted/rated, after pulling them locally. */ +private data class PulledKeys( + val favoriteKeys: Set>, + val watchlistKeys: Set>, + val ratedKeys: Set> +) + +@Singleton +class AccountSyncRepository @Inject constructor( + private val accountRepository: AccountRepository, + private val moviesRepository: MoviesRepository +) { + private var hasSyncedThisSession = false + + /** + * Two-way merges local Watched/Watchlist with the user's real TMDB account: + * pulls TMDB's favorite/watchlist lists into local storage, then pushes any + * local watched/watchlist entries TMDB doesn't already have (e.g. items marked + * before the user ever logged in). Runs at most once per app process (guarded + * by [hasSyncedThisSession]) and is a no-op if the user isn't logged in. + * + * Note: this is a union merge, not full two-way sync — it never *removes* + * anything from either side, so an item un-watchlisted locally while logged + * out but still watchlisted on TMDB will reappear locally. Deletion tracking + * would need real conflict resolution (e.g. last-modified timestamps), which + * is out of scope here. + */ + suspend fun syncIfNeeded() { + if (hasSyncedThisSession) return + if (!accountRepository.isLoggedIn()) return + val sessionId = accountRepository.getSessionIdFromPref() ?: return + val accountId = accountRepository.getProfileFromLocal()?.id ?: return + + hasSyncedThisSession = true + try { + val pulled = pullFavoritesAndWatchlist(accountId, sessionId) + pushLocalOnlyEntries(accountId, sessionId, pulled) + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** Pulls favorites/watchlist/ratings from TMDB, saves them locally, and returns what TMDB already has. */ + private suspend fun pullFavoritesAndWatchlist( + accountId: Int, + sessionId: String + ): PulledKeys { + data class MediaFlags( + var favorite: Boolean = false, + var watchlist: Boolean = false, + var rating: Float? = null + ) + + val movieFlags = mutableMapOf() + val tvFlags = mutableMapOf() + + suspend fun collectMoviePages( + fetchPage: suspend (Int) -> MoviesResponse, + mark: (MediaFlags) -> Unit + ) { + var page = 1 + var totalPages = 1 + while (page <= totalPages) { + val response = fetchPage(page) + totalPages = response.totalPages ?: 1 + response.results.forEach { movie -> + mark(movieFlags.getOrPut(movie.id) { MediaFlags() }) + } + page++ + } + } + + suspend fun collectTvPages( + fetchPage: suspend (Int) -> TvShowResponse, + mark: (MediaFlags) -> Unit + ) { + var page = 1 + var totalPages = 1 + while (page <= totalPages) { + val response = fetchPage(page) + totalPages = response.totalPages ?: 1 + response.results.forEach { show -> + mark(tvFlags.getOrPut(show.id) { MediaFlags() }) + } + page++ + } + } + + suspend fun collectRatedMoviePages() { + var page = 1 + var totalPages = 1 + while (page <= totalPages) { + val response = accountRepository.getRatedMovies(accountId, sessionId, page).first() + totalPages = response.totalPages ?: 1 + response.results.forEach { item -> + movieFlags.getOrPut(item.id) { MediaFlags() }.rating = item.rating + } + page++ + } + } + + suspend fun collectRatedTvPages() { + var page = 1 + var totalPages = 1 + while (page <= totalPages) { + val response = accountRepository.getRatedTv(accountId, sessionId, page).first() + totalPages = response.totalPages ?: 1 + response.results.forEach { item -> + tvFlags.getOrPut(item.id) { MediaFlags() }.rating = item.rating + } + page++ + } + } + + collectMoviePages({ accountRepository.getFavoriteMovies(accountId, sessionId, it).first() }) { it.favorite = true } + collectMoviePages({ accountRepository.getWatchlistMovies(accountId, sessionId, it).first() }) { it.watchlist = true } + collectTvPages({ accountRepository.getFavoriteTv(accountId, sessionId, it).first() }) { it.favorite = true } + collectTvPages({ accountRepository.getWatchlistTv(accountId, sessionId, it).first() }) { it.watchlist = true } + collectRatedMoviePages() + collectRatedTvPages() + + // Fetch full details only for items whose flags actually differ from what's + // already stored locally, then write everything in one batch — so the + // Collections screen sees at most one clean update, not one per item. + val pendingWrites = mutableListOf() + + movieFlags.forEach { (id, flags) -> + val existing = moviesRepository.getMovieDetailsFromLocal(id, 0) + if (existing != null && + existing.watched == flags.favorite && + existing.watchlist == flags.watchlist && + existing.userRating == flags.rating + ) { + return@forEach + } + try { + val details = moviesRepository.getMovieDetailsFromNetwork(id.toString()).first() + pendingWrites.add( + details.copy( + watched = flags.favorite, + watchlist = flags.watchlist, + userRating = flags.rating, + type = 0 + ) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } + + tvFlags.forEach { (id, flags) -> + val existing = moviesRepository.getMovieDetailsFromLocal(id, 1) + if (existing != null && + existing.watched == flags.favorite && + existing.watchlist == flags.watchlist && + existing.userRating == flags.rating + ) { + return@forEach + } + try { + val details = moviesRepository.getTvShowDetailsFromNetwork(id.toString()).first() + pendingWrites.add( + details.toMovieDetails(existing = existing, watched = flags.favorite, watchlist = flags.watchlist) + .copy(userRating = flags.rating) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } + + moviesRepository.saveMovieDetailsBatch(pendingWrites) + + val favoriteKeys = movieFlags.filterValues { it.favorite }.keys.map { it to 0 }.toSet() + + tvFlags.filterValues { it.favorite }.keys.map { it to 1 }.toSet() + val watchlistKeys = movieFlags.filterValues { it.watchlist }.keys.map { it to 0 }.toSet() + + tvFlags.filterValues { it.watchlist }.keys.map { it to 1 }.toSet() + val ratedKeys = movieFlags.filterValues { it.rating != null }.keys.map { it to 0 }.toSet() + + tvFlags.filterValues { it.rating != null }.keys.map { it to 1 }.toSet() + + return PulledKeys(favoriteKeys, watchlistKeys, ratedKeys) + } + + /** Pushes local watched/watchlist/rating rows TMDB doesn't already know about (e.g. set before login). */ + private suspend fun pushLocalOnlyEntries( + accountId: Int, + sessionId: String, + pulled: PulledKeys + ) { + val localWatched = moviesRepository.getWatched().first() + val localWatchlist = moviesRepository.getWatchlist().first() + val localRated = moviesRepository.getRated().first() + + localWatched.forEach { movie -> + if (movie.id to movie.type !in pulled.favoriteKeys) { + try { + accountRepository.markFavorite( + accountId, sessionId, mediaType(movie.type), movie.id, favorite = true + ).first() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + localWatchlist.forEach { movie -> + if (movie.id to movie.type !in pulled.watchlistKeys) { + try { + accountRepository.markWatchlist( + accountId, sessionId, mediaType(movie.type), movie.id, watchlist = true + ).first() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + localRated.forEach { movie -> + val rating = movie.userRating ?: return@forEach + if (movie.id to movie.type !in pulled.ratedKeys) { + try { + if (movie.type == 1) { + accountRepository.rateTv(movie.id, sessionId, rating).first() + } else { + accountRepository.rateMovie(movie.id, sessionId, rating).first() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + + /** + * Pushes [movieDetails]'s current watched(->favorite)/watchlist state to TMDB. + * No-ops (returns true) if the user isn't logged in — there's nothing to sync, + * so callers shouldn't treat that as a failure worth rolling back for. + * Returns false only when a logged-in push actually fails (network/API error), + * so callers can roll back their optimistic local write. + */ + suspend fun pushItemState(movieDetails: MovieDetails): Boolean { + if (!accountRepository.isLoggedIn()) return true + val sessionId = accountRepository.getSessionIdFromPref() ?: return true + val accountId = accountRepository.getProfileFromLocal()?.id ?: return true + + return try { + val type = mediaType(movieDetails.type) + accountRepository.markFavorite( + accountId, sessionId, type, movieDetails.id, movieDetails.watched + ).first() + accountRepository.markWatchlist( + accountId, sessionId, type, movieDetails.id, movieDetails.watchlist + ).first() + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + /** + * Pushes [movieDetails]'s current [MovieDetails.userRating] to TMDB — rates it if + * non-null, deletes any existing TMDB rating if null. Same no-op-if-logged-out / + * false-on-failure contract as [pushItemState], but kept separate since rating is + * a distinct user action, not something to redundantly re-push on every + * watched/watchlist toggle. + */ + suspend fun pushRating(movieDetails: MovieDetails): Boolean { + if (!accountRepository.isLoggedIn()) return true + val sessionId = accountRepository.getSessionIdFromPref() ?: return true + + return try { + val rating = movieDetails.userRating + if (movieDetails.type == 1) { + if (rating != null) { + accountRepository.rateTv(movieDetails.id, sessionId, rating).first() + } else { + accountRepository.deleteTvRating(movieDetails.id, sessionId).first() + } + } else { + if (rating != null) { + accountRepository.rateMovie(movieDetails.id, sessionId, rating).first() + } else { + accountRepository.deleteMovieRating(movieDetails.id, sessionId).first() + } + } + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + private fun mediaType(type: Int): String = if (type == 1) "tv" else "movie" +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/home/LoginLauncher.kt b/app/src/main/java/tech/salroid/filmy/ui/home/LoginLauncher.kt new file mode 100644 index 00000000..d865ba79 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/home/LoginLauncher.kt @@ -0,0 +1,64 @@ +package tech.salroid.filmy.ui.home + +import android.content.Context +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContract +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner + +/** + * Drives the TMDB v4 Custom-Tabs OAuth flow (request token -> Custom Tab -> + * resume -> access token -> session -> profile) against [viewModel], usable + * from any screen - not just the Account screen. Returns a callback that + * kicks the flow off; the rest happens via this composable's own effects, so + * the caller just needs to observe [LoginViewModel.uiStateProfile] to know + * when login completes. + */ +@Composable +fun rememberLoginLauncher(viewModel: LoginViewModel): () -> Unit { + val customTabLauncher = rememberLauncherForActivityResult( + contract = object : ActivityResultContract() { + override fun createIntent(context: Context, input: String): Intent { + val customTabsIntent = CustomTabsIntent.Builder().build().intent + customTabsIntent.data = input.toUri() + return customTabsIntent + } + + override fun parseResult(resultCode: Int, intent: Intent?): Int = resultCode + } + ) { } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, viewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + if (viewModel.requestToken != null && viewModel.sessionId == null) { + viewModel.getAccessToken() + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + val uiStateToken by viewModel.uiStateToken.collectAsState() + LaunchedEffect(uiStateToken) { + uiStateToken?.requestToken?.let { token -> + if (viewModel.accessToken == null) { + val url = "https://www.themoviedb.org/auth/access?request_token=$token" + customTabLauncher.launch(url) + } + } + } + + return { viewModel.getRequestToken() } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/home/LoginViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/home/LoginViewModel.kt index c3947e18..04f29602 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/home/LoginViewModel.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/home/LoginViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import tech.salroid.filmy.data.local.db.entity.Profile import tech.salroid.filmy.data.local.model.login.* import javax.inject.Inject @@ -16,25 +17,46 @@ class LoginViewModel @Inject constructor( ) : ViewModel() { private val _uiStateToken = MutableStateFlow(null) - private val _uiStateProfile = MutableStateFlow(null) + private val _isLoggingOut = MutableStateFlow(false) + private val _isAuthenticating = MutableStateFlow(false) val uiStateToken: StateFlow = _uiStateToken.asStateFlow() - val uiStateProfile: StateFlow = _uiStateProfile.asStateFlow() + val isLoggingOut: StateFlow = _isLoggingOut.asStateFlow() + + /** + * True from the moment login starts until it either succeeds (profile + * saved) or fails at any step - including the user closing the Custom + * Tab without approving the request token, which surfaces as a failure + * when exchanging it for an access token. Never gets stuck on true. + */ + val isAuthenticating: StateFlow = _isAuthenticating.asStateFlow() + + /** + * Reactive to the local `profile` table via [AccountRepository.getProfileFlow] + * - every screen's own [LoginViewModel] instance (Account, MyLists, details + * screen, etc.) observes the same underlying row, so a login/logout + * completed from any one of them is reflected everywhere else too, + * without needing to navigate away and back. + */ + val uiStateProfile: StateFlow = accountRepository.getProfileFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) var requestToken: String? = null var accessToken: String? = null var sessionId: String? = null init { - getProfileFromLocal() + repairStaleLocalProfile() } fun getRequestToken() { + _isAuthenticating.value = true viewModelScope.launch { val requestTokenData = RequestTokenData(redirectTo = "https://www.themoviedb.org/") accountRepository.getRequestToken(requestTokenData) .flowOn(Dispatchers.IO) .catch { it.printStackTrace() + _isAuthenticating.value = false }.collect { requestToken = it.requestToken _uiStateToken.emit(it) @@ -48,7 +70,13 @@ class LoginViewModel @Inject constructor( accountRepository.getAccessToken(requestTokenData) .flowOn(Dispatchers.IO) .catch { + // Reached when the user closed the Custom Tab without + // approving the request token - exchanging it then + // fails. Clear it so a later, unrelated app resume + // doesn't keep retrying a dead token. it.printStackTrace() + requestToken = null + _isAuthenticating.value = false }.collect { accessToken = it.accessToken getSession() @@ -63,6 +91,7 @@ class LoginViewModel @Inject constructor( .flowOn(Dispatchers.IO) .catch { it.printStackTrace() + _isAuthenticating.value = false }.collect { sessionId = it.sessionId accountRepository.storeSessionId(sessionId) @@ -79,45 +108,54 @@ class LoginViewModel @Inject constructor( .flowOn(Dispatchers.IO) .catch { it.printStackTrace() - }.collect { - _uiStateProfile.emit(it) - // Save Profile in local - viewModelScope.launch(Dispatchers.IO) { - accountRepository.saveProfileToLocal(it) + _isAuthenticating.value = false + }.collect { profile -> + // Writing here is enough - uiStateProfile is reactive to + // this same local table, so it (and every other screen's + // LoginViewModel instance) picks this up automatically. + withContext(Dispatchers.IO) { + accountRepository.saveProfileToLocal(profile) } + _isAuthenticating.value = false } } } - private fun getProfileFromLocal() { + /** + * A cached profile with no matching session is stale (e.g. a prior + * logout's cleanup got interrupted) - don't show a "logged in" UI for a + * session that no longer exists. Just a repair write; uiStateProfile + * picks up the result reactively. + */ + private fun repairStaleLocalProfile() { viewModelScope.launch(Dispatchers.IO) { - val profile = accountRepository.getProfileFromLocal() - _uiStateProfile.emit(profile) + if (!accountRepository.isLoggedIn()) { + accountRepository.clearProfile() + } } } fun logout() { viewModelScope.launch { val sessionId = accountRepository.getSessionIdFromPref() - sessionId?.let { id -> - accountRepository.deleteSession(id) + _isLoggingOut.value = true + + // Best-effort server-side revoke. Whether this succeeds, fails, + // or returns success=false, the local logout below still always + // runs — the user should never be stuck showing as "logged in" + // in the UI just because the network call had a hiccup. + if (sessionId != null) { + accountRepository.deleteSession(sessionId) .flowOn(Dispatchers.IO) - .catch { - it.printStackTrace() - }.collect { - if (it.success == true) { - // Delete DB Profile - viewModelScope.launch(Dispatchers.IO) { - accountRepository.clearProfile() - } - // Nullify Profile Data - _uiStateProfile.emit(null) + .catch { it.printStackTrace() } + .collect { } + } - // Nullify Session ID - accountRepository.storeSessionId(null) - } - } + withContext(Dispatchers.IO) { + accountRepository.clearProfile() } + accountRepository.storeSessionId(null) + _isLoggingOut.value = false } } } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/home/MainActivity.kt b/app/src/main/java/tech/salroid/filmy/ui/home/MainActivity.kt deleted file mode 100644 index d39baa7b..00000000 --- a/app/src/main/java/tech/salroid/filmy/ui/home/MainActivity.kt +++ /dev/null @@ -1,116 +0,0 @@ -package tech.salroid.filmy.ui.home - -import android.content.Intent -import android.os.Build -import android.os.Bundle -import androidx.activity.OnBackPressedCallback -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.WindowCompat -import androidx.lifecycle.lifecycleScope -import androidx.navigation.findNavController -import androidx.navigation.ui.NavigationUI -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import tech.salroid.filmy.R -import tech.salroid.filmy.databinding.ActivityMainBinding -import tech.salroid.filmy.ui.intro.FilmyIntroActivity -import tech.salroid.filmy.ui.search.SearchViewModel -import tech.salroid.filmy.utility.PreferenceHelper.isColdStart -import tech.salroid.filmy.utility.PreferenceHelper.setColdStartDone -import tech.salroid.filmy.utility.themeSystemBars - -@AndroidEntryPoint -class MainActivity : AppCompatActivity() { - - private val viewModel: MainViewModel by viewModels() - private val viewModelSearch: SearchViewModel by viewModels() - private var throughShortcut: Boolean? = null - private lateinit var binding: ActivityMainBinding - - override fun onCreate(savedInstanceState: Bundle?) { - setTheme(R.style.AppTheme) - super.onCreate(savedInstanceState) - - // For recreate case - if (savedInstanceState != null && Build.VERSION.SDK_INT >= 35) { - WindowCompat.setDecorFitsSystemWindows(window, false) - } - - // For Backward Compatibility - WindowCompat.enableEdgeToEdge(window) - - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - throughShortcut = intent.getBooleanExtra("throughShortcut", false) - themeSystemBars(lightStatusBar = true, navigationColorAsStatus = false) - - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (viewModelSearch.isSearchOpen.value) { - viewModelSearch.closeSearch() - } else { - if (isEnabled) { - isEnabled = false - onBackPressedDispatcher.onBackPressed() - } - } - } - }) - introLogic() - setupNavigation() - observerUiStates() - } - - private fun observerUiStates() { - lifecycleScope.launch { - /*viewModelSearch.uiStateSearchView.collect { - it?.let { - val margin = when (it) { - SearchViewUiState.Hidden -> 0 - SearchViewUiState.Visible -> binding.navigationBarView.height - } - animateBottomNavigation(margin) - } - }*/ - } - - lifecycleScope.launch { - viewModel.uiStateNavigationVisibility.collect { - // binding.mainAppBarContent.root.isVisible = it - // animateTopNavigation(it) - } - } - } - - private fun animateBottomNavigation(margin: Int) { - /*binding.navigationBarView.clearAnimation() - binding.navigationBarView - .animate() - .translationY(margin.toFloat()).apply { - duration = 175 - startDelay = if (margin == 0) 100 else 0 - interpolator = - if (margin == 0) LinearOutSlowInInterpolator() else FastOutLinearInInterpolator() - }*/ - } - - private fun setupNavigation() { - val navController = findNavController(R.id.navHostFragment) - NavigationUI.setupWithNavController(binding.navigationBarView, navController) - - if (throughShortcut == true) { - navController.navigate(R.id.collections) - binding.navigationBarView.selectedItemId = R.id.collections - } - } - - private fun introLogic() { - if (isColdStart(this)) { - val intent = Intent(this@MainActivity, FilmyIntroActivity::class.java) - startActivity(intent) - setColdStartDone(this) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/home/MainActivityCompose.kt b/app/src/main/java/tech/salroid/filmy/ui/home/MainActivityCompose.kt new file mode 100644 index 00000000..1a6da7c5 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/home/MainActivityCompose.kt @@ -0,0 +1,59 @@ +package tech.salroid.filmy.ui.home + +import android.os.Bundle +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import dagger.hilt.android.AndroidEntryPoint +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.FilmyApp +import tech.salroid.filmy.ui.theme.AppTheme +import tech.salroid.filmy.utility.PreferenceHelper + +@AndroidEntryPoint +class MainActivityCompose : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen().setKeepOnScreenCondition { false } + super.onCreate(savedInstanceState) + + val throughShortcut = intent.getBooleanExtra("throughShortcut", false) + + setContent { + val systemDark = isSystemInDarkTheme() + val themeMode = PreferenceHelper.getCurrentThemeMode(this) + + val darkTheme = when (themeMode) { + getString(R.string.mode_night_no) -> false + getString(R.string.mode_night_yes) -> true + else -> systemDark + } + + val statusBarStyle = + if (darkTheme) { + SystemBarStyle.dark(Color.Transparent.toArgb()) + } else { + SystemBarStyle.light( + Color.Transparent.toArgb(), + Color.Transparent.toArgb() + ) + } + + enableEdgeToEdge( + statusBarStyle = statusBarStyle, + navigationBarStyle = SystemBarStyle.auto( + Color.Transparent.toArgb(), + Color.Transparent.toArgb(), + ) { !darkTheme }, + ) + AppTheme(darkTheme = darkTheme) { + FilmyApp(throughShortcut = throughShortcut) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/home/MoviesFragment.kt b/app/src/main/java/tech/salroid/filmy/ui/home/MoviesFragment.kt index bc8229dc..a72cb6ca 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/home/MoviesFragment.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/home/MoviesFragment.kt @@ -29,7 +29,7 @@ import tech.salroid.filmy.data.local.model.MoviesUiState import tech.salroid.filmy.databinding.FragmentMoviesBinding import tech.salroid.filmy.ui.adapters.MoviesAdapter import tech.salroid.filmy.ui.details.MovieDetailsActivity -import tech.salroid.filmy.ui.search.SearchViewModel +import tech.salroid.filmy.ui.search.SearchViewModelLegacy import tech.salroid.filmy.utility.FilmyUtility.getGridLayoutManager import tech.salroid.filmy.utility.isDarkThemeActivated import tech.salroid.filmy.utility.showSnackBar @@ -38,7 +38,7 @@ import tech.salroid.filmy.utility.showSnackBar class MoviesFragment : Fragment() { private val viewModel: MainViewModel by activityViewModels() - private val viewModelSearch: SearchViewModel by activityViewModels() + private val viewModelSearch: SearchViewModelLegacy by activityViewModels() private var adapter: MoviesAdapter? = null private var _binding: FragmentMoviesBinding? = null private val binding get() = _binding!! diff --git a/app/src/main/java/tech/salroid/filmy/ui/home/MoviesRepository.kt b/app/src/main/java/tech/salroid/filmy/ui/home/MoviesRepository.kt index a498b862..045637b3 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/home/MoviesRepository.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/home/MoviesRepository.kt @@ -1,30 +1,75 @@ package tech.salroid.filmy.ui.home import androidx.paging.PagingData +import androidx.room.withTransaction import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map import tech.salroid.filmy.data.local.db.FilmyDatabase import tech.salroid.filmy.data.local.db.entity.Movie import tech.salroid.filmy.data.local.db.entity.MovieDetails import tech.salroid.filmy.data.local.model.* +import tech.salroid.filmy.data.local.model.collection.CollectionDetailsResponse +import tech.salroid.filmy.data.local.model.discover.DiscoverFilters +import tech.salroid.filmy.data.local.model.discover.GenreResponse +import tech.salroid.filmy.data.local.model.tv.SeasonDetailsResponse import tech.salroid.filmy.data.local.model.tv.TvDetails import tech.salroid.filmy.data.local.model.watch_providers.WatchProviderResponse import tech.salroid.filmy.data.network.MoviesApiHelper import javax.inject.Inject - +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import tech.salroid.filmy.ui.widget.WidgetRefresher + +@Singleton class MoviesRepository @Inject constructor( private val filmyDatabase: FilmyDatabase, - private val moviesApiHelper: MoviesApiHelper + private val moviesApiHelper: MoviesApiHelper, + @param:ApplicationContext private val context: Context ) { + private val repositoryScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private fun refreshWidget() { + repositoryScope.launch { + WidgetRefresher.refresh(context) + } + } + fun getMovies(type: String, isTrending: Boolean): Flow> = moviesApiHelper.getMovies(type, isTrending) + fun getMoviesFlow(type: String, isTrending: Boolean): Flow> = + moviesApiHelper.getMoviesFlow(type, isTrending) + .map { response -> + Result.success(response) + } + .catch { throwable -> + emit(Result.failure(throwable)) + } + fun getTvShows(type: String, isTrending: Boolean): Flow> = moviesApiHelper.getTvShows(type, isTrending) + fun getTvShowsFlow(type: String, isTrending: Boolean): Flow> = + moviesApiHelper.getTvShowsFlow(type, isTrending) + .map { response -> + Result.success(response) + }.catch { throwable -> + emit(Result.failure(throwable)) + } + fun getMovieDetailsFromLocal(id: Int, type: Int): MovieDetails? { return filmyDatabase.movieDetailsDao().getDetailsOfType(id, type) } + fun getMovieDetailsFlow(id: Int, type: Int): Flow = + filmyDatabase.movieDetailsDao().getDetailsFlow(id, type) + fun getRatings(id: String): Flow = moviesApiHelper.getOMDBRatings(id) fun getMovieDetailsFromNetwork(id: String): Flow { @@ -49,6 +94,12 @@ class MoviesRepository @Inject constructor( fun getCastCrewTvShows(id: String): Flow = moviesApiHelper.getCastCrewTvShows(id) + fun getCombinedCredits(id: String): Flow = + moviesApiHelper.getCombinedCredits(id) + + fun getPersonExternalIds(id: String): Flow = + moviesApiHelper.getPersonExternalIds(id) + fun getSimilar(id: String): Flow = moviesApiHelper.getSimilar(id) fun getSimilarTv(id: String): Flow = moviesApiHelper.getSimilarTv(id) @@ -62,16 +113,71 @@ class MoviesRepository @Inject constructor( fun searchMovies(query: String): Flow = moviesApiHelper.searchMovies(query) + fun searchMoviesFlow(query: String): Flow> = + moviesApiHelper.searchMovies(query).map { + Result.success(it) + }.catch { + emit(Result.failure(it)) + } + + fun searchMultiFlow(query: String): Flow> = + moviesApiHelper.searchMulti(query).map { + Result.success(it) + }.catch { + emit(Result.failure(it)) + } + + /** + * Upserts [movieDetails]: updates the existing row in place if one exists for + * this id+type, otherwise inserts a new one. Using a real UPDATE (rather than + * INSERT-with-REPLACE) for existing rows preserves their SQLite rowid, so + * re-saving an already-known item doesn't silently reshuffle list order. + */ fun addMovieDetailsToLocal(movieDetails: MovieDetails) { - return filmyDatabase.movieDetailsDao().insert(movieDetails) + upsertMovieDetails(movieDetails) + refreshWidget() } - fun getFavorites(): List = filmyDatabase.movieDetailsDao().getAllFavorites() + /** + * Same upsert as [addMovieDetailsToLocal], but for many items at once inside a + * single DB transaction — Room's Flow observers (getWatched/getWatchlist) only + * get notified once, after everything commits, instead of once per item. + */ + suspend fun saveMovieDetailsBatch(items: List) { + if (items.isEmpty()) return + filmyDatabase.withTransaction { + items.forEach { upsertMovieDetails(it) } + } + refreshWidget() + } + + private fun upsertMovieDetails(movieDetails: MovieDetails) { + val dao = filmyDatabase.movieDetailsDao() + if (dao.getDetailsOfType(movieDetails.id, movieDetails.type) != null) { + dao.updateDetails(movieDetails) + } else { + dao.insert(movieDetails) + } + } + + fun deleteMovieDetailsFromLocal(movieDetails: MovieDetails) { + filmyDatabase.movieDetailsDao().delete(movieDetails) + refreshWidget() + } + + fun getWatched(): Flow> = filmyDatabase.movieDetailsDao().getAllWatched() - fun getWatchlist(): List = filmyDatabase.movieDetailsDao().getAllWatchlist() + fun getWatchlist(): Flow> = filmyDatabase.movieDetailsDao().getAllWatchlist() - fun updateMovieDetails(movieDetails: MovieDetails): Int = - filmyDatabase.movieDetailsDao().updateDetails(movieDetails) + fun getRated(): Flow> = filmyDatabase.movieDetailsDao().getAllRated() + + fun getWatchedUnrated(): Flow> = filmyDatabase.movieDetailsDao().getWatchedUnrated() + + fun updateMovieDetails(movieDetails: MovieDetails): Int { + val result = filmyDatabase.movieDetailsDao().updateDetails(movieDetails) + refreshWidget() + return result + } fun addAllMoviesToDb(movies: List) { filmyDatabase.movieDao().insertAll(movies) @@ -85,4 +191,46 @@ class MoviesRepository @Inject constructor( fun getWatchProvidersTv(id: String): Flow = moviesApiHelper.getWatchProvidersTv(id) -} \ No newline at end of file + + fun discoverMovies(filters: DiscoverFilters): Flow> = + moviesApiHelper.discoverMovies(filters) + + fun discoverTv(filters: DiscoverFilters): Flow> = + moviesApiHelper.discoverTv(filters) + + fun getMovieGenres(): Flow = moviesApiHelper.getMovieGenres() + + fun getTvGenres(): Flow = moviesApiHelper.getTvGenres() + + fun getPeople(): Flow> = moviesApiHelper.getPeople() + + fun getMovieCertification(id: String): Flow = + moviesApiHelper.getMovieCertification(id) + + fun getTvCertification(id: String): Flow = + moviesApiHelper.getTvCertification(id) + + fun getCollectionDetails(id: Int): Flow = + moviesApiHelper.getCollectionDetails(id) + + fun getSeasonDetails(tvId: String, seasonNumber: Int): Flow = + moviesApiHelper.getSeasonDetails(tvId, seasonNumber) + + fun getTvExternalIds(tvId: String): Flow = + moviesApiHelper.getTvExternalIds(tvId) + + fun getMovieExternalIds(movieId: String): Flow = + moviesApiHelper.getMovieExternalIds(movieId) + + fun getMovieImages(id: String): Flow = + moviesApiHelper.getMovieImages(id) + + fun getTvImages(id: String): Flow = + moviesApiHelper.getTvImages(id) + + fun getMovieKeywords(id: String): Flow> = + moviesApiHelper.getMovieKeywords(id) + + fun getTvKeywords(id: String): Flow> = + moviesApiHelper.getTvKeywords(id) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/home/TvFragment.kt b/app/src/main/java/tech/salroid/filmy/ui/home/TvFragment.kt index 8fd99845..b417b1c1 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/home/TvFragment.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/home/TvFragment.kt @@ -22,7 +22,7 @@ import tech.salroid.filmy.databinding.FragmentTvBinding import tech.salroid.filmy.ui.adapters.TvAdapter import tech.salroid.filmy.ui.component.NavigationItemData import tech.salroid.filmy.ui.details.TvDetailsActivity -import tech.salroid.filmy.ui.search.SearchViewModel +import tech.salroid.filmy.ui.search.SearchViewModelLegacy import tech.salroid.filmy.utility.FilmyUtility.getGridLayoutManager import tech.salroid.filmy.utility.isDarkThemeActivated import tech.salroid.filmy.utility.showSnackBar @@ -31,7 +31,7 @@ import tech.salroid.filmy.utility.showSnackBar class TvFragment : Fragment() { private val viewModel: MainViewModel by activityViewModels() - private val viewModelSearch: SearchViewModel by activityViewModels() + private val viewModelSearch: SearchViewModelLegacy by activityViewModels() private var adapter: TvAdapter? = null private var _binding: FragmentTvBinding? = null private val binding get() = _binding!! diff --git a/app/src/main/java/tech/salroid/filmy/ui/intro/FilmyIntroActivity.kt b/app/src/main/java/tech/salroid/filmy/ui/intro/FilmyIntroActivity.kt deleted file mode 100644 index abf16e9c..00000000 --- a/app/src/main/java/tech/salroid/filmy/ui/intro/FilmyIntroActivity.kt +++ /dev/null @@ -1,31 +0,0 @@ -package tech.salroid.filmy.ui.intro - -import com.github.paolorotolo.appintro.AppIntro2 -import android.os.Bundle -import androidx.fragment.app.Fragment - -class FilmyIntroActivity : AppIntro2() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - addSlide(IntroFragmentA()) - addSlide(IntroFragmentB()) - addSlide(IntroFragmentC()) - addSlide(IntroFragmentD()) - - showStatusBar(true) - isProgressButtonEnabled = true - setDepthAnimation() - } - - override fun onSkipPressed(currentFragment: Fragment) { - super.onSkipPressed(currentFragment) - finish() - } - - override fun onDonePressed(currentFragment: Fragment) { - super.onDonePressed(currentFragment) - finish() - } -} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentA.kt b/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentA.kt deleted file mode 100644 index 227b4d8e..00000000 --- a/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentA.kt +++ /dev/null @@ -1,6 +0,0 @@ -package tech.salroid.filmy.ui.intro - -import androidx.fragment.app.Fragment -import tech.salroid.filmy.R - -class IntroFragmentA : Fragment(R.layout.fragment_intro) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentB.kt b/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentB.kt deleted file mode 100644 index e792a057..00000000 --- a/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentB.kt +++ /dev/null @@ -1,6 +0,0 @@ -package tech.salroid.filmy.ui.intro - -import androidx.fragment.app.Fragment -import tech.salroid.filmy.R - -class IntroFragmentB : Fragment(R.layout.fragment_intro_fragment_b) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentC.kt b/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentC.kt deleted file mode 100644 index 8f62f6c2..00000000 --- a/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentC.kt +++ /dev/null @@ -1,6 +0,0 @@ -package tech.salroid.filmy.ui.intro - -import androidx.fragment.app.Fragment -import tech.salroid.filmy.R - -class IntroFragmentC : Fragment(R.layout.fragment_intro_fragment_c) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentD.kt b/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentD.kt deleted file mode 100644 index 5e53ec71..00000000 --- a/app/src/main/java/tech/salroid/filmy/ui/intro/IntroFragmentD.kt +++ /dev/null @@ -1,6 +0,0 @@ -package tech.salroid.filmy.ui.intro - -import androidx.fragment.app.Fragment -import tech.salroid.filmy.R - -class IntroFragmentD : Fragment(R.layout.fragment_intro_fragment_d) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/lists/ListDetailsScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/lists/ListDetailsScreen.kt new file mode 100644 index 00000000..2dbf7fda --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/lists/ListDetailsScreen.kt @@ -0,0 +1,193 @@ +package tech.salroid.filmy.ui.lists + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.LaunchedEffect +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.db.entity.Movie +import tech.salroid.filmy.ui.common.components.LoadingWidget +import tech.salroid.filmy.ui.common.components.PreviewList +import tech.salroid.filmy.utility.toReadableDate + +@Composable +fun ListDetailsScreen( + listId: Int, + title: String, + viewModel: ListDetailsViewModel = hiltViewModel(), + onMovieClick: (Int) -> Unit, + onBackClick: () -> Unit +) { + val items by viewModel.items.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + + var movieToRemove by remember { mutableStateOf(null) } + + LaunchedEffect(listId) { + viewModel.loadListDetails(listId) + } + + ListDetailsContent( + title = title, + items = items, + isLoading = isLoading, + onMovieClick = { onMovieClick(it.id) }, + onMovieLongClick = { movieToRemove = it }, + onBackClick = onBackClick + ) + + movieToRemove?.let { movie -> + AlertDialog( + onDismissRequest = { movieToRemove = null }, + title = { Text(stringResource(R.string.remove)) }, + text = { Text(movie.title ?: "") }, + confirmButton = { + TextButton(onClick = { + viewModel.removeItem(listId, movie) + movieToRemove = null + }) { + Text(stringResource(R.string.remove)) + } + }, + dismissButton = { + TextButton(onClick = { movieToRemove = null }) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListDetailsContent( + title: String, + items: List, + isLoading: Boolean, + onMovieClick: (Movie) -> Unit, + onMovieLongClick: (Movie) -> Unit, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + when { + isLoading -> { + LoadingWidget(modifier = Modifier.padding(paddingValues).fillMaxSize()) + } + items.isEmpty() -> { + Box( + modifier = Modifier.padding(paddingValues).fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(stringResource(R.string.list_empty)) + } + } + else -> { + PreviewList( + modifier = Modifier + .padding(paddingValues) + .padding(top = 16.dp), + items = items.size + ) { index -> + val movie = items[index] + ListMovieItem( + movie = movie, + onClick = { onMovieClick(movie) }, + onLongClick = { onMovieLongClick(movie) } + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ListMovieItem( + movie: Movie, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + Card( + shape = RoundedCornerShape(corner = CornerSize(8.dp)), + colors = CardDefaults.cardColors(containerColor = Color.Transparent) + ) { + Column( + modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .height(170.dp) + .clip(RoundedCornerShape(corner = CornerSize(8.dp))), + contentScale = ContentScale.Crop, + model = stringResource(R.string.movie_poster_url, movie.posterPath ?: ""), + contentDescription = movie.title, + error = painterResource(R.drawable.poster_error_placeholder) + ) + Text( + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + text = movie.title ?: "" + ) + Text( + modifier = Modifier.padding(top = 4.dp).alpha(0.8f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + text = movie.releaseDate?.toReadableDate() ?: "" + ) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/lists/ListDetailsViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/lists/ListDetailsViewModel.kt new file mode 100644 index 00000000..4a3d2a8c --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/lists/ListDetailsViewModel.kt @@ -0,0 +1,61 @@ +package tech.salroid.filmy.ui.lists + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import tech.salroid.filmy.data.local.db.entity.Movie +import tech.salroid.filmy.ui.home.AccountRepository +import javax.inject.Inject + +@HiltViewModel +class ListDetailsViewModel @Inject constructor( + private val accountRepository: AccountRepository +) : ViewModel() { + + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + fun loadListDetails(listId: Int) { + viewModelScope.launch(Dispatchers.IO) { + val sessionId = accountRepository.getSessionIdFromPref() ?: return@launch + _isLoading.emit(true) + try { + val response = accountRepository.getListDetails(listId, sessionId).first() + _items.emit(response.items) + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isLoading.emit(false) + } + } + } + + /** Optimistically removes the item locally, rolling back if the TMDB removal fails. */ + fun removeItem(listId: Int, movie: Movie) { + val previous = _items.value + _items.value = _items.value.filterNot { it.id == movie.id } + + viewModelScope.launch(Dispatchers.IO) { + val sessionId = accountRepository.getSessionIdFromPref() + if (sessionId == null) { + _items.value = previous + return@launch + } + try { + accountRepository.removeFromList(listId, sessionId, movie.id).first() + } catch (e: Exception) { + e.printStackTrace() + _items.value = previous + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/lists/MyListsScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/lists/MyListsScreen.kt new file mode 100644 index 00000000..46cded9d --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/lists/MyListsScreen.kt @@ -0,0 +1,301 @@ +package tech.salroid.filmy.ui.lists + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.account.TmdbList +import tech.salroid.filmy.ui.common.components.LoadingWidget +import tech.salroid.filmy.ui.home.LoginViewModel +import tech.salroid.filmy.ui.home.rememberLoginLauncher + +@Composable +fun MyListsScreen( + viewModel: MyListsViewModel = hiltViewModel(), + loginViewModel: LoginViewModel = hiltViewModel(), + onListClick: (Int, String) -> Unit, + onBackClick: () -> Unit +) { + val lists by viewModel.lists.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val profile by loginViewModel.uiStateProfile.collectAsStateWithLifecycle() + val startLogin = rememberLoginLauncher(loginViewModel) + + var showCreateDialog by remember { mutableStateOf(false) } + var listToDelete by remember { mutableStateOf(null) } + + // Logging in here refreshes this same screen's lists immediately - + // no need to leave and come back. + LaunchedEffect(profile) { + if (profile != null) { + viewModel.loadLists() + } + } + + if (profile == null) { + LoggedOutContent( + onLoginClick = startLogin, + onBackClick = onBackClick + ) + } else { + MyListsContent( + lists = lists, + isLoading = isLoading, + onListClick = onListClick, + onCreateClick = { showCreateDialog = true }, + onListLongClick = { listToDelete = it }, + onBackClick = onBackClick + ) + } + + if (showCreateDialog) { + CreateListDialog( + onCreate = { name -> + viewModel.createList(name) + showCreateDialog = false + }, + onDismiss = { showCreateDialog = false } + ) + } + + listToDelete?.let { list -> + AlertDialog( + onDismissRequest = { listToDelete = null }, + title = { + Text( + stringResource(R.string.delete_list_confirmation), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { Text(list.name ?: "") }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteList(list.id) + listToDelete = null + }) { + Text(stringResource(R.string.remove)) + } + }, + dismissButton = { + TextButton(onClick = { listToDelete = null }) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MyListsContent( + lists: List, + isLoading: Boolean, + onListClick: (Int, String) -> Unit, + onCreateClick: () -> Unit, + onListLongClick: (TmdbList) -> Unit, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.my_lists)) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + if (isLoading) { + LoadingWidget( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) + } else { + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + item { + TextButton( + onClick = onCreateClick, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text("+ " + stringResource(R.string.create_new_list)) + } + } + + if (lists.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp) + ) { + Text( + text = stringResource(R.string.no_lists_yet), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + items(lists, key = { it.id }) { list -> + ListRow( + list = list, + onClick = { onListClick(list.id, list.name ?: "") }, + onLongClick = { onListLongClick(list) } + ) + HorizontalDivider() + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LoggedOutContent( + onLoginClick: () -> Unit, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.my_lists)) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Log in to your TMDB account to view\n and manage your lists", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onLoginClick) { + Text("Log In") + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ListRow( + list: TmdbList, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = list.name ?: "", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "${list.itemCount ?: 0} items", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun CreateListDialog( + onCreate: (String) -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(R.string.create_new_list), + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + placeholder = { Text(stringResource(R.string.list_name_hint)) }, + singleLine = true + ) + }, + confirmButton = { + TextButton(onClick = { if (name.isNotBlank()) onCreate(name.trim()) }) { + Text(stringResource(R.string.add)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/lists/MyListsViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/lists/MyListsViewModel.kt new file mode 100644 index 00000000..d56a3e6c --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/lists/MyListsViewModel.kt @@ -0,0 +1,79 @@ +package tech.salroid.filmy.ui.lists + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import tech.salroid.filmy.data.local.model.account.TmdbList +import tech.salroid.filmy.ui.home.AccountRepository +import javax.inject.Inject + +@HiltViewModel +class MyListsViewModel @Inject constructor( + private val accountRepository: AccountRepository +) : ViewModel() { + + private val _lists = MutableStateFlow>(emptyList()) + val lists: StateFlow> = _lists.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + init { + loadLists() + } + + fun loadLists() { + viewModelScope.launch(Dispatchers.IO) { + val sessionId = accountRepository.getSessionIdFromPref() ?: return@launch + val accountId = accountRepository.getProfileFromLocal()?.id ?: return@launch + _isLoading.emit(true) + try { + val response = accountRepository.getLists(accountId, sessionId).first() + _lists.emit(response.results) + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isLoading.emit(false) + } + } + } + + fun createList(name: String) { + viewModelScope.launch(Dispatchers.IO) { + val sessionId = accountRepository.getSessionIdFromPref() ?: return@launch + try { + val response = accountRepository.createList(sessionId, name).first() + val listId = response.listId ?: return@launch + _lists.value = _lists.value + TmdbList(id = listId, name = name, itemCount = 0) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + /** Optimistically removes the list locally, rolling back if the TMDB delete fails. */ + fun deleteList(listId: Int) { + val previous = _lists.value + _lists.value = _lists.value.filterNot { it.id == listId } + + viewModelScope.launch(Dispatchers.IO) { + val sessionId = accountRepository.getSessionIdFromPref() + if (sessionId == null) { + _lists.value = previous + return@launch + } + try { + accountRepository.deleteList(listId, sessionId).first() + } catch (e: Exception) { + e.printStackTrace() + _lists.value = previous + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/Dummy.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/Dummy.kt new file mode 100644 index 00000000..453005af --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/Dummy.kt @@ -0,0 +1,19 @@ +package tech.salroid.filmy.ui.movies + +import tech.salroid.filmy.data.model.MoviePreview +import tech.salroid.filmy.data.model.TvShowPreview +import kotlin.math.floor + +val dummyMoviePreview = MoviePreview( + id = (floor(Math.random() * 9000) + 1000).toInt(), + title = "People We Meet On Vacation", + posterUrl = "", + readableReleaseDate = "06 Jan 2026" +) + +val dummyShowPreview = TvShowPreview( + id = (floor(Math.random() * 9000) + 1000).toInt(), + title = "Hunger Games", + posterUrl = "", + firstAirReadableDate = "06 Jan 2026" +) \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/MoviePreviewMapper.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/MoviePreviewMapper.kt new file mode 100644 index 00000000..e3d63b3b --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/MoviePreviewMapper.kt @@ -0,0 +1,23 @@ +package tech.salroid.filmy.ui.movies + +import tech.salroid.filmy.data.local.db.entity.Movie +import tech.salroid.filmy.data.model.MoviePreview +import tech.salroid.filmy.utility.ImageConfig +import tech.salroid.filmy.utility.formateReleaseDate +import javax.inject.Inject + +class MoviePreviewMapper @Inject constructor() { + + // Make it more testable by taking from constructor + private val posterBaseUrl = ImageConfig.BASE_URL + + fun map(movie: Movie): MoviePreview = + MoviePreview( + id = movie.id, + title = movie.title.orEmpty(), + posterUrl = posterBaseUrl + movie.posterPath.orEmpty(), + readableReleaseDate = movie.releaseDate + ?.let(::formateReleaseDate) + .orEmpty() + ) +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesRoute.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesRoute.kt new file mode 100644 index 00000000..bcb7f20e --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesRoute.kt @@ -0,0 +1,56 @@ +package tech.salroid.filmy.ui.movies + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems +import tech.salroid.filmy.data.model.SearchPreview +import tech.salroid.filmy.ui.search.SearchScreenState + +@Composable +fun MoviesRoute( + modifier: Modifier = Modifier, + viewModel: MoviesViewModel = hiltViewModel(), + textFieldState: TextFieldState, + searchUiState: SearchScreenState, + isSearchExpanded: Boolean, + onSearchExpandedChange: (Boolean) -> Unit, + onSearch: (String) -> Unit, + onMovieClick: (Int) -> Unit, + onSearchResultClick: (SearchPreview) -> Unit, + recentSearches: List = emptyList(), + onRecentSearchClick: (String) -> Unit = {}, + onRemoveRecentSearch: (String) -> Unit = {}, + onClearRecentSearches: () -> Unit = {}, + onFilterClick: () -> Unit = {}, + onPeopleClick: () -> Unit = {} +) { + val movies = viewModel.moviesPagingData.collectAsLazyPagingItems() + val selectedCategory by viewModel.selectedCategory.collectAsStateWithLifecycle() + + MoviesScreen( + modifier = modifier, + movies = movies, + selectedCategory = selectedCategory, + onCategorySelected = viewModel::onCategorySelected, + textFieldState = textFieldState, + searchUiState = searchUiState, + isSearchExpanded = isSearchExpanded, + onSearchExpandedChange = onSearchExpandedChange, + onSearch = onSearch, + onMovieClick = onMovieClick, + onSearchResultClick = onSearchResultClick, + recentSearches = recentSearches, + onRecentSearchClick = onRecentSearchClick, + onRemoveRecentSearch = onRemoveRecentSearch, + onClearRecentSearches = onClearRecentSearches, + onFilterClick = onFilterClick, + onPeopleClick = onPeopleClick, + fetchQuickActionState = viewModel::getQuickActionState, + onQuickToggleWatchlist = viewModel::quickToggleWatchlist, + onQuickToggleWatched = viewModel::quickToggleWatched + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesScreen.kt new file mode 100644 index 00000000..30e646ac --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesScreen.kt @@ -0,0 +1,162 @@ +package tech.salroid.filmy.ui.movies + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.db.entity.Movie +import tech.salroid.filmy.data.model.MoviePreview +import tech.salroid.filmy.data.model.SearchPreview +import tech.salroid.filmy.ui.common.components.CategorySelector +import tech.salroid.filmy.ui.common.components.ErrorWidget +import tech.salroid.filmy.ui.common.components.HomeTopBar +import tech.salroid.filmy.ui.common.components.LoadingWidget +import tech.salroid.filmy.ui.common.components.QuickActionState +import tech.salroid.filmy.ui.common.icons.MoreUp +import tech.salroid.filmy.ui.movies.components.MoviesList +import tech.salroid.filmy.ui.search.SearchScreenState +import tech.salroid.filmy.utility.toUserMessage + +private fun labelFor(category: Movie.MovieType): Int = when (category) { + Movie.MovieType.TRENDING -> R.string.label_trending + Movie.MovieType.POPULAR -> R.string.label_pouplar + Movie.MovieType.NOW_PLAYING -> R.string.label_now_playing + Movie.MovieType.UPCOMING -> R.string.label_upcoming + Movie.MovieType.TOP_RATED -> R.string.label_top_rated +} + +@Composable +fun MoviesScreen( + modifier: Modifier = Modifier, + movies: LazyPagingItems, + selectedCategory: Movie.MovieType, + onCategorySelected: (Movie.MovieType) -> Unit, + textFieldState: TextFieldState, + searchUiState: SearchScreenState, + isSearchExpanded: Boolean, + onSearchExpandedChange: (Boolean) -> Unit, + onSearch: (String) -> Unit, + onMovieClick: (Int) -> Unit, + onSearchResultClick: (SearchPreview) -> Unit, + recentSearches: List = emptyList(), + onRecentSearchClick: (String) -> Unit = {}, + onRemoveRecentSearch: (String) -> Unit = {}, + onClearRecentSearches: () -> Unit = {}, + onFilterClick: () -> Unit = {}, + onPeopleClick: () -> Unit = {}, + fetchQuickActionState: suspend (MoviePreview) -> QuickActionState = { QuickActionState(false, false) }, + onQuickToggleWatchlist: (MoviePreview) -> Unit = {}, + onQuickToggleWatched: (MoviePreview) -> Unit = {} +) { + var showMoreMenu by remember { mutableStateOf(false) } + + HomeTopBar( + modifier = modifier, + textFieldState = textFieldState, + searchUiState = searchUiState, + isSearchExpanded = isSearchExpanded, + onSearchExpandedChange = onSearchExpandedChange, + onSearch = onSearch, + onSearchResultClick = onSearchResultClick, + recentSearches = recentSearches, + onRecentSearchClick = onRecentSearchClick, + onRemoveRecentSearch = onRemoveRecentSearch, + onClearRecentSearches = onClearRecentSearches, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CategorySelector( + categories = Movie.MovieType.entries, + selected = selectedCategory, + label = { stringResource(labelFor(it)) }, + onSelected = onCategorySelected + ) + Box { + IconButton(onClick = { showMoreMenu = true }) { + Icon( + MoreUp, + contentDescription = "More options", + tint = MaterialTheme.colorScheme.primary + ) + } + DropdownMenu( + expanded = showMoreMenu, + onDismissRequest = { showMoreMenu = false } + ) { + DropdownMenuItem( + text = { Text("Discover Movies") }, + onClick = { + showMoreMenu = false + onFilterClick() + } + ) + DropdownMenuItem( + text = { Text("Popular Persons") }, + onClick = { + showMoreMenu = false + onPeopleClick() + } + ) + } + } + } + }, + content = { + when (val state = movies.loadState.refresh) { + is LoadState.Loading -> { + LoadingWidget(modifier = Modifier.weight(1f)) + } + + is LoadState.Error -> { + ErrorWidget( + modifier = Modifier.weight(1f), + message = state.error.toUserMessage(), + onRetryClick = { movies.retry() } + ) + } + + else -> { + if (movies.itemCount == 0 && + movies.loadState.append is LoadState.NotLoading && + movies.loadState.append.endOfPaginationReached + ) { + ErrorWidget( + modifier = Modifier.weight(1f), + message = "No movies found", + onRetryClick = { movies.refresh() } + ) + } else { + MoviesList( + modifier = Modifier.weight(1f), + movies = movies, + onMovieClick = onMovieClick, + fetchQuickActionState = fetchQuickActionState, + onQuickToggleWatchlist = onQuickToggleWatchlist, + onQuickToggleWatched = onQuickToggleWatched + ) + } + } + } + } + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesScreenState.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesScreenState.kt new file mode 100644 index 00000000..a43d5c49 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesScreenState.kt @@ -0,0 +1,9 @@ +package tech.salroid.filmy.ui.movies + +import tech.salroid.filmy.data.model.MoviePreview + +sealed class MoviesScreenState { + object Loading : MoviesScreenState() + data class Success(val moviesList: List) : MoviesScreenState() + data class Error(val errorMessage: String) : MoviesScreenState() +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesViewModel.kt new file mode 100644 index 00000000..51d742bb --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/MoviesViewModel.kt @@ -0,0 +1,100 @@ +package tech.salroid.filmy.ui.movies + +import android.content.SharedPreferences +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import tech.salroid.filmy.data.local.db.entity.Movie +import tech.salroid.filmy.data.local.db.entity.MovieDetails +import tech.salroid.filmy.data.model.MoviePreview +import tech.salroid.filmy.ui.common.components.QuickActionState +import tech.salroid.filmy.ui.home.MoviesRepository +import tech.salroid.filmy.utility.ImageConfig +import tech.salroid.filmy.utility.PreferenceHelper.selectedCountryFlow +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class MoviesViewModel @Inject constructor( + private val moviesRepository: MoviesRepository, + private val moviePreviewMapper: MoviePreviewMapper, + sharedPreferences: SharedPreferences +) : ViewModel() { + + private val _selectedCategory = MutableStateFlow(Movie.MovieType.TRENDING) + val selectedCategory: StateFlow = _selectedCategory + + val moviesPagingData: Flow> = combine( + _selectedCategory, + sharedPreferences.selectedCountryFlow() + ) { category, _ -> category } + .flatMapLatest { category -> + moviesRepository.getMovies( + type = category.toApiTypeString(), + isTrending = category == Movie.MovieType.TRENDING + ) + } + .map { pagingData -> pagingData.map(moviePreviewMapper::map) } + .cachedIn(viewModelScope) + + fun onCategorySelected(category: Movie.MovieType) { + _selectedCategory.value = category + } + + /** + * Quick actions from the long-press menu on a poster - upserts against + * whatever local record already exists (preserving its other fields) + * rather than blindly overwriting, so this is safe even if the title was + * already saved with full details from a previous visit. + */ + suspend fun getQuickActionState(movie: MoviePreview): QuickActionState = withContext(Dispatchers.IO) { + val existing = moviesRepository.getMovieDetailsFromLocal(movie.id, 0) + QuickActionState( + isWatchlisted = existing?.watchlist ?: false, + isWatched = existing?.watched ?: false + ) + } + + fun quickToggleWatchlist(movie: MoviePreview) { + viewModelScope.launch(Dispatchers.IO) { + val merged = mergedLocalDetails(movie) + moviesRepository.addMovieDetailsToLocal(merged.copy(watchlist = !merged.watchlist)) + } + } + + fun quickToggleWatched(movie: MoviePreview) { + viewModelScope.launch(Dispatchers.IO) { + val merged = mergedLocalDetails(movie) + moviesRepository.addMovieDetailsToLocal(merged.copy(watched = !merged.watched)) + } + } + + private fun mergedLocalDetails(movie: MoviePreview): MovieDetails { + val existing = moviesRepository.getMovieDetailsFromLocal(movie.id, 0) + return (existing ?: MovieDetails(id = movie.id)).copy( + title = movie.title, + posterPath = movie.posterUrl.removePrefix(ImageConfig.BASE_URL) + ) + } + + private fun Movie.MovieType.toApiTypeString(): String = when (this) { + Movie.MovieType.TRENDING -> "day" + Movie.MovieType.POPULAR -> "popular" + Movie.MovieType.NOW_PLAYING -> "now_playing" + Movie.MovieType.UPCOMING -> "upcoming" + Movie.MovieType.TOP_RATED -> "top_rated" + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/components/MovieItem.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/components/MovieItem.kt new file mode 100644 index 00000000..0dc8a982 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/components/MovieItem.kt @@ -0,0 +1,49 @@ +package tech.salroid.filmy.ui.movies.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.data.model.MoviePreview +import tech.salroid.filmy.ui.common.components.PreviewItem +import tech.salroid.filmy.ui.common.components.QuickActionState +import tech.salroid.filmy.ui.movies.dummyMoviePreview + +@Composable +fun MovieItem( + modifier: Modifier = Modifier, + movie: MoviePreview, + onMovieClick: () -> Unit, + fetchQuickActionState: (suspend () -> QuickActionState)? = null, + onToggleWatchlist: (() -> Unit)? = null, + onToggleWatched: (() -> Unit)? = null +) { + PreviewItem( + modifier = modifier, + title = movie.title, + posterUrl = movie.posterUrl, + readableDate = movie.readableReleaseDate, + contentDescription = "${movie.title} - Movie Item", + onItemClick = onMovieClick, + fetchQuickActionState = fetchQuickActionState, + onToggleWatchlist = onToggleWatchlist, + onToggleWatched = onToggleWatched + ) +} + +@Preview( + name = "MovieItem - Preview", + showBackground = true +) +@Composable +fun MovieItemPreview() { + MovieItem( + modifier = Modifier + .width(140.dp) + .padding(16.dp), + movie = dummyMoviePreview, + onMovieClick = { } + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/components/MoviesList.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/components/MoviesList.kt new file mode 100644 index 00000000..78144849 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/components/MoviesList.kt @@ -0,0 +1,34 @@ +package tech.salroid.filmy.ui.movies.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.paging.compose.LazyPagingItems +import tech.salroid.filmy.data.model.MoviePreview +import tech.salroid.filmy.ui.common.components.PaginatedPreviewList +import tech.salroid.filmy.ui.common.components.QuickActionState + +@Composable +fun MoviesList( + modifier: Modifier = Modifier, + movies: LazyPagingItems, + onMovieClick: (Int) -> Unit, + fetchQuickActionState: suspend (MoviePreview) -> QuickActionState = { QuickActionState(false, false) }, + onQuickToggleWatchlist: (MoviePreview) -> Unit = {}, + onQuickToggleWatched: (MoviePreview) -> Unit = {} +) { + PaginatedPreviewList( + modifier = modifier, + items = movies, + content = { movie -> + MovieItem( + movie = movie, + onMovieClick = { + onMovieClick(movie.id) + }, + fetchQuickActionState = { fetchQuickActionState(movie) }, + onToggleWatchlist = { onQuickToggleWatchlist(movie) }, + onToggleWatched = { onQuickToggleWatched(movie) } + ) + } + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/MovieDetailsScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/MovieDetailsScreen.kt new file mode 100644 index 00000000..94771fe7 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/MovieDetailsScreen.kt @@ -0,0 +1,159 @@ +package tech.salroid.filmy.ui.movies.details + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import tech.salroid.filmy.ui.common.components.DetailsContent +import tech.salroid.filmy.ui.common.components.DetailsSkeletonLoader +import tech.salroid.filmy.ui.common.components.ErrorWidget +import tech.salroid.filmy.ui.common.components.LoginRequiredDialog +import tech.salroid.filmy.ui.details.MovieDetailsViewModel +import tech.salroid.filmy.ui.common.model.DetailsActions +import tech.salroid.filmy.ui.home.LoginViewModel +import tech.salroid.filmy.ui.home.rememberLoginLauncher +import tech.salroid.filmy.ui.movies.details.components.AddToListSheet +import tech.salroid.filmy.ui.movies.details.components.RateMediaSheet +import tech.salroid.filmy.utility.openUrl +import tech.salroid.filmy.utility.openYoutubeTrailer +import tech.salroid.filmy.utility.shareMedia + +@Composable +fun MovieDetailsScreen( + movieId: Int, + modifier: Modifier = Modifier, + viewModel: MovieDetailsViewModel = hiltViewModel(), + loginViewModel: LoginViewModel = hiltViewModel(), + onViewAllCastClick: (Int, Boolean, String) -> Unit, + onViewAllReviewsClick: (Int, Boolean, String) -> Unit, + onMemberClick: (Int, Boolean) -> Unit, + onMovieClick: (Int) -> Unit, + onBackNavigation: () -> Unit, + onCollectionClick: (Int, String) -> Unit = { _, _ -> }, + onGalleryClick: (Int, Boolean) -> Unit = { _, _ -> }, + onKeywordClick: (Int, String) -> Unit = { _, _ -> } +) { + val state by viewModel.mediaDetailsUiState.collectAsStateWithLifecycle() + val movieDetails by viewModel.uiStateMovieDetails.collectAsStateWithLifecycle() + val isError by viewModel.uiStateError.collectAsStateWithLifecycle() + val userLists by viewModel.userLists.collectAsStateWithLifecycle() + val listMembership by viewModel.listMembership.collectAsStateWithLifecycle() + val loginProfile by loginViewModel.uiStateProfile.collectAsStateWithLifecycle() + val startLogin = rememberLoginLauncher(loginViewModel) + val context = LocalContext.current + + var showAddToListSheet by remember { mutableStateOf(false) } + var showRateSheet by remember { mutableStateOf(false) } + var showLoginPrompt by remember { mutableStateOf(false) } + + LaunchedEffect(movieId) { + viewModel.fetchAllMovieDetails(movieId.toString(), 0) + } + + // Logging in here opens the list sheet right away with fresh data - + // no need to leave the details screen and come back. + LaunchedEffect(loginProfile) { + if (loginProfile != null && showLoginPrompt) { + showLoginPrompt = false + viewModel.loadUserLists(movieId) + showAddToListSheet = true + } + } + + Crossfade( + targetState = state != null && movieDetails != null, + animationSpec = tween(durationMillis = 600), + label = "details_fade" + ) { isLoaded -> + if (isLoaded) { + val currentState = state + val currentMovieDetails = movieDetails + if (currentState != null && currentMovieDetails != null) { + val actions = DetailsActions( + onWatchedToggle = { viewModel.toggleWatched(currentMovieDetails) }, + onWatchlistToggle = { viewModel.toggleWatchlist(currentMovieDetails) }, + onViewAllCastClick = onViewAllCastClick, + onViewAllReviewsClick = onViewAllReviewsClick, + onMemberClick = onMemberClick, + onMediaClick = onMovieClick, + onTrailerClick = { context.openYoutubeTrailer(it) }, + onShareClick = { + context.shareMedia( + title = currentState.title, + tagline = currentState.tagline, + imdbId = currentState.imdbId, + isTvShow = currentState.isTvShow + ) + }, + onBackNavigation = onBackNavigation, + onAddToListClick = { + if (viewModel.isLoggedIn()) { + viewModel.loadUserLists(movieId) + showAddToListSheet = true + } else { + showLoginPrompt = true + } + }, + onCollectionClick = onCollectionClick, + onRateClick = { showRateSheet = true }, + onGalleryClick = onGalleryClick, + onKeywordClick = onKeywordClick, + onLinkClick = { context.openUrl(it) } + ) + + DetailsContent( + state = currentState, + actions = actions, + modifier = modifier + ) + + if (showAddToListSheet) { + AddToListSheet( + lists = userLists, + membership = listMembership, + onToggle = { listId, currentlyIn -> + viewModel.toggleListMembership(listId, movieId, currentlyIn) + }, + onCreateList = { name -> viewModel.createList(name) }, + onDismiss = { showAddToListSheet = false } + ) + } + + if (showRateSheet) { + RateMediaSheet( + currentRating = currentState.userRating, + onSubmit = { rating -> + viewModel.rateMovie(currentMovieDetails, rating) + showRateSheet = false + }, + onRemove = { + viewModel.rateMovie(currentMovieDetails, null) + showRateSheet = false + }, + onDismiss = { showRateSheet = false } + ) + } + } + } else if (isError) { + ErrorWidget( + modifier = modifier, + message = "Couldn't load details. Check your connection.", + onRetryClick = { viewModel.fetchAllMovieDetails(movieId.toString(), 0) }, + onBackClick = onBackNavigation + ) + } else { + DetailsSkeletonLoader(modifier = modifier) + } + } + + if (showLoginPrompt) { + LoginRequiredDialog( + message = "Log in to your TMDB account to add this to a list.", + onLogin = startLogin, + onDismiss = { showLoginPrompt = false } + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/ActionsCard.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/ActionsCard.kt new file mode 100644 index 00000000..50771908 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/ActionsCard.kt @@ -0,0 +1,251 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.StarBorder +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.common.model.DetailsActions +import tech.salroid.filmy.ui.common.model.MediaDetailsUiState +import tech.salroid.filmy.ui.common.model.PaletteColors +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun ActionsCard( + state: MediaDetailsUiState, + actions: DetailsActions, + paletteColors: PaletteColors?, + modifier: Modifier = Modifier +) { + val isDark = isSystemInDarkTheme() + val tint = remember(paletteColors, isDark) { + val color = if (isDark) { + paletteColors?.lightVibrantRgb ?: paletteColors?.vibrantRgb + } else { + paletteColors?.vibrantRgb ?: paletteColors?.darkVibrantRgb + } + color?.let { Color(it) } + } ?: MaterialTheme.colorScheme.primary + + val unselectedColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + val haptic = LocalHapticFeedback.current + + Row( + modifier = modifier + .fillMaxWidth() + .padding( + top = 16.dp, + bottom = 24.dp, + start = 16.dp, + end = 16.dp + ), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + + // Watched Toggle + ActionItem( + isSelected = state.isWatched, + onIcon = painterResource(R.drawable.ic_check), + offIcon = painterResource(R.drawable.ic_check), + label = if (state.isWatched) stringResource(R.string.watched) else stringResource(R.string.watched_question), + selectedColor = tint, + unselectedColor = unselectedColor, + onClick = { + haptic.performHapticFeedback( + if (!state.isWatched) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff + ) + actions.onWatchedToggle() + }, + modifier = Modifier.weight(1f) + ) + + VerticalDivider( + modifier = Modifier.height(24.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + + // Watchlist Toggle + ActionItem( + isSelected = state.isWatchlist, + onIcon = painterResource(R.drawable.ic_round_bookmark_added_24), + offIcon = painterResource(R.drawable.ic_round_bookmark_add_24), + label = if (state.isWatchlist) stringResource(R.string.watchlisted) else stringResource( + R.string.add_to_watchlist + ), + selectedColor = tint, + unselectedColor = unselectedColor, + onClick = { + haptic.performHapticFeedback( + if (!state.isWatchlist) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff + ) + actions.onWatchlistToggle() + }, + modifier = Modifier.weight(1f) + ) + + VerticalDivider( + modifier = Modifier.height(24.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + + // Rate + ActionItem( + isSelected = state.userRating != null, + onIcon = rememberVectorPainter(Icons.Filled.Star), + offIcon = rememberVectorPainter(Icons.Outlined.StarBorder), + label = state.userRating?.let { "★ $it" } ?: stringResource(R.string.rate), + selectedColor = tint, + unselectedColor = unselectedColor, + onClick = actions.onRateClick, + modifier = Modifier.weight(1f) + ) + + if (!state.isTvShow) { + VerticalDivider( + modifier = Modifier.height(24.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + + // Add to List + ActionItem( + isSelected = false, + onIcon = painterResource(R.drawable.ic_collections_bookmark_24dp), + offIcon = painterResource(R.drawable.ic_collections_bookmark_24dp), + label = stringResource(R.string.add_to_list), + selectedColor = tint, + unselectedColor = unselectedColor, + onClick = actions.onAddToListClick, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun ActionItem( + isSelected: Boolean, + onIcon: Painter, + offIcon: Painter, + label: String, + selectedColor: Color, + unselectedColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val color by animateColorAsState( + targetValue = if (isSelected) selectedColor else unselectedColor, + label = "color_anim" + ) + + // Small celebratory bounce when the item actually becomes selected - + // guarded by hasComposedOnce so an already-selected item (e.g. reopening + // a watched title) doesn't bounce on first composition. + var hasComposedOnce by remember { mutableStateOf(false) } + val scale = remember { Animatable(1f) } + LaunchedEffect(isSelected) { + if (isSelected && hasComposedOnce) { + scale.animateTo( + 1.35f, + spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessHigh) + ) + scale.animateTo( + 1f, + spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) + ) + } + hasComposedOnce = true + } + + Column( + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onClick + ) + .padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = if (isSelected) onIcon else offIcon, + contentDescription = label, + tint = color, + modifier = Modifier + .size(22.dp) + .scale(scale.value) + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ActionsCardPreview() { + AppTheme { + ActionsCard( + state = MediaDetailsUiState( + mediaId = 1, + title = "Inception", + overview = "", + tagline = "", + backdropPath = null, + posterPath = null, + genres = "", + runtimeText = "", + releaseDateText = "", + youtubeTrailers = null, + isWatched = false, + isWatchlist = true, + isTvShow = false + ), + actions = DetailsActions( + onWatchedToggle = {}, + onWatchlistToggle = {}, + onViewAllCastClick = { _, _, _ -> }, + onViewAllReviewsClick = { _, _, _ -> }, + onMemberClick = { _, _ -> }, + onMediaClick = {}, + onTrailerClick = {}, + onShareClick = {}, + onBackNavigation = {} + ), + paletteColors = null + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/AddToListSheet.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/AddToListSheet.kt new file mode 100644 index 00000000..d527c99b --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/AddToListSheet.kt @@ -0,0 +1,136 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.account.TmdbList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddToListSheet( + lists: List, + membership: Map, + onToggle: (listId: Int, currentlyIn: Boolean) -> Unit, + onCreateList: (name: String) -> Unit, + onDismiss: () -> Unit +) { + var newListName by remember { mutableStateOf("") } + var showCreateField by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.add_to_list), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (lists.isEmpty()) { + Text( + text = stringResource(R.string.no_lists_yet), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp) + ) + } else { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + lists.forEach { list -> + val checked = membership[list.id] ?: false + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle(list.id, checked) } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked, + onCheckedChange = { onToggle(list.id, checked) } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = list.name ?: "", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + } + } + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + if (showCreateField) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = newListName, + onValueChange = { newListName = it }, + placeholder = { Text(stringResource(R.string.list_name_hint)) }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + if (newListName.isNotBlank()) { + onCreateList(newListName.trim()) + newListName = "" + showCreateField = false + } + } + ) { + Icon(Icons.Default.Check, contentDescription = stringResource(R.string.create_new_list)) + } + } + } else { + TextButton( + onClick = { showCreateField = true }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + ) { + Text("+ " + stringResource(R.string.create_new_list)) + } + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/AllTrailersSheet.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/AllTrailersSheet.kt new file mode 100644 index 00000000..b81e73f1 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/AllTrailersSheet.kt @@ -0,0 +1,158 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +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.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.Youtube +import tech.salroid.filmy.ui.theme.AppTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AllTrailersSheet( + title: String?, + trailers: List, + onTrailerClick: (String) -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + text = title ?: "", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(trailers) { trailer -> + TrailerItem(trailer) { + trailer.source?.let { onTrailerClick(it) } + } + } + } + } + } +} + +@Composable +fun TrailerItem(trailer: Youtube, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) { + AsyncImage( + model = stringResource(R.string.trailer_img_url, trailer.source ?: ""), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Icon( + painter = painterResource(R.drawable.ic_play_circle_filled_white_48dp), + contentDescription = null, + tint = Color.White.copy(alpha = 0.8f), + modifier = Modifier + .align(Alignment.Center) + .size(48.dp) + ) + trailer.type?.let { type -> + Text( + text = type, + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black.copy(alpha = 0.6f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + Text( + text = trailer.name ?: "", + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun TrailerItemPreview() { + AppTheme { + TrailerItem( + trailer = Youtube(name = "Official Trailer 1", source = "8hP9D6kZseM") + ) {} + } +} + +@Preview(showBackground = true) +@Composable +fun AllTrailersSheetPreview() { + val sampleTrailers = listOf( + Youtube(name = "Official Trailer 1", source = "8hP9D6kZseM"), + Youtube(name = "Official Trailer 2", source = "YoHD9XEInc0"), + Youtube(name = "Teaser Trailer", source = "8Z99vYmda99uSHI6fSToMvSztpZ") + ) + AppTheme { + AllTrailersSheet( + title = "Inception", + trailers = sampleTrailers, + onTrailerClick = {}, + onDismiss = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/AwardsSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/AwardsSection.kt new file mode 100644 index 00000000..a8695871 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/AwardsSection.kt @@ -0,0 +1,20 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AwardsSection(awards: String?) { + if (awards.isNullOrBlank()) return + + DetailsInfoItem( + label = "Awards", + value = awards, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/BoxOfficeSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/BoxOfficeSection.kt new file mode 100644 index 00000000..4476f638 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/BoxOfficeSection.kt @@ -0,0 +1,34 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.utility.toMoneyString + +@Composable +fun BoxOfficeSection(budget: Long?, revenue: Long?) { + if (budget == null && revenue == null) return + + Row( + modifier = Modifier + .fillMaxWidth() + ) { + budget?.let { + DetailsInfoItem( + label = "Budget", + value = it.toMoneyString(), + modifier = Modifier.weight(1f) + ) + } + revenue?.let { + DetailsInfoItem( + label = "Revenue", + value = it.toMoneyString(), + modifier = Modifier.weight(1f) + ) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/CastCrewSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/CastCrewSection.kt new file mode 100644 index 00000000..c8be56aa --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/CastCrewSection.kt @@ -0,0 +1,157 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +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.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.Cast +import tech.salroid.filmy.data.local.model.Crew +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun MemberItem( + name: String?, + description: String?, + profilePath: String?, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = stringResource(R.string.member_profile_url, profilePath ?: ""), + contentDescription = null, + modifier = Modifier + .size(42.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.default_avatar), + error = painterResource(R.drawable.default_avatar) + ) + Spacer(modifier = Modifier.width(20.dp)) + Column { + Text( + text = name ?: "", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.9f) + ) + if (!description.isNullOrEmpty()) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.6f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + HorizontalDivider( + modifier = Modifier.padding(start = 70.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.surfaceVariant + ) + } +} + +@Composable +fun CastSection( + cast: List?, + id: Int, + title: String, + isTv: Boolean, + onViewAllCastClick: (Int, Boolean, String) -> Unit, + onMemberClick: (Int, Boolean) -> Unit +) { + if (cast?.isNotEmpty() == true) { + DetailsSection( + title = stringResource(R.string.cast) + ) { + Column { + cast.take(4).forEach { castMember -> + MemberItem( + name = castMember.name, + description = castMember.character, + profilePath = castMember.profilePath, + onClick = { onMemberClick(castMember.id ?: 0, isTv) } + ) + } + if (cast.size > 4) { + SeeAllItem(onClick = { onViewAllCastClick(id, isTv, title) }) + } + } + } + } +} + +@Composable +fun CrewSection( + crew: List?, + id: Int, + title: String, + isTv: Boolean, + onViewAllCastClick: (Int, Boolean, String) -> Unit, + onMemberClick: (Int, Boolean) -> Unit +) { + if (crew?.isNotEmpty() == true) { + DetailsSection( + title = stringResource(R.string.crew) + ) { + Column { + crew.take(4).forEach { crewMember -> + MemberItem( + name = crewMember.name, + description = crewMember.job, + profilePath = crewMember.profilePath, + onClick = { onMemberClick(crewMember.id ?: 0, isTv) } + ) + } + if (crew.size > 4) { + SeeAllItem(onClick = { onViewAllCastClick(id, isTv, title) }) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun CastSectionPreview() { + val sampleCast = listOf( + Cast(id = 1, name = "Dave Bautista", character = "Eric", profilePath = null), + Cast(id = 2, name = "John Cena", character = "Peacemaker", profilePath = null), + Cast(id = 3, name = "The Rock", character = "Black Adam", profilePath = null), + Cast(id = 4, name = "Jason Statham", character = "Deckard Shaw", profilePath = null), + Cast(id = 5, name = "Vin Diesel", character = "Dominic Toretto", profilePath = null) + ) + AppTheme { + CastSection( + cast = sampleCast, + id = 1, + title = "Inception", + isTv = false, + onViewAllCastClick = { _, _, _ -> }, + onMemberClick = { _, _ -> } + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/CollectionTeaserRow.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/CollectionTeaserRow.kt new file mode 100644 index 00000000..fc4fdf83 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/CollectionTeaserRow.kt @@ -0,0 +1,91 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.common.model.PaletteColors +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun CollectionTeaserRow( + name: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + paletteColors: PaletteColors? = null +) { + val backgroundColor = rememberPaletteCardColor( + paletteColors = paletteColors, + fallback = MaterialTheme.colorScheme.secondaryContainer + ) + val contentColor = if (paletteColors != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSecondaryContainer + } + + Surface( + modifier = modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onClick + ), + color = backgroundColor, + shape = RoundedCornerShape(12.dp), + border = androidx.compose.foundation.BorderStroke( + width = 0.5.dp, + color = backgroundColor.copy(alpha = 0.2f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Part of a Collection", + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.7f) + ) + Text( + text = name, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + color = contentColor + ) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = contentColor + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun CollectionTeaserRowPreview() { + AppTheme { + CollectionTeaserRow( + name = "The Dark Knight Collection", + onClick = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/CommonDetails.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/CommonDetails.kt new file mode 100644 index 00000000..c2ae1c8f --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/CommonDetails.kt @@ -0,0 +1,134 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.common.model.PaletteColors +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun DetailsInfoItem(label: String, value: String, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.alpha(0.6f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +/** Same dynamic-palette background derivation used by [RatingCard]/[CollectionTeaserRow]. */ +@Composable +fun rememberPaletteCardColor(paletteColors: PaletteColors?, fallback: Color): Color { + val isDark = isSystemInDarkTheme() + return remember(paletteColors, isDark, fallback) { + val colorInt = if (isDark) { + paletteColors?.darkMutedRgb ?: paletteColors?.darkVibrantRgb + } else { + paletteColors?.lightMutedRgb ?: paletteColors?.lightVibrantRgb + } + colorInt?.let { Color(it).copy(alpha = 0.15f) } ?: fallback + } +} + +@Composable +fun DetailsSection( + title: String, + onViewAllClick: (() -> Unit)? = null, + content: @Composable () -> Unit +) { + Column(modifier = Modifier.padding(vertical = 12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + if (onViewAllClick != null) { + TextButton(onClick = onViewAllClick) { + Text(stringResource(R.string.view_all)) + } + } + } + content() + } +} + +@Composable +fun SeeAllItem(onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(28.dp)) + Text( + text = stringResource(R.string.view_all), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun DetailsInfoItemPreview() { + AppTheme { + DetailsInfoItem(label = "Runtime", value = "120m") + } +} + +@Preview(showBackground = true) +@Composable +fun DetailsSectionPreview() { + AppTheme { + DetailsSection(title = "Cast", onViewAllClick = {}) { + Text("Sample Content") + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/DetailsHeader.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/DetailsHeader.kt new file mode 100644 index 00000000..63005067 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/DetailsHeader.kt @@ -0,0 +1,444 @@ +package tech.salroid.filmy.ui.movies.details.components + +import android.graphics.drawable.BitmapDrawable +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.palette.graphics.Palette +import coil3.asDrawable +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.allowHardware +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.common.model.MediaDetailsUiState +import tech.salroid.filmy.ui.common.model.PaletteColors +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun DetailsHeader( + state: MediaDetailsUiState, + paletteColors: PaletteColors? = null, + onPaletteGenerated: (PaletteColors) -> Unit = {}, + onHeaderClick: () -> Unit = {} +) { + val density = LocalDensity.current + val backdropPrefix = stringResource(R.string.poster_prefix_500) + var posterTop by remember { mutableStateOf(135.dp) } + + var isPosterLoaded by remember { mutableStateOf(false) } + val tiltAnim = remember { Animatable(0f) } + val shineAnim = remember { Animatable(0f) } + var isAnimating by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + val playAnimation = { + if (!isAnimating) { + isAnimating = true + coroutineScope.launch { + shineAnim.snapTo(0f) + val job1 = launch { + tiltAnim.animateTo( + targetValue = 15f, + animationSpec = tween(300, easing = FastOutLinearInEasing) + ) + tiltAnim.animateTo( + targetValue = 0f, + animationSpec = tween(500, easing = LinearOutSlowInEasing) + ) + } + val job2 = launch { + shineAnim.animateTo( + targetValue = 1f, + animationSpec = tween(800, easing = FastOutSlowInEasing) + ) + } + job1.join() + job2.join() + isAnimating = false + } + } + } + + LaunchedEffect(isPosterLoaded) { + if (isPosterLoaded) { + delay(150) + playAnimation() + } + } + + val mainBackdropPath = remember(state.backdropPath, state.posterPath) { + if (state.backdropPath != null && state.backdropPath != "null") { + state.backdropPath + } else { + state.posterPath + } + } + + val bannerUrls = remember(mainBackdropPath, state.backdropImages) { + val extra = state.backdropImages.orEmpty().filter { it != mainBackdropPath } + (listOfNotNull(mainBackdropPath) + extra).distinct().map { "$backdropPrefix$it" } + } + + Box( + modifier = Modifier.fillMaxWidth() + ) { + HeaderBackdrop( + bannerUrls = bannerUrls, + paletteColors = paletteColors, + onPaletteGenerated = onPaletteGenerated + ) + + HeaderInfoCard( + state = state, + paletteColors = paletteColors, + onHeaderClick = onHeaderClick, + onBottomPositionCalculated = { bottom -> + posterTop = 220.dp + with(density) { bottom.toDp() } - 180.dp + } + ) + + HeaderPoster( + posterUrl = stringResource(R.string.movie_poster_url, state.posterPath ?: ""), + posterTop = posterTop, + tiltAnimValue = tiltAnim.value, + shineAnimValue = shineAnim.value, + onPosterLoaded = { isPosterLoaded = true }, + onClick = playAnimation + ) + } +} + +private const val BACKDROP_ROTATION_INTERVAL_MS = 6000L +private const val BACKDROP_CROSSFADE_DURATION_MS = 1200 + +@Composable +fun HeaderBackdrop( + bannerUrls: List, + paletteColors: PaletteColors?, + onPaletteGenerated: (PaletteColors) -> Unit +) { + val context = LocalContext.current + var currentIndex by remember { mutableIntStateOf(0) } + var hasGeneratedPalette by remember { mutableStateOf(false) } + + // Cycle through the fetched backdrops once there's more than one to show. + LaunchedEffect(bannerUrls) { + if (bannerUrls.size <= 1) return@LaunchedEffect + while (true) { + delay(BACKDROP_ROTATION_INTERVAL_MS) + currentIndex = (currentIndex + 1) % bannerUrls.size + } + } + + val currentUrl = bannerUrls.getOrNull(currentIndex) ?: bannerUrls.firstOrNull() + + Box( + modifier = Modifier + .fillMaxWidth() + .height(280.dp) + ) { + Crossfade( + targetState = currentUrl, + animationSpec = tween(BACKDROP_CROSSFADE_DURATION_MS), + label = "backdrop_crossfade" + ) { url -> + AsyncImage( + model = ImageRequest.Builder(context) + .data(url) + .allowHardware(false) + .build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + onSuccess = { imageState -> + // Only the very first backdrop drives the screen's color theme — + // later rotations shouldn't shift the rest of the UI's colors. + if (hasGeneratedPalette) return@AsyncImage + val drawable = imageState.result.image.asDrawable(context.resources) + if (drawable is BitmapDrawable) { + hasGeneratedPalette = true + val bitmap = drawable.bitmap + Palette.from(bitmap).generate { palette -> + val vibrant = palette?.vibrantSwatch ?: palette?.dominantSwatch + val darkVibrant = palette?.darkVibrantSwatch ?: palette?.vibrantSwatch + ?: palette?.dominantSwatch + val lightVibrant = palette?.lightVibrantSwatch + val lightMuted = palette?.lightMutedSwatch + val darkMuted = palette?.darkMutedSwatch + val muted = palette?.mutedSwatch + + onPaletteGenerated( + PaletteColors( + vibrantRgb = vibrant?.rgb, + vibrantTitleTextColor = vibrant?.titleTextColor, + vibrantBodyTextColor = vibrant?.bodyTextColor, + darkVibrantRgb = darkVibrant?.rgb, + darkVibrantBodyTextColor = darkVibrant?.bodyTextColor, + lightVibrantRgb = lightVibrant?.rgb, + lightMutedRgb = lightMuted?.rgb, + darkMutedRgb = darkMuted?.rgb, + mutedRgb = muted?.rgb + ) + ) + } + } + } + ) + } + // Backdrop Scrim + Box( + modifier = Modifier + .fillMaxSize() + .background( + paletteColors?.darkVibrantRgb?.let { Color(it).copy(alpha = 0.3f) } + ?: Color.Black.copy(alpha = 0.3f) + ) + ) + } +} + +@Composable +fun HeaderInfoCard( + state: MediaDetailsUiState, + paletteColors: PaletteColors?, + onHeaderClick: () -> Unit, + onBottomPositionCalculated: (Float) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 220.dp) + .padding(horizontal = 8.dp) + .clickable { onHeaderClick() }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = paletteColors?.vibrantRgb?.let { Color(it) } + ?: MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + // Title and basic info positioned to the right of the poster + Column( + modifier = Modifier + .padding(start = 156.dp, top = 16.dp, end = 16.dp) + .onGloballyPositioned { coords -> + val bottom = coords.positionInParent().y + coords.size.height + onBottomPositionCalculated(bottom) + } + ) { + Text( + text = state.title, + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp + ), + color = paletteColors?.vibrantTitleTextColor?.let { Color(it) } + ?: Color.Unspecified + ) + + if (state.genres.isNotEmpty()) { + Text( + text = state.genres, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.alpha(if (paletteColors != null) 1f else 0.6f), + color = paletteColors?.vibrantBodyTextColor?.let { Color(it) } + ?: Color.Unspecified + ) + } + + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (!state.certification.isNullOrEmpty()) { + val badgeContentColor = paletteColors?.vibrantBodyTextColor?.let { Color(it) } + ?: MaterialTheme.colorScheme.onSecondaryContainer + Surface( + color = badgeContentColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(4.dp), + modifier = Modifier.padding(end = 6.dp) + ) { + Text( + text = state.certification, + style = MaterialTheme.typography.labelSmall, + color = badgeContentColor, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp) + ) + } + } + + if (state.runtimeText.isNotEmpty()) { + Text( + text = state.runtimeText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.alpha(if (paletteColors != null) 1f else 0.6f), + color = paletteColors?.vibrantBodyTextColor?.let { Color(it) } + ?: Color.Unspecified + ) + } + + if (state.releaseDateText.isNotEmpty()) { + Text( + text = state.releaseDateText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.alpha(if (paletteColors != null) 1f else 0.6f), + color = paletteColors?.vibrantBodyTextColor?.let { Color(it) } + ?: Color.Unspecified + ) + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Tagline + if (state.tagline.isNotEmpty()) { + Text( + text = state.tagline, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Medium + ), + modifier = Modifier + .padding(horizontal = 20.dp) + .alpha(if (paletteColors != null) 1f else 0.8f), + color = paletteColors?.vibrantBodyTextColor?.let { Color(it) } + ?: Color.Unspecified + ) + } + + // Overview + if (state.overview.isNotEmpty()) { + Text( + text = state.overview, + style = MaterialTheme.typography.bodySmall, + overflow = TextOverflow.Ellipsis, + maxLines = 3, + modifier = Modifier + .padding(horizontal = 20.dp) + .padding(top = 8.dp) + .alpha(if (paletteColors != null) 1f else 0.7f), + color = paletteColors?.vibrantBodyTextColor?.let { Color(it) } + ?: Color.Unspecified + ) + } + } + } +} + +@Composable +fun HeaderPoster( + posterUrl: String, + posterTop: Dp, + tiltAnimValue: Float, + shineAnimValue: Float, + onPosterLoaded: () -> Unit, + onClick: () -> Unit +) { + val density = LocalDensity.current + Card( + modifier = Modifier + .padding(start = 24.dp, top = posterTop) + .graphicsLayer { + cameraDistance = 12f * density.density + rotationY = tiltAnimValue + rotationX = -tiltAnimValue * 0.2f + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), + shape = RoundedCornerShape(16.dp) + ) { + Box(modifier = Modifier.size(120.dp, 180.dp)) { + AsyncImage( + model = posterUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + error = painterResource(R.drawable.poster_error_placeholder), + onSuccess = { onPosterLoaded() }, + onError = { onPosterLoaded() } + ) + + if (shineAnimValue in 0.01f..0.99f) { + Canvas(modifier = Modifier.fillMaxSize()) { + val progress = shineAnimValue + val w = size.width + val h = size.height + + val offset = (progress * 2f) - 0.5f + val startX = w * offset + val startY = h * offset + + val brush = Brush.linearGradient( + colors = listOf( + Color.Transparent, + Color.White.copy(alpha = 0.4f), + Color.Transparent + ), + start = Offset(startX, startY), + end = Offset(startX + w * 0.5f, startY + h * 0.5f) + ) + drawRect(brush = brush) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun DetailsHeaderPreview() { + AppTheme { + DetailsHeader( + state = MediaDetailsUiState( + mediaId = 1, + title = "Inception", + overview = "Cobb, a skilled thief who commits corporate espionage by infiltrating the subconscious of his targets is offered a chance to regain his old life as payment for a task considered to be impossible: \"inception\", the implantation of another person's idea into a target's subconscious.", + tagline = "Your mind is the scene of the crime.", + backdropPath = "/8Z99vYmda99uSHI6fSToMvSztpZ.jpg", + posterPath = "/edv5bs1pS9v796LpT2M0sYhC76B.jpg", + genres = "Action / Sci-Fi", + runtimeText = "2h 28m", + releaseDateText = " • 16 Jul 2010", + youtubeTrailers = null, + isWatched = false, + isWatchlist = false, + isTvShow = false + ) + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/ExternalLinksSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/ExternalLinksSection.kt new file mode 100644 index 00000000..4886d3a2 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/ExternalLinksSection.kt @@ -0,0 +1,136 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.geometry.Offset +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.R + +private data class ExternalLink(val label: String, val url: String, val iconRes: Int?) + +private const val GRID_COLUMNS = 2 + +/** Grid of tappable link tiles (official site, IMDb, socials) for the currently-open title. */ +@Composable +fun ExternalLinksSection( + homepage: String?, + imdbId: String?, + facebookId: String?, + instagramId: String?, + twitterId: String?, + onLinkClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + val links = buildList { + homepage?.let { add(ExternalLink("Website", it, R.drawable.ic_language_24dp)) } + imdbId?.let { add(ExternalLink("IMDb", "https://www.imdb.com/title/$it/", R.drawable.imdb)) } + facebookId?.let { add(ExternalLink("Facebook", "https://www.facebook.com/$it", null)) } + instagramId?.let { add(ExternalLink("Instagram", "https://www.instagram.com/$it", null)) } + twitterId?.let { add(ExternalLink("X", "https://x.com/$it", null)) } + } + if (links.isEmpty()) return + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp) + ) { + DottedDivider(modifier = Modifier.padding(vertical = 16.dp)) + + links.chunked(GRID_COLUMNS).forEach { rowLinks -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + rowLinks.forEach { link -> + LinkTile(link = link, onLinkClick = onLinkClick, modifier = Modifier.weight(1f)) + } + if (rowLinks.size < GRID_COLUMNS) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun DottedDivider(modifier: Modifier = Modifier) { + val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f) + Canvas( + modifier = modifier + .fillMaxWidth() + .height(1.dp) + ) { + drawLine( + color = color, + start = Offset(0f, size.height / 2), + end = Offset(size.width, size.height / 2), + strokeWidth = 4f, + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(1f, 10f), 0f) + ) + } +} + +@Composable +private fun LinkTile(link: ExternalLink, onLinkClick: (String) -> Unit, modifier: Modifier = Modifier) { + val mutedColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + + Row( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onLinkClick(link.url) } + .padding(vertical = 13.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (link.iconRes != null) { + Image( + painter = painterResource(link.iconRes), + contentDescription = null, + alpha = 0.7f, + modifier = Modifier.size(14.dp) + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + tint = mutedColor, + modifier = Modifier.size(14.dp) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = link.label, + style = MaterialTheme.typography.bodySmall, + color = mutedColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = mutedColor.copy(alpha = 0.5f), + modifier = Modifier + .size(12.dp) + .graphicsLayer { rotationZ = -45f } + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/FullReadSheet.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/FullReadSheet.kt new file mode 100644 index 00000000..24604393 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/FullReadSheet.kt @@ -0,0 +1,57 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import tech.salroid.filmy.ui.theme.AppTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FullReadSheet( + title: String, + content: String, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(bottom = 16.dp) + ) + Text( + text = content, + style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 24.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun FullReadSheetPreview() { + AppTheme { + FullReadSheet( + title = "Inception", + content = "A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O., but his tragic past may doom the project and his team to disaster.", + onDismiss = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/KeywordsSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/KeywordsSection.kt new file mode 100644 index 00000000..daa2047a --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/KeywordsSection.kt @@ -0,0 +1,56 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.LazyHorizontalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.data.local.model.Keyword + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun KeywordsSection( + keywords: List?, + onKeywordClick: (Int, String) -> Unit +) { + if (keywords.isNullOrEmpty()) return + + LazyHorizontalStaggeredGrid( + rows = StaggeredGridCells.Fixed(2), + modifier = Modifier + .padding(vertical = 8.dp) + .fillMaxWidth() + .height(72.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalItemSpacing = 6.dp + ) { + items(keywords) { keyword -> + val name = keyword.name ?: return@items + AssistChip( + onClick = { onKeywordClick(keyword.id, name) }, + label = { + Text( + text = name, + style = MaterialTheme.typography.labelSmall + ) + }, + modifier = Modifier.height(28.dp), + shape = RoundedCornerShape(percent = 50), + colors = AssistChipDefaults.assistChipColors( + labelColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/MediaSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/MediaSection.kt new file mode 100644 index 00000000..e9064895 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/MediaSection.kt @@ -0,0 +1,96 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.SimilarMovie +import tech.salroid.filmy.data.local.model.SimilarMoviesResponse +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun MediaSuggestionsSection( + title: String, + response: SimilarMoviesResponse?, + onMediaClick: (Int) -> Unit +) { + if (response?.results?.isNotEmpty() == true) { + DetailsSection(title = title) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(response.results) { media -> + SuggestionItem(media.title, media.posterPath) { + onMediaClick(media.id ?: 0) + } + } + } + } + } +} + +@Composable +fun SuggestionItem( + title: String?, + posterPath: String?, + onClick: () -> Unit = {} +) { + Column( + modifier = Modifier + .width(100.dp) + .clickable { onClick() } + ) { + AsyncImage( + model = stringResource(R.string.movie_poster_url, posterPath ?: ""), + contentDescription = null, + modifier = Modifier + .size(100.dp, 145.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + Text( + text = title ?: "", + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun MediaSuggestionsSectionPreview() { + val sampleMovies = SimilarMoviesResponse( + results = arrayListOf( + SimilarMovie( + id = 1, + title = "Inception", + posterPath = "/edv5bs1pS9v796LpT2M0sYhC76B.jpg" + ), + SimilarMovie( + id = 2, + title = "Interstellar", + posterPath = "/edv5bs1pS9v796LpT2M0sYhC76B.jpg" + ) + ) + ) + AppTheme { + MediaSuggestionsSection( + title = "Similar", + response = sampleMovies, + onMediaClick = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/RateMediaSheet.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/RateMediaSheet.kt new file mode 100644 index 00000000..9083efbf --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/RateMediaSheet.kt @@ -0,0 +1,105 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.R +import java.util.Locale +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun RateMediaSheet( + currentRating: Float?, + onSubmit: (Float) -> Unit, + onRemove: () -> Unit, + onDismiss: () -> Unit +) { + var sliderValue by remember { mutableFloatStateOf(currentRating ?: 5f) } + val haptic = LocalHapticFeedback.current + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + MaterialExpressiveTheme( + colorScheme = MaterialTheme.colorScheme, + typography = MaterialTheme.typography + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp) + ) { + Text( + text = stringResource(R.string.your_rating), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(bottom = 8.dp) + ) + + Text( + text = String.format(Locale.getDefault(), "%.1f", sliderValue), + style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Slider( + value = sliderValue, + onValueChange = { newValue -> + val rounded = (newValue * 2).roundToInt() / 2f + if (rounded != sliderValue) { + haptic.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + sliderValue = rounded + }, + valueRange = 0.5f..10f, + steps = 18, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (currentRating != null) { + TextButton(onClick = onRemove) { + Text(stringResource(R.string.remove_rating)) + } + } + Button(onClick = { onSubmit(sliderValue) }) { + Text(stringResource(R.string.rate)) + } + } + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/RatingsSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/RatingsSection.kt new file mode 100644 index 00000000..a6619118 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/RatingsSection.kt @@ -0,0 +1,231 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +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.material3.* +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.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.common.model.* +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun RatingsSection( + ratings: RatingsUiModel?, + paletteColors: PaletteColors? = null +) { + if (ratings == null || ratings.ratings.isEmpty()) return + + val context = LocalContext.current + val openUrl = { url: String -> + val customTabsIntent = CustomTabsIntent.Builder().build() + customTabsIntent.launchUrl(context, url.toUri()) + } + + val ratingList = ratings.ratings + + Column( + modifier = Modifier.padding( + top = 4.dp, + bottom = 4.dp + ) + ) { + Text( + text = "Ratings", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp) + ) + + when (ratingList.size) { + 1 -> { + // Single rating: Full width + RatingCard( + rating = ratingList[0], + modifier = Modifier + .fillMaxWidth(), + paletteColors = paletteColors, + onClick = { ratingList[0].url?.let { openUrl(it) } } + ) + } + + 2 -> { + // Two ratings: Half width each + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + RatingCard( + rating = ratingList[0], + modifier = Modifier.weight(1f), + paletteColors = paletteColors, + onClick = { ratingList[0].url?.let { openUrl(it) } } + ) + RatingCard( + rating = ratingList[1], + modifier = Modifier.weight(1f), + paletteColors = paletteColors, + onClick = { ratingList[1].url?.let { openUrl(it) } } + ) + } + } + + else -> { + // More than two: Default horizontal list + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 4.dp) + ) { + items(ratingList) { rating -> + RatingCard( + rating = rating, + modifier = Modifier + .width(IntrinsicSize.Max) + .widthIn(min = 130.dp), + paletteColors = paletteColors, + onClick = { rating.url?.let { openUrl(it) } } + ) + } + } + } + } + } +} + +@Composable +fun RatingCard( + rating: RatingSourceUiModel, + modifier: Modifier = Modifier, + paletteColors: PaletteColors? = null, + onClick: () -> Unit +) { + val backgroundColor = rememberPaletteCardColor( + paletteColors = paletteColors, + fallback = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + + Surface( + modifier = modifier + .height(52.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = rating.url != null, onClick = onClick), + color = backgroundColor, + shape = RoundedCornerShape(16.dp), + border = androidx.compose.foundation.BorderStroke( + width = 0.5.dp, + color = backgroundColor.copy(alpha = 0.2f) + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val icon = when (rating.source) { + RatingSource.IMDB -> R.drawable.imdb + RatingSource.ROTTEN_TOMATOES -> R.drawable.rotten + RatingSource.TMDB -> R.drawable.tmdb_logo + RatingSource.USER -> R.drawable.ic_stars + else -> R.drawable.ic_stars + } + + Image( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = rating.value, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1 + ) + Text( + text = when (rating.source) { + RatingSource.IMDB -> "IMDb" + RatingSource.ROTTEN_TOMATOES -> "Rotten Tomatoes" + RatingSource.TMDB -> "TMDB" + RatingSource.METACRITIC -> "Metacritic" + RatingSource.USER -> "You" + else -> "Rating" + }, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), + lineHeight = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + if (rating.url != null) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.0f)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier + .size(12.dp) + .graphicsLayer { + rotationZ = -45f + } + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun RatingsSectionPreview() { + val sampleRatings = RatingsUiModel( + ratings = listOf( + RatingSourceUiModel(RatingSource.IMDB, "5.3/10", "https://imdb.com"), + RatingSourceUiModel(RatingSource.ROTTEN_TOMATOES, "12%", "https://rottentomatoes.com") + ) + ) + AppTheme { + Box(modifier = Modifier.padding(16.dp)) { + RatingsSection(ratings = sampleRatings) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/ReviewSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/ReviewSection.kt new file mode 100644 index 00000000..157c946c --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/ReviewSection.kt @@ -0,0 +1,111 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.common.model.ReviewResponseUiModel +import tech.salroid.filmy.ui.common.model.ReviewUiModel +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun ReviewsSection( + reviews: ReviewResponseUiModel?, + onViewAllClick: (() -> Unit)? = null, + onReviewClick: (String, String) -> Unit +) { + if (reviews?.results?.isNotEmpty() == true) { + val showViewAll = reviews.results.size > 2 + + DetailsSection( + title = "Reviews" + ) { + Column { + reviews.results.take(2).forEach { review -> + ReviewItem(review) { + onReviewClick(review.author, review.content) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + if (showViewAll && onViewAllClick != null) { + SeeAllItem(onClick = onViewAllClick) + } + } + } + } +} + +@Composable +fun ReviewItem( + review: ReviewUiModel, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.3f + ) + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = review.authorAvatarUrl, + contentDescription = null, + modifier = Modifier + .size(30.dp) + .clip(CircleShape), + placeholder = painterResource(R.drawable.default_avatar), + error = painterResource(R.drawable.default_avatar) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text(text = review.author, style = MaterialTheme.typography.labelLarge) + Text( + text = review.createdAt, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.alpha(0.6f) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = review.content, + style = MaterialTheme.typography.bodySmall, + maxLines = 4, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ReviewsSectionPreview() { + val sampleReviews = ReviewResponseUiModel( + results = listOf( + ReviewUiModel(id = "1", author = "John Doe", content = "Great movie!", createdAt = "01 Jan 2024", authorAvatarUrl = null), + ReviewUiModel(id = "2", author = "Jane Doe", content = "I loved it!", createdAt = "02 Jan 2024", authorAvatarUrl = null) + ) + ) + AppTheme { + ReviewsSection( + reviews = sampleReviews, + onReviewClick = { _, _ -> }) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/SeasonsSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/SeasonsSection.kt new file mode 100644 index 00000000..801657f2 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/SeasonsSection.kt @@ -0,0 +1,85 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.common.model.SeasonUiModel +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun SeasonsSection( + seasons: List?, + onSeasonClick: (Int) -> Unit +) { + if (!seasons.isNullOrEmpty()) { + DetailsSection(title = "Seasons") { + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(seasons) { season -> + SeasonItem(season) { onSeasonClick(season.seasonNumber) } + } + } + } + } +} + +@Composable +private fun SeasonItem( + season: SeasonUiModel, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .width(100.dp) + .clickable { onClick() } + ) { + AsyncImage( + model = stringResource(R.string.movie_poster_url, season.posterPath ?: ""), + contentDescription = null, + modifier = Modifier + .size(100.dp, 145.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + Text( + text = season.name, + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + Text( + text = "${season.episodeCount} Episodes", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.alpha(0.6f) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun SeasonsSectionPreview() { + AppTheme { + SeasonsSection( + seasons = listOf( + SeasonUiModel(1, 1, "Season 1", 8, "/edv5bs1pS9v796LpT2M0sYhC76B.jpg", "2020-01-01"), + SeasonUiModel(2, 2, "Season 2", 10, "/edv5bs1pS9v796LpT2M0sYhC76B.jpg", "2021-01-01") + ), + onSeasonClick = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/StudiosSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/StudiosSection.kt new file mode 100644 index 00000000..678e3952 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/StudiosSection.kt @@ -0,0 +1,70 @@ +package tech.salroid.filmy.ui.movies.details.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import tech.salroid.filmy.ui.common.model.StudioUiModel + +@Composable +fun StudiosSection(studios: List?) { + val withLogos = studios?.filter { !it.logoPath.isNullOrEmpty() } ?: emptyList() + if (withLogos.isEmpty()) return + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Studios", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .alpha(0.6f) + .padding(bottom = 6.dp) + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(withLogos) { studio -> + Box( + modifier = Modifier + .size(width = 84.dp, height = 44.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = "https://image.tmdb.org/t/p/w185${studio.logoPath}", + contentDescription = studio.name, + modifier = Modifier.size(width = 68.dp, height = 32.dp), + contentScale = ContentScale.Fit + ) + } + } + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/TrailerSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/TrailerSection.kt new file mode 100644 index 00000000..6ad54d6a --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/TrailerSection.kt @@ -0,0 +1,113 @@ +package tech.salroid.filmy.ui.movies.details.components + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +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.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.Youtube +import tech.salroid.filmy.ui.common.model.PaletteColors +import tech.salroid.filmy.ui.theme.AppTheme + +@SuppressLint("QueryPermissionsNeeded") +@Composable +fun TrailersSection( + youtubeTrailers: List?, + paletteColors: PaletteColors? = null, + onTrailerClick: (String) -> Unit = {}, + onPlusMoreClick: () -> Unit = {} +) { + youtubeTrailers?.firstOrNull()?.let { trailer -> + Card( + modifier = Modifier + .fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = paletteColors?.darkVibrantRgb?.let { Color(it) } + ?: MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + Box( + modifier = Modifier + .weight(7.5f) + .clickable { + trailer.source?.let { onTrailerClick(it) } + } + ) { + AsyncImage( + model = "https://img.youtube.com/vi/${trailer.source}/0.jpg", + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(154.dp) + .padding(4.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + Icon( + painter = painterResource(R.drawable.ic_play_circle_filled_white_48dp), + contentDescription = null, + tint = Color.White.copy(alpha = 0.8f), + modifier = Modifier + .align(Alignment.Center) + .size(48.dp) + ) + } + + Column( + modifier = Modifier + .weight(2.5f) + .fillMaxHeight() + .clickable { onPlusMoreClick() }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.twotone_video_library_24), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = paletteColors?.darkVibrantBodyTextColor?.let { Color(it) } + ?: LocalContentColor.current + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.plus_more).uppercase(), + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + color = paletteColors?.darkVibrantBodyTextColor?.let { Color(it) } + ?: Color.Unspecified + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun TrailersSectionPreview() { + val sampleTrailers = listOf( + Youtube(name = "Official Trailer", source = "8Z99vYmda99uSHI6fSToMvSztpZ"), + Youtube(name = "Teaser Trailer", source = "8Z99vYmda99uSHI6fSToMvSztpZ") + ) + AppTheme { + TrailersSection(youtubeTrailers = sampleTrailers) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/WatchProvidersSection.kt b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/WatchProvidersSection.kt new file mode 100644 index 00000000..b6888d2e --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/movies/details/components/WatchProvidersSection.kt @@ -0,0 +1,218 @@ +package tech.salroid.filmy.ui.movies.details.components + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +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.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.common.model.PaletteColors +import tech.salroid.filmy.ui.common.model.ProviderUiModel +import tech.salroid.filmy.ui.common.model.WatchProvidersUiModel +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun WatchProvidersSection( + watchProviders: WatchProvidersUiModel, + paletteColors: PaletteColors? = null +) { + val context = LocalContext.current + val link = watchProviders.link + val providers = watchProviders.providers + + if (providers.isNotEmpty()) { + val onProviderClick = { + link?.let { + val intent = Intent(Intent.ACTION_VIEW, it.toUri()) + context.startActivity(intent) + } + } + + Column( + modifier = Modifier.padding(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + + Text( + text = stringResource(R.string.where_to_watch), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + when (providers.size) { + 1 -> { + ProviderCard( + provider = providers[0], + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + paletteColors = paletteColors, + onClick = { onProviderClick() } + ) + } + + 2 -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ProviderCard( + provider = providers[0], + modifier = Modifier.weight(1f), + paletteColors = paletteColors, + onClick = { onProviderClick() } + ) + ProviderCard( + provider = providers[1], + modifier = Modifier.weight(1f), + paletteColors = paletteColors, + onClick = { onProviderClick() } + ) + } + } + + else -> { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp) + ) { + items(providers) { provider -> + ProviderCard( + provider = provider, + modifier = Modifier + .width(IntrinsicSize.Max) + .widthIn(min = 130.dp), + paletteColors = paletteColors, + onClick = { onProviderClick() } + ) + } + } + } + } + } + } +} + +@Composable +fun ProviderCard( + provider: ProviderUiModel, + modifier: Modifier = Modifier, + paletteColors: PaletteColors? = null, + onClick: () -> Unit +) { + val isDark = isSystemInDarkTheme() + val defaultCardColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + val backgroundColor = remember(paletteColors, isDark, defaultCardColor) { + val colorInt = if (isDark) { + paletteColors?.darkMutedRgb ?: paletteColors?.darkVibrantRgb + } else { + paletteColors?.lightMutedRgb ?: paletteColors?.lightVibrantRgb + } + colorInt?.let { Color(it).copy(alpha = 0.15f) } + ?: defaultCardColor + } + + Surface( + modifier = modifier + .height(52.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick), + color = backgroundColor, + shape = RoundedCornerShape(16.dp), + border = androidx.compose.foundation.BorderStroke( + width = 0.5.dp, + color = backgroundColor.copy(alpha = 0.2f) + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = stringResource(R.string.member_profile_url, provider.logoPath), + contentDescription = provider.name, + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + modifier = Modifier.weight(1f), + text = provider.name, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 12.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + lineHeight = 14.sp, + overflow = TextOverflow.Ellipsis + ) + + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.0f)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier + .size(12.dp) + .graphicsLayer { + rotationZ = -45f + } + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun WatchProvidersSectionPreview() { + val sampleProviders = WatchProvidersUiModel( + link = "https://www.themoviedb.org", + providers = listOf( + ProviderUiModel(1, "Netflix", "/t2Y9jYvMvIDpS0T3H4pYt6pS6pS.jpg"), + ProviderUiModel(2, "Amazon Prime Video", "/t2Y9jYvMvIDpS0T3H4pYt6pS6pS.jpg") + ) + ) + AppTheme { + Box(modifier = Modifier.padding(16.dp)) { + WatchProvidersSection(watchProviders = sampleProviders) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/onboarding/AnimatedPosterBackdrop.kt b/app/src/main/java/tech/salroid/filmy/ui/onboarding/AnimatedPosterBackdrop.kt new file mode 100644 index 00000000..b793e0dd --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/onboarding/AnimatedPosterBackdrop.kt @@ -0,0 +1,119 @@ +package tech.salroid.filmy.ui.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +private const val FRAME_DELAY_MS = 16L + +/** + * Immersive hero backdrop: 3 columns of posters, each auto-scrolling + * continuously at a different speed/direction, forever. + * + * Genuinely infinite by construction - each column is a [LazyColumn] with a + * huge fake item count, indexed onto the real (short) poster list via + * modulo, so there is no "ran out of content" case to get wrong: LazyColumn + * only ever composes what's on screen plus a small buffer, regardless of how + * far the scroll position has moved. Purely decorative - if [posterUrls] is + * empty (offline first launch, fetch failed, etc.) this just renders a plain + * surface instead. + */ +@Composable +fun AnimatedPosterBackdrop( + posterUrls: List, + modifier: Modifier = Modifier +) { + if (posterUrls.isEmpty()) { + Box(modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant)) + return + } + + val columnCount = 3 + val columns = remember(posterUrls) { + // Every column draws from the whole poster list (just rotated to a + // different starting point) rather than a disjoint split, so no + // column is ever starved of source images. + val step = (posterUrls.size / columnCount).coerceAtLeast(1) + (0 until columnCount).map { columnIndex -> + val rotation = columnIndex * step + posterUrls.drop(rotation) + posterUrls.take(rotation) + } + } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + columns.forEachIndexed { index, posters -> + PosterColumn( + posters = posters, + pixelsPerFrame = 0.6f + index * 0.3f, + scrollUp = index % 2 == 0, + modifier = Modifier + .weight(1f) + .fillMaxSize() + ) + } + } +} + +@Composable +private fun PosterColumn( + posters: List, + pixelsPerFrame: Float, + scrollUp: Boolean, + modifier: Modifier = Modifier +) { + // Scrolling "up" only ever increases the scroll position, so index 0 is a + // safe start. Scrolling "down" decreases it, so it starts deep in the + // middle of the fake range to leave effectively unlimited headroom. + val startIndex = if (scrollUp) 0 else Int.MAX_VALUE / 2 + val listState = rememberLazyListState(initialFirstVisibleItemIndex = startIndex) + + LaunchedEffect(listState, scrollUp, pixelsPerFrame) { + while (isActive) { + listState.scrollBy(if (scrollUp) pixelsPerFrame else -pixelsPerFrame) + delay(FRAME_DELAY_MS) + } + } + + LazyColumn( + state = listState, + userScrollEnabled = false, + modifier = modifier + ) { + items(count = Int.MAX_VALUE) { index -> + AsyncImage( + model = posters[index % posters.size], + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(bottom = 12.dp) + .clip(RoundedCornerShape(8.dp)) + ) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 00000000..e7146d32 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/onboarding/OnboardingScreen.kt @@ -0,0 +1,171 @@ +package tech.salroid.filmy.ui.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.account.RegionSelectionDialog +import tech.salroid.filmy.utility.PreferenceHelper +import java.util.Locale + +private const val HERO_HEIGHT_FRACTION = 0.70f + +@Composable +fun OnboardingScreen( + onDone: () -> Unit, + viewModel: OnboardingViewModel = hiltViewModel() +) { + val context = LocalContext.current + var selectedCountry by remember { mutableStateOf(PreferenceHelper.getSelectedCountry(context)) } + var hasUserPickedCountry by remember { mutableStateOf(false) } + var showCountryDialog by remember { mutableStateOf(false) } + val posterUrls by viewModel.posterUrls.collectAsStateWithLifecycle() + val detectedCountry by viewModel.detectedCountry.collectAsStateWithLifecycle() + + // IP-based guess is best-effort and arrives async - only apply it if the + // user hasn't already made their own choice in the meantime. + LaunchedEffect(detectedCountry) { + val country = detectedCountry + if (country != null && !hasUserPickedCountry) { + selectedCountry = country + } + } + + val selectedCountryName = remember(selectedCountry) { + Locale("", selectedCountry).displayCountry.ifBlank { selectedCountry } + } + + Box(modifier = Modifier.fillMaxSize()) { + AnimatedPosterBackdrop( + posterUrls = posterUrls, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(HERO_HEIGHT_FRACTION) + .align(Alignment.TopCenter) + ) + + // One continuous gradient spanning from well within the hero region + // down into where the panel content starts, so the poster imagery + // fades gradually into the solid panel color instead of hitting a + // hard edge. The panel itself has no background of its own - by the + // time content reaches it, this gradient is already fully solid. + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + (HERO_HEIGHT_FRACTION - 0.22f) to Color.Transparent, + (HERO_HEIGHT_FRACTION + 0.06f) to MaterialTheme.colorScheme.background.copy(alpha = 0.45f), + 1f to MaterialTheme.colorScheme.background.copy(alpha = 0.45f) + ) + ) + ) + + Scaffold(containerColor = Color.Transparent) { paddingValues -> + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(start = 20.dp, end = 20.dp, bottom = 16.dp) + ) { + Text( + text = "Welcome to Filmy", + style = MaterialTheme.typography.titleLarge + ) + Text( + text = "Set your country for accurate watch providers, ratings, and release dates.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .alpha(0.7f) + .padding(top = 4.dp, bottom = 20.dp) + ) + + OutlinedButton( + onClick = { showCountryDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Public, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = selectedCountryName, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.Default.KeyboardArrowRight, + contentDescription = "Change country" + ) + } + + Button( + onClick = { + PreferenceHelper.setSelectedCountry(context, selectedCountry) + PreferenceHelper.setColdStartDone(context) + onDone() + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) { + Text("Continue") + } + } + } + } + } + + if (showCountryDialog) { + RegionSelectionDialog( + selectedCountry = selectedCountry, + onDismiss = { showCountryDialog = false }, + onCountrySelected = { code -> + selectedCountry = code + hasUserPickedCountry = true + showCountryDialog = false + } + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 00000000..f3383fa4 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,47 @@ +package tech.salroid.filmy.ui.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import tech.salroid.filmy.ui.home.MoviesRepository +import tech.salroid.filmy.utility.ImageConfig +import tech.salroid.filmy.utility.IpGeolocation +import javax.inject.Inject + +/** + * Purely decorative - powers the animated poster backdrop on the onboarding + * screen. Best-effort only: a failed fetch just leaves the backdrop empty, + * it never blocks or affects the actual country-selection flow. + */ +@HiltViewModel +class OnboardingViewModel @Inject constructor( + private val moviesRepository: MoviesRepository +) : ViewModel() { + + private val _posterUrls = MutableStateFlow>(emptyList()) + val posterUrls: StateFlow> = _posterUrls.asStateFlow() + + /** Best-effort IP-based country guess, null until resolved (or if it fails). */ + private val _detectedCountry = MutableStateFlow(null) + val detectedCountry: StateFlow = _detectedCountry.asStateFlow() + + init { + viewModelScope.launch { + moviesRepository.getMoviesFlow(type = "day", isTrending = true) + .collect { result -> + result.getOrNull()?.results?.let { movies -> + _posterUrls.value = movies.mapNotNull { it.posterPath } + .map { ImageConfig.BASE_URL + it } + .shuffled() + } + } + } + viewModelScope.launch { + _detectedCountry.value = IpGeolocation.fetchCountryCode() + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/people/PeopleScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/people/PeopleScreen.kt new file mode 100644 index 00000000..34feea0f --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/people/PeopleScreen.kt @@ -0,0 +1,148 @@ +package tech.salroid.filmy.ui.people + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.Person +import tech.salroid.filmy.ui.common.components.ErrorWidget +import tech.salroid.filmy.ui.common.components.LoadingWidget +import tech.salroid.filmy.ui.common.components.PaginatedPreviewList +import tech.salroid.filmy.utility.toUserMessage + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PeopleScreen( + modifier: Modifier = Modifier, + viewModel: PeopleViewModel = hiltViewModel(), + onPersonClick: (Int) -> Unit, + onBackClick: () -> Unit = {} +) { + val people = viewModel.people.collectAsLazyPagingItems() + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text("Popular People") }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + PeopleContent(people = people, onPersonClick = onPersonClick) + } + } +} + +@Composable +private fun PeopleContent( + people: LazyPagingItems, + onPersonClick: (Int) -> Unit +) { + when (val state = people.loadState.refresh) { + is LoadState.Loading -> { + LoadingWidget(modifier = Modifier.fillMaxSize()) + } + + is LoadState.Error -> { + ErrorWidget( + modifier = Modifier.fillMaxSize(), + message = state.error.toUserMessage(), + onRetryClick = { people.retry() } + ) + } + + else -> { + if (people.itemCount == 0 && + people.loadState.append is LoadState.NotLoading && + people.loadState.append.endOfPaginationReached + ) { + ErrorWidget( + modifier = Modifier.fillMaxSize(), + message = "No people found", + onRetryClick = { people.refresh() } + ) + } else { + PaginatedPreviewList( + modifier = Modifier.fillMaxSize(), + items = people, + content = { person -> + PersonItem(person = person, onClick = { onPersonClick(person.id) }) + } + ) + } + } + } +} + +@Composable +private fun PersonItem(person: Person, onClick: () -> Unit) { + Column( + modifier = Modifier + .width(100.dp) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AsyncImage( + model = "https://image.tmdb.org/t/p/w185${person.profilePath}", + contentDescription = null, + modifier = Modifier + .size(70.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.default_avatar), + error = painterResource(R.drawable.default_avatar) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = person.name ?: "", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + if (!person.knownForDepartment.isNullOrBlank()) { + Text( + text = person.knownForDepartment ?: "", + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/people/PeopleViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/people/PeopleViewModel.kt new file mode 100644 index 00000000..403d32f6 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/people/PeopleViewModel.kt @@ -0,0 +1,20 @@ +package tech.salroid.filmy.ui.people + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import tech.salroid.filmy.data.local.model.Person +import tech.salroid.filmy.ui.home.MoviesRepository +import javax.inject.Inject + +@HiltViewModel +class PeopleViewModel @Inject constructor( + moviesRepository: MoviesRepository +) : ViewModel() { + + val people: Flow> = moviesRepository.getPeople() + .cachedIn(viewModelScope) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/reviews/ReviewsListScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/reviews/ReviewsListScreen.kt new file mode 100644 index 00000000..eeb1a2bb --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/reviews/ReviewsListScreen.kt @@ -0,0 +1,137 @@ +package tech.salroid.filmy.ui.reviews + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.details.MovieDetailsViewModel +import tech.salroid.filmy.ui.movies.details.components.ReviewItem + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReviewsListScreen( + id: Int, + isTv: Boolean, + title: String, + viewModel: MovieDetailsViewModel = hiltViewModel(), + onBackClick: () -> Unit +) { + val state by viewModel.mediaDetailsUiState.collectAsStateWithLifecycle() + val reviews = state?.reviews + + var showFullRead by remember { mutableStateOf(false) } + var fullReadContent by remember { mutableStateOf(Pair("", "")) } + + // Optimize here + LaunchedEffect(id, isTv) { + if (isTv) { + viewModel.fetchAllTvDetails( + id.toString(), + 1 + ) + } else { + viewModel.fetchAllMovieDetails( + id.toString(), + 0 + ) + } + } + + if (showFullRead) { + ModalBottomSheet( + onDismissRequest = { showFullRead = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = fullReadContent.first, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(bottom = 16.dp) + ) + Text( + text = fullReadContent.second, + style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 24.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(text = "Reviews", style = MaterialTheme.typography.titleMedium) + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + reviews?.let { data -> + if (data.results.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text("No reviews found.") + } + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + modifier = Modifier.padding(paddingValues), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(data.results) { review -> + ReviewItem(review) { + fullReadContent = Pair(review.author, review.content) + showFullRead = true + } + } + } + } + } ?: run { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/search/SearchFragment.kt b/app/src/main/java/tech/salroid/filmy/ui/search/SearchFragment.kt index 51795d82..00eb7549 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/search/SearchFragment.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/search/SearchFragment.kt @@ -27,7 +27,7 @@ import tech.salroid.filmy.utility.themeSystemBars @AndroidEntryPoint class SearchFragment : Fragment() { - private lateinit var viewModel: SearchViewModel + private lateinit var viewModel: SearchViewModelLegacy private var _binding: FragmentSearchBinding? = null private val binding get() = _binding!! @@ -47,7 +47,7 @@ class SearchFragment : Fragment() { savedInstanceState: Bundle? ): View { _binding = FragmentSearchBinding.inflate(inflater, container, false) - viewModel = ViewModelProvider(requireActivity())[SearchViewModel::class.java] + viewModel = ViewModelProvider(requireActivity())[SearchViewModelLegacy::class.java] val spanCount = when (activity?.resources?.configuration?.orientation) { Configuration.ORIENTATION_PORTRAIT -> 3 diff --git a/app/src/main/java/tech/salroid/filmy/ui/search/SearchPreviewMapper.kt b/app/src/main/java/tech/salroid/filmy/ui/search/SearchPreviewMapper.kt new file mode 100644 index 00000000..308020b6 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/search/SearchPreviewMapper.kt @@ -0,0 +1,24 @@ +package tech.salroid.filmy.ui.search + +import tech.salroid.filmy.data.local.model.SearchResult +import tech.salroid.filmy.data.model.SearchPreview +import tech.salroid.filmy.utility.ImageConfig +import tech.salroid.filmy.utility.formateReleaseDate +import javax.inject.Inject + +class SearchPreviewMapper @Inject constructor() { + + // Make it more testable by taking from constructor + private val posterBaseUrl = ImageConfig.BASE_URL + + fun map(searchResult: SearchResult): SearchPreview = + SearchPreview( + id = searchResult.id, + title = (searchResult.title ?: searchResult.name).orEmpty(), + posterUrl = posterBaseUrl + (searchResult.posterPath ?: searchResult.profilePath).orEmpty(), + readableReleaseDate = (searchResult.releaseDate ?: searchResult.firstAirDate) + ?.let(::formateReleaseDate) + .orEmpty(), + mediaType = searchResult.mediaType + ) +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/search/SearchScreenState.kt b/app/src/main/java/tech/salroid/filmy/ui/search/SearchScreenState.kt new file mode 100644 index 00000000..cbd06a74 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/search/SearchScreenState.kt @@ -0,0 +1,10 @@ +package tech.salroid.filmy.ui.search + +import tech.salroid.filmy.data.model.SearchPreview + +sealed class SearchScreenState { + object Loading : SearchScreenState() + object Idle : SearchScreenState() + data class Success(val previews: List) : SearchScreenState() + data class Error(val errorMessage: String) : SearchScreenState() +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/search/SearchViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/search/SearchViewModel.kt index 3883db11..be6c9d6d 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/search/SearchViewModel.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/search/SearchViewModel.kt @@ -1,79 +1,100 @@ package tech.salroid.filmy.ui.search +import android.content.SharedPreferences import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import tech.salroid.filmy.data.local.model.SearchResult import tech.salroid.filmy.ui.home.MoviesRepository -import tech.salroid.filmy.ui.home.SearchViewUiState +import tech.salroid.filmy.utility.PreferenceHelper.addRecentSearch +import tech.salroid.filmy.utility.PreferenceHelper.clearRecentSearches +import tech.salroid.filmy.utility.PreferenceHelper.recentSearches +import tech.salroid.filmy.utility.PreferenceHelper.removeRecentSearch +import tech.salroid.filmy.utility.toUserMessage import javax.inject.Inject @HiltViewModel +@OptIn( + FlowPreview::class, + ExperimentalCoroutinesApi::class +) class SearchViewModel @Inject constructor( - private val moviesRepository: MoviesRepository + private val moviesRepository: MoviesRepository, + private val searchPreviewMapper: SearchPreviewMapper, + private val sharedPreferences: SharedPreferences ) : ViewModel() { - val isSearchOpen = MutableStateFlow(false) - val query = MutableStateFlow("") + private val _searchQuery = MutableStateFlow("") + val searchQuery = _searchQuery.asStateFlow() - private val _uiStateCloseSearchView = MutableStateFlow(false) - private val _uiStateSearchView = MutableStateFlow(SearchViewUiState.Hidden) - private val _uiStateSearchResult = MutableStateFlow?>(null) - val uiStateSearchResults: StateFlow?> = - _uiStateSearchResult.asStateFlow() - val uiStateSearchView: StateFlow = _uiStateSearchView.asStateFlow() - val uiStateCloseSearch: StateFlow = _uiStateCloseSearchView.asStateFlow() + private val _recentSearches = MutableStateFlow>(sharedPreferences.recentSearches()) + val recentSearches: StateFlow> = _recentSearches.asStateFlow() - init { - searchMovies() - } - - @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) - private fun searchMovies() { - viewModelScope.launch(Dispatchers.IO) { - query.debounce(300) - .filter { query -> - return@filter query.isNotEmpty() - }.distinctUntilChanged().flatMapLatest { query -> - moviesRepository.searchMovies(query) - }.flowOn(Dispatchers.Main).collect { result -> - viewModelScope.launch(Dispatchers.Main) { - result.results - .let { - _uiStateSearchResult.emit(it) - } - } + val uiState = _searchQuery + .debounce(300) + .distinctUntilChanged() + .flatMapLatest { query -> + if (query.isBlank()) { + flowOf(SearchScreenState.Idle) + } else { + flow { + emit(SearchScreenState.Loading) + + moviesRepository + .searchMultiFlow(query) + .collect { searchResultResponse -> + searchResultResponse.fold(onSuccess = { response -> + runCatching { + response.results + .map(searchPreviewMapper::map) + }.fold(onSuccess = { previews -> + emit(SearchScreenState.Success(previews)) + }, onFailure = { exception -> + emit(SearchScreenState.Error(exception.toUserMessage())) + }) + }, onFailure = { exception -> + emit(SearchScreenState.Error(exception.toUserMessage())) + }) + } } - } - } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SearchScreenState.Idle + ) - fun searchViewVisible() { + fun onSearchQueryChange(query: String) { viewModelScope.launch { - _uiStateSearchView.emit(SearchViewUiState.Visible) + _searchQuery.value = query } } - fun searchViewHidden() { - viewModelScope.launch { - _uiStateSearchView.emit(SearchViewUiState.Hidden) - isSearchOpen.emit(false) - } + fun commitSearch(query: String) { + if (query.isBlank()) return + sharedPreferences.addRecentSearch(query) + _recentSearches.value = sharedPreferences.recentSearches() } - fun closeSearch() { - viewModelScope.launch { - _uiStateCloseSearchView.emit(true) - } + fun removeRecentSearch(query: String) { + sharedPreferences.removeRecentSearch(query) + _recentSearches.value = sharedPreferences.recentSearches() } - fun closeSearchDone() { - viewModelScope.launch { - _uiStateCloseSearchView.emit(false) - } + fun clearRecentSearches() { + sharedPreferences.clearRecentSearches() + _recentSearches.value = emptyList() } -} \ No newline at end of file +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/search/SearchViewModelLegacy.kt b/app/src/main/java/tech/salroid/filmy/ui/search/SearchViewModelLegacy.kt new file mode 100644 index 00000000..235e2a70 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/search/SearchViewModelLegacy.kt @@ -0,0 +1,77 @@ +package tech.salroid.filmy.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import tech.salroid.filmy.data.local.model.SearchResult +import tech.salroid.filmy.ui.home.MoviesRepository +import tech.salroid.filmy.ui.home.SearchViewUiState +import javax.inject.Inject + +@HiltViewModel +class SearchViewModelLegacy @Inject constructor( + private val moviesRepository: MoviesRepository +) : ViewModel() { + + val isSearchOpen = MutableStateFlow(false) + val query = MutableStateFlow("") + + private val _uiStateCloseSearchView = MutableStateFlow(false) + private val _uiStateSearchView = MutableStateFlow(SearchViewUiState.Hidden) + private val _uiStateSearchResult = MutableStateFlow?>(null) + val uiStateSearchResults: StateFlow?> = + _uiStateSearchResult.asStateFlow() + val uiStateSearchView: StateFlow = _uiStateSearchView.asStateFlow() + val uiStateCloseSearch: StateFlow = _uiStateCloseSearchView.asStateFlow() + + init { + searchMovies() + } + + private fun searchMovies() { + viewModelScope.launch(Dispatchers.IO) { + query.debounce(300) + .filter { query -> + return@filter query.isNotEmpty() + }.distinctUntilChanged() + .flatMapLatest { query -> + moviesRepository.searchMovies(query) + }.flowOn(Dispatchers.Main).collect { result -> + viewModelScope.launch(Dispatchers.Main) { + result.results + .let { + _uiStateSearchResult.emit(it) + } + } + } + } + } + + fun searchViewVisible(){ + viewModelScope.launch { + _uiStateSearchView.emit(SearchViewUiState.Visible) + } + } + + fun searchViewHidden(){ + viewModelScope.launch { + _uiStateSearchView.emit(SearchViewUiState.Hidden) + isSearchOpen.emit(false) + } + } + + fun closeSearch() { + viewModelScope.launch { + _uiStateCloseSearchView.emit(true) + } + } + + fun closeSearchDone() { + viewModelScope.launch { + _uiStateCloseSearchView.emit(false) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/search/component/AppSearchBar.kt b/app/src/main/java/tech/salroid/filmy/ui/search/component/AppSearchBar.kt new file mode 100644 index 00000000..3876a1cb --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/search/component/AppSearchBar.kt @@ -0,0 +1,341 @@ +package tech.salroid.filmy.ui.search.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.History +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import tech.salroid.filmy.data.model.SearchPreview +import tech.salroid.filmy.ui.common.components.ErrorWidget +import tech.salroid.filmy.ui.search.SearchScreenState +import tech.salroid.filmy.ui.theme.AppTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppSearchBar( + textFieldState: TextFieldState, + onSearch: (String) -> Unit, + searchUiState: SearchScreenState, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onSearchResultClick: (SearchPreview) -> Unit, + modifier: Modifier = Modifier, + recentSearches: List = emptyList(), + onRecentSearchClick: (String) -> Unit = {}, + onRemoveRecentSearch: (String) -> Unit = {}, + onClearRecentSearches: () -> Unit = {} +) { + Box( + modifier + .fillMaxWidth() + .semantics { isTraversalGroup = true } + .offset(y = (-8).dp) + ) { + SearchBar( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .semantics { traversalIndex = 0f } + .padding( + start = if (!expanded) 12.dp else 0.dp, + end = if (!expanded) 12.dp else 0.dp, + top = 0.dp, + bottom = if (!expanded) 4.dp else 0.dp + ), + inputField = { + SearchBarDefaults.InputField( + state = textFieldState, + onSearch = { + onSearch(textFieldState.text.toString()) + }, + expanded = expanded, + onExpandedChange = onExpandedChange, + placeholder = { + if (expanded) { + Text( + modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium + ), + text = "Search Movies, Shows or People" + ) + } else { + RotatingSearchPlaceholder(modifier = Modifier.padding(start = 16.dp)) + } + }, + trailingIcon = { + if (textFieldState.text.isNotEmpty()) { + IconButton(onClick = { textFieldState.clearText() }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear search" + ) + } + } + } + ) + }, + expanded = expanded, + onExpandedChange = onExpandedChange, + ) { + var showLoading by remember { mutableStateOf(false) } + LaunchedEffect(searchUiState) { + if (searchUiState is SearchScreenState.Loading) { + delay(400) // Show loading only if it takes more than 400ms + showLoading = true + } else { + showLoading = false + } + } + + AnimatedVisibility( + visible = showLoading, + enter = fadeIn(), + exit = fadeOut() + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(2.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + + val query = textFieldState.text.toString() + when { + searchUiState is SearchScreenState.Error -> { + ErrorWidget( + modifier = Modifier.fillMaxSize(), + message = searchUiState.errorMessage, + onRetryClick = { onSearch(query) } + ) + } + + query.isBlank() -> { + RecentSearchesList( + recentSearches = recentSearches, + onRecentSearchClick = { recent -> + textFieldState.setTextAndPlaceCursorAtEnd(recent) + onSearch(recent) + }, + onRemoveClick = onRemoveRecentSearch, + onClearAllClick = onClearRecentSearches + ) + } + + searchUiState is SearchScreenState.Success && searchUiState.previews.isEmpty() -> { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + text = "No results for \"$query\"", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } + + else -> { + val searchResults = (searchUiState as? SearchScreenState.Success)?.previews ?: emptyList() + SearchList(searchPreviews = searchResults) { item -> + onExpandedChange(false) + onSearchResultClick(item) + } + } + } + } + } +} + +private val searchPlaceholderSubjects = listOf("Movies", "Shows", "People") + +@Composable +private fun RotatingSearchPlaceholder(modifier: Modifier = Modifier) { + var index by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + while (true) { + delay(4800) + index = (index + 1) % searchPlaceholderSubjects.size + } + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Search ", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium) + ) + AnimatedContent( + targetState = index, + modifier = Modifier.clipToBounds(), + transitionSpec = { + (slideInVertically { height -> height } + fadeIn()) togetherWith + (slideOutVertically { height -> -height } + fadeOut()) + }, + label = "search_placeholder_subject" + ) { subjectIndex -> + Text( + text = searchPlaceholderSubjects[subjectIndex], + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Composable +private fun RecentSearchesList( + recentSearches: List, + onRecentSearchClick: (String) -> Unit, + onRemoveClick: (String) -> Unit, + onClearAllClick: () -> Unit +) { + if (recentSearches.isEmpty()) return + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Recent searches", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = onClearAllClick) { + Text(text = "Clear all", style = MaterialTheme.typography.labelMedium) + } + } + LazyColumn { + items(recentSearches, key = { it }) { recent -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onRecentSearchClick(recent) } + .padding(horizontal = 22.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.History, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.height(18.dp) + ) + Text( + modifier = Modifier.padding(start = 16.dp), + text = recent, + style = MaterialTheme.typography.bodyMedium + ) + } + IconButton(onClick = { onRemoveClick(recent) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.height(16.dp) + ) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AppSearchBarPreview() { + AppTheme { + AppSearchBar( + textFieldState = rememberTextFieldState(), + onSearch = {}, + searchUiState = SearchScreenState.Idle, + expanded = false, + onExpandedChange = {}, + onSearchResultClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun AppSearchBarExpandedPreview() { + AppTheme { + AppSearchBar( + textFieldState = rememberTextFieldState("Inception"), + onSearch = {}, + searchUiState = SearchScreenState.Success(listOf( + SearchPreview( + 1, + "Inception", + "", + "2010" + ), + SearchPreview( + 2, + "Interstellar", + "", + "2014" + ) + )), + expanded = true, + onExpandedChange = {}, + onSearchResultClick = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/search/component/SearchItem.kt b/app/src/main/java/tech/salroid/filmy/ui/search/component/SearchItem.kt new file mode 100644 index 00000000..2a3ecb86 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/search/component/SearchItem.kt @@ -0,0 +1,153 @@ +package tech.salroid.filmy.ui.search.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.Transparent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.model.SearchPreview +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun SearchItem( + modifier: Modifier = Modifier, + searchPreview: SearchPreview, + onItemClick: () -> Unit +) { + Card( + modifier = modifier, + shape = RoundedCornerShape(corner = CornerSize(4.dp)), + colors = CardDefaults.cardColors(containerColor = Transparent), + onClick = { onItemClick() } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val isPerson = searchPreview.mediaType == "person" + AsyncImage( + modifier = if (isPerson) { + Modifier + .size(50.dp) + .clip(CircleShape) + } else { + Modifier + .height(80.dp) + .width(50.dp) + .clip(RoundedCornerShape(corner = CornerSize(4.dp))) + }, + contentScale = ContentScale.Crop, + model = searchPreview.posterUrl, + contentDescription = "${searchPreview.title} Poster Image", + error = painterResource( + if (isPerson) R.drawable.default_avatar else R.drawable.poster_error_placeholder + ) + ) + + Spacer(modifier = Modifier.padding(start = 16.dp)) + + Column(horizontalAlignment = Alignment.Start) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyLarge.copy( + letterSpacing = 0.0.sp, + fontWeight = FontWeight.Medium, + lineHeight = 18.sp + ), + text = searchPreview.title + ) + + val badgeText = when (searchPreview.mediaType) { + "tv" -> "SHOW" + "person" -> "PERSON" + else -> null + } + if (badgeText != null) { + Spacer(modifier = Modifier.width(8.dp)) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(4.dp), + modifier = Modifier.padding(top = 8.dp) + ) { + Text( + text = badgeText, + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + + Text( + modifier = Modifier.padding(top = 4.dp), + textAlign = TextAlign.Start, + style = MaterialTheme.typography.bodyMedium, + text = searchPreview.readableReleaseDate + ) + } + } + HorizontalDivider( + modifier = Modifier.padding(top = 16.dp), + color = Color.LightGray + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SearchItemPreview() { + AppTheme { + SearchItem( + searchPreview = SearchPreview( + id = 1, + title = "Inception", + posterUrl = "", + readableReleaseDate = "2010" + ), + onItemClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SearchItemTvPreview() { + AppTheme { + SearchItem( + searchPreview = SearchPreview( + id = 1, + title = "Breaking Bad", + posterUrl = "", + readableReleaseDate = "2008", + mediaType = "tv" + ), + onItemClick = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/search/component/SearchList.kt b/app/src/main/java/tech/salroid/filmy/ui/search/component/SearchList.kt new file mode 100644 index 00000000..a8912193 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/search/component/SearchList.kt @@ -0,0 +1,62 @@ +package tech.salroid.filmy.ui.search.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.data.model.SearchPreview +import tech.salroid.filmy.ui.theme.AppTheme + +@Composable +fun SearchList( + modifier: Modifier = Modifier, + searchPreviews: List, + onSearchItemClick: (SearchPreview) -> Unit +) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(searchPreviews, key = { "${it.mediaType}_${it.id}" }) { item -> + SearchItem( + searchPreview = item, + onItemClick = { onSearchItemClick(item) } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SearchListPreview() { + AppTheme { + SearchList( + searchPreviews = listOf( + SearchPreview( + 1, + "Inception", + "", + "2010" + ), + SearchPreview( + 2, + "Interstellar", + "", + "2014" + ), + SearchPreview( + 3, + "The Dark Knight", + "", + "2008" + ) + ), + onSearchItemClick = {} + ) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/season/SeasonScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/season/SeasonScreen.kt new file mode 100644 index 00000000..a78fbd1c --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/season/SeasonScreen.kt @@ -0,0 +1,176 @@ +package tech.salroid.filmy.ui.season + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.tv.Episode +import tech.salroid.filmy.ui.common.components.LoadingWidget +import tech.salroid.filmy.utility.toReadableDate + +@Composable +fun SeasonScreen( + tvId: Int, + seasonNumber: Int, + showTitle: String, + viewModel: SeasonViewModel = hiltViewModel(), + onBackClick: () -> Unit +) { + val season by viewModel.season.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + + LaunchedEffect(tvId, seasonNumber) { + viewModel.loadSeason(tvId.toString(), seasonNumber) + } + + SeasonContent( + title = season?.name ?: showTitle, + episodes = season?.episodes ?: emptyList(), + isLoading = isLoading, + onBackClick = onBackClick + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SeasonContent( + title: String, + episodes: List, + isLoading: Boolean, + onBackClick: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(painterResource(R.drawable.ic_arrow_back), contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + when { + isLoading -> { + LoadingWidget(modifier = Modifier.padding(paddingValues).fillMaxSize()) + } + episodes.isEmpty() -> { + Box( + modifier = Modifier.padding(paddingValues).fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(stringResource(R.string.list_empty)) + } + } + else -> { + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(episodes) { episode -> + EpisodeItem(episode) + } + } + } + } + } +} + +@Composable +private fun EpisodeItem(episode: Episode) { + val runtimeText = episode.runtime?.takeIf { it > 0 }?.let { "${it}m" } + val airDateText = episode.airDate?.toReadableDate() + val metaText = listOfNotNull(runtimeText, airDateText).joinToString(" • ") + + Card( + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = Color.Transparent) + ) { + Column { + AsyncImage( + model = stringResource(R.string.movie_poster_url, episode.stillPath ?: ""), + contentDescription = episode.name, + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentScale = ContentScale.Crop + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + Text( + text = "${episode.episodeNumber ?: 0}. ${episode.name ?: ""}", + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.weight(1f) + ) + } + + if (metaText.isNotEmpty()) { + Text( + text = metaText, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier + .padding(top = 2.dp) + .alpha(0.6f) + ) + } + + if (!episode.overview.isNullOrEmpty()) { + Text( + text = episode.overview ?: "", + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 4.dp) + .alpha(0.8f) + ) + } + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/season/SeasonViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/season/SeasonViewModel.kt new file mode 100644 index 00000000..a5081cbe --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/season/SeasonViewModel.kt @@ -0,0 +1,38 @@ +package tech.salroid.filmy.ui.season + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import tech.salroid.filmy.data.local.model.tv.SeasonDetailsResponse +import tech.salroid.filmy.ui.home.MoviesRepository +import javax.inject.Inject + +@HiltViewModel +class SeasonViewModel @Inject constructor( + private val moviesRepository: MoviesRepository +) : ViewModel() { + + private val _season = MutableStateFlow(null) + val season: StateFlow = _season.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + fun loadSeason(tvId: String, seasonNumber: Int) { + viewModelScope.launch { + _isLoading.emit(true) + moviesRepository.getSeasonDetails(tvId, seasonNumber) + .flowOn(Dispatchers.IO) + .catch { } + .collect { _season.emit(it) } + _isLoading.emit(false) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/settings/AboutActivity.kt b/app/src/main/java/tech/salroid/filmy/ui/settings/AboutActivity.kt index e0f0b462..790c4d20 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/settings/AboutActivity.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/settings/AboutActivity.kt @@ -1,106 +1,23 @@ package tech.salroid.filmy.ui.settings -import android.content.Intent import android.os.Bundle -import android.view.View +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import androidx.browser.customtabs.CustomTabColorSchemeParams -import androidx.browser.customtabs.CustomTabsIntent -import androidx.core.content.ContextCompat -import com.bumptech.glide.Glide -import tech.salroid.filmy.R -import tech.salroid.filmy.databinding.ActivityAboutBinding +import tech.salroid.filmy.ui.theme.AppTheme import tech.salroid.filmy.utility.themeSystemBars -import androidx.core.net.toUri class AboutActivity : AppCompatActivity() { - private lateinit var binding: ActivityAboutBinding - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) themeSystemBars(lightStatusBar = true) - binding = ActivityAboutBinding.inflate(layoutInflater) - setContentView(binding.root) - - binding.toolbar.setNavigationOnClickListener { - onBackPressedDispatcher.onBackPressed() - } - - setBannerAndProfile() - } - - private fun setBannerAndProfile() { - Glide.with(this) - .load(getString(R.string.banner_webianks)) - .into(binding.bannerWebianks) - - Glide.with(this) - .load(getString(R.string.banner_salroid)) - .into(binding.bannerSalroid) - - Glide.with(this) - .load(getString(R.string.profile_webianks)) - .into(binding.profileWebianks) - - Glide.with(this) - .load(getString(R.string.profile_salroid)) - .into(binding.profileSalroid) - } - - fun sendEmail(view: View) { - when (view.id) { - R.id.email_webianks -> { - val emailIntent = Intent(Intent.ACTION_SENDTO) - emailIntent.data = "mailto: webianks@gmail.com".toUri() - startActivity(Intent.createChooser(emailIntent, "Send feedback")) - } - - R.id.email_salroid -> { - val emailIntent = Intent(Intent.ACTION_SENDTO) - emailIntent.data = "mailto: gupta.sajal631@gmail.com".toUri() - startActivity(Intent.createChooser(emailIntent, "Send feedback")) + setContent { + AppTheme { + AboutScreen(onBackClick = { + onBackPressedDispatcher.onBackPressed() + }) } } } - - fun redirectGithub(view: View) { - when (view.id) { - R.id.github_webianks -> openCustomTabIntent( - getString(R.string.git_webianks), - R.color.black - ) - - R.id.github_salroid -> openCustomTabIntent( - getString(R.string.git_salroid), - R.color.black - ) - } - } - - fun redirectWebsite(view: View) { - when (view.id) { - R.id.website_webianks -> openCustomTabIntent( - getString(R.string.website_webianks), - R.color.colorMore - ) - - R.id.website_salroid -> openCustomTabIntent( - getString(R.string.website_salroid), - R.color.colorMore - ) - } - } - - private fun openCustomTabIntent(url: String, color: Int) { - val intent = CustomTabsIntent.Builder().run { - val customTabColorSchemeParams = CustomTabColorSchemeParams.Builder() - .setToolbarColor(ContextCompat.getColor(this@AboutActivity, color)) - .build() - setDefaultColorSchemeParams(customTabColorSchemeParams) - build() - } - intent.launchUrl(this, url.toUri()) - } } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/settings/AboutScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/settings/AboutScreen.kt new file mode 100644 index 00000000..67262f0a --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/settings/AboutScreen.kt @@ -0,0 +1,197 @@ +package tech.salroid.filmy.ui.settings + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import coil3.compose.AsyncImage +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.theme.AppTheme +import androidx.core.net.toUri + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen(onBackClick: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text(text = stringResource(R.string.about_screen_tittle)) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + DeveloperCard( + name = stringResource(R.string.name_webianks), + bio = stringResource(R.string.bio_webianks), + bannerUrl = stringResource(R.string.banner_webianks), + profileUrl = stringResource(R.string.profile_webianks), + email = "webianks@gmail.com", + githubUrl = stringResource(R.string.git_webianks), + websiteUrl = stringResource(R.string.website_webianks) + ) + + DeveloperCard( + name = stringResource(R.string.name_salroid), + bio = stringResource(R.string.bio_salroid), + bannerUrl = stringResource(R.string.banner_salroid), + profileUrl = stringResource(R.string.profile_salroid), + email = "gupta.sajal631@gmail.com", + githubUrl = stringResource(R.string.git_salroid), + websiteUrl = stringResource(R.string.website_salroid) + ) + } + } +} + +@Composable +fun DeveloperCard( + name: String, + bio: String, + bannerUrl: String, + profileUrl: String, + email: String, + githubUrl: String, + websiteUrl: String +) { + val context = LocalContext.current + + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium + ) { + Column { + Box(modifier = Modifier.height(175.dp)) { + AsyncImage( + model = bannerUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.default_banner_min) + ) + + AsyncImage( + model = profileUrl, + contentDescription = null, + modifier = Modifier + .padding(start = 16.dp, top = 55.dp) + .size(120.dp) + .border(4.dp, MaterialTheme.colorScheme.surface, CircleShape) + .padding(4.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.default_avatar) + ) + } + + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge + .copy( + fontWeight = FontWeight.Bold, + fontSize = 20.sp + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = bio, + style = MaterialTheme.typography.bodyMedium + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton( + onClick = { + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:$email") + } + context.startActivity(Intent.createChooser(emailIntent, "Send feedback")) + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.email), fontWeight = FontWeight.Bold) + } + + TextButton( + onClick = { + openCustomTabIntent(context, githubUrl, android.R.color.black) + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.github), fontWeight = FontWeight.Bold) + } + + TextButton( + onClick = { + openCustomTabIntent(context, websiteUrl, R.color.colorMore) + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.website), fontWeight = FontWeight.Bold) + } + } + } + } +} + +private fun openCustomTabIntent(context: Context, url: String, colorRes: Int) { + val intent = CustomTabsIntent.Builder().run { + val customTabColorSchemeParams = CustomTabColorSchemeParams.Builder() + .setToolbarColor(ContextCompat.getColor(context, colorRes)) + .build() + setDefaultColorSchemeParams(customTabColorSchemeParams) + build() + } + intent.launchUrl(context, url.toUri()) +} + +@Preview(showBackground = true) +@Composable +fun AboutScreenPreview() { + AppTheme { + AboutScreen(onBackClick = {}) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/settings/LicenseActivity.kt b/app/src/main/java/tech/salroid/filmy/ui/settings/LicenseActivity.kt index e11be519..d3790ab0 100644 --- a/app/src/main/java/tech/salroid/filmy/ui/settings/LicenseActivity.kt +++ b/app/src/main/java/tech/salroid/filmy/ui/settings/LicenseActivity.kt @@ -1,49 +1,23 @@ package tech.salroid.filmy.ui.settings import android.os.Bundle -import android.text.Html +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import tech.salroid.filmy.R -import tech.salroid.filmy.databinding.ActivityLicenseBinding +import tech.salroid.filmy.ui.theme.AppTheme import tech.salroid.filmy.utility.themeSystemBars class LicenseActivity : AppCompatActivity() { - private lateinit var binding: ActivityLicenseBinding - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) themeSystemBars(lightStatusBar = true) - binding = ActivityLicenseBinding.inflate(layoutInflater) - setContentView(binding.root) - - binding.toolbar.setNavigationOnClickListener { - onBackPressedDispatcher.onBackPressed() + setContent { + AppTheme { + LicenseScreen(onBackClick = { + onBackPressedDispatcher.onBackPressed() + }) + } } - - setup() - } - - private fun setup() { - binding.glide.text = Html.fromHtml( - getString(R.string.glide), - Html.FROM_HTML_MODE_LEGACY - ) - binding.materialsearcview.text = - Html.fromHtml( - getString(R.string.materialsearch), - Html.FROM_HTML_MODE_LEGACY - ) - binding.appintro.text = - Html.fromHtml( - getString(R.string.appintro), - Html.FROM_HTML_MODE_LEGACY - ) - binding.crashlytics.text = - Html.fromHtml( - getString(R.string.crashlytics), - Html.FROM_HTML_MODE_LEGACY - ) } } \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/settings/LicenseScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/settings/LicenseScreen.kt new file mode 100644 index 00000000..8699164d --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/settings/LicenseScreen.kt @@ -0,0 +1,99 @@ +package tech.salroid.filmy.ui.settings + +import android.os.Build +import android.text.Html +import android.widget.TextView +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import tech.salroid.filmy.R +import tech.salroid.filmy.ui.theme.AppTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LicenseScreen(onBackClick: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text(text = stringResource(R.string.license)) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.newtmdb), + contentDescription = "Powered by TMDB", + modifier = Modifier + .height(88.dp) + .padding(16.dp) + ) + + val textColor = MaterialTheme.colorScheme.onSurface + + HtmlText(stringResource(R.string.materialsearch), textColor = textColor) + HtmlText(stringResource(R.string.crashlytics), textColor = textColor) + HtmlText(stringResource(R.string.glide), textColor = textColor) + } + } +} + +@Composable +fun HtmlText( + html: String, + modifier: Modifier = Modifier, + textColor: androidx.compose.ui.graphics.Color +) { + val argbColor = textColor.toArgb() + AndroidView( + modifier = modifier.padding(vertical = 8.dp), + factory = { context -> + TextView(context).apply { + setTextColor(argbColor) + } + }, + update = { textView -> + textView.setTextColor(argbColor) + textView.text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY) + } else { + Html.fromHtml(html) + } + } + ) +} + +@Preview(showBackground = true) +@Composable +fun LicenseScreenPreview() { + AppTheme { + LicenseScreen(onBackClick = {}) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsRoute.kt b/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsRoute.kt new file mode 100644 index 00000000..7a08b4be --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsRoute.kt @@ -0,0 +1,54 @@ +package tech.salroid.filmy.ui.shows + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems +import tech.salroid.filmy.data.model.SearchPreview +import tech.salroid.filmy.ui.search.SearchScreenState + +@Composable +fun ShowsRoute( + modifier: Modifier = Modifier, + viewModel: ShowsViewModel = hiltViewModel(), + textFieldState: TextFieldState, + searchUiState: SearchScreenState, + isSearchExpanded: Boolean, + onSearchExpandedChange: (Boolean) -> Unit, + onSearch: (String) -> Unit, + onShowClick: (Int) -> Unit, + onSearchResultClick: (SearchPreview) -> Unit, + recentSearches: List = emptyList(), + onRecentSearchClick: (String) -> Unit = {}, + onRemoveRecentSearch: (String) -> Unit = {}, + onClearRecentSearches: () -> Unit = {}, + onFilterClick: () -> Unit = {} +) { + val shows = viewModel.showsPagingData.collectAsLazyPagingItems() + val selectedCategory by viewModel.selectedCategory.collectAsStateWithLifecycle() + + ShowsScreen( + modifier = modifier, + shows = shows, + selectedCategory = selectedCategory, + onCategorySelected = viewModel::onCategorySelected, + textFieldState = textFieldState, + searchUiState = searchUiState, + isSearchExpanded = isSearchExpanded, + onSearchExpandedChange = onSearchExpandedChange, + onSearch = onSearch, + onShowClick = onShowClick, + onSearchResultClick = onSearchResultClick, + recentSearches = recentSearches, + onRecentSearchClick = onRecentSearchClick, + onRemoveRecentSearch = onRemoveRecentSearch, + onClearRecentSearches = onClearRecentSearches, + onFilterClick = onFilterClick, + fetchQuickActionState = viewModel::getQuickActionState, + onQuickToggleWatchlist = viewModel::quickToggleWatchlist, + onQuickToggleWatched = viewModel::quickToggleWatched + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsScreen.kt new file mode 100644 index 00000000..b56e4c7d --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsScreen.kt @@ -0,0 +1,121 @@ +package tech.salroid.filmy.ui.shows + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.model.TvShow +import tech.salroid.filmy.data.model.TvShowPreview +import tech.salroid.filmy.data.model.SearchPreview +import tech.salroid.filmy.ui.common.components.CategorySelector +import tech.salroid.filmy.ui.common.components.ErrorWidget +import tech.salroid.filmy.ui.common.components.HomeTopBar +import tech.salroid.filmy.ui.common.components.LoadingWidget +import tech.salroid.filmy.ui.common.components.QuickActionState +import tech.salroid.filmy.ui.shows.components.ShowsList +import tech.salroid.filmy.ui.search.SearchScreenState +import tech.salroid.filmy.utility.toUserMessage + +private fun labelFor(category: TvShow.ShowType): Int = when (category) { + TvShow.ShowType.TRENDING -> R.string.tv_label_trending + TvShow.ShowType.POPULAR -> R.string.tv_label_popular + TvShow.ShowType.AIRING_TODAY -> R.string.tv_label_arriving_today + TvShow.ShowType.ON_TV -> R.string.tv_label_on_tv + TvShow.ShowType.TOP_RATED -> R.string.tv_label_top_rated +} + +@Composable +fun ShowsScreen( + modifier: Modifier = Modifier, + shows: LazyPagingItems, + selectedCategory: TvShow.ShowType, + onCategorySelected: (TvShow.ShowType) -> Unit, + textFieldState: TextFieldState, + searchUiState: SearchScreenState, + isSearchExpanded: Boolean, + onSearchExpandedChange: (Boolean) -> Unit, + onSearch: (String) -> Unit, + onShowClick: (Int) -> Unit, + onSearchResultClick: (SearchPreview) -> Unit, + recentSearches: List = emptyList(), + onRecentSearchClick: (String) -> Unit = {}, + onRemoveRecentSearch: (String) -> Unit = {}, + onClearRecentSearches: () -> Unit = {}, + onFilterClick: () -> Unit = {}, + fetchQuickActionState: suspend (TvShowPreview) -> QuickActionState = { QuickActionState(false, false) }, + onQuickToggleWatchlist: (TvShowPreview) -> Unit = {}, + onQuickToggleWatched: (TvShowPreview) -> Unit = {} +) { + HomeTopBar( + modifier = modifier, + textFieldState = textFieldState, + searchUiState = searchUiState, + isSearchExpanded = isSearchExpanded, + onSearchExpandedChange = onSearchExpandedChange, + onSearch = onSearch, + onSearchResultClick = onSearchResultClick, + recentSearches = recentSearches, + onRecentSearchClick = onRecentSearchClick, + onRemoveRecentSearch = onRemoveRecentSearch, + onClearRecentSearches = onClearRecentSearches, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CategorySelector( + categories = TvShow.ShowType.entries, + selected = selectedCategory, + label = { stringResource(labelFor(it)) }, + onSelected = onCategorySelected + ) + IconButton(onClick = onFilterClick) { + Icon(Icons.Default.Tune, contentDescription = stringResource(R.string.discover_filters)) + } + } + }, + content = { + when (val state = shows.loadState.refresh) { + is LoadState.Loading -> { + LoadingWidget(modifier = Modifier.weight(1f)) + } + is LoadState.Error -> { + ErrorWidget( + modifier = Modifier.weight(1f), + message = state.error.toUserMessage(), + onRetryClick = { shows.retry() } + ) + } + else -> { + if (shows.itemCount == 0 && shows.loadState.append is LoadState.NotLoading && shows.loadState.append.endOfPaginationReached) { + ErrorWidget( + modifier = Modifier.weight(1f), + message = "No shows found", + onRetryClick = { shows.refresh() } + ) + } else { + ShowsList( + modifier = Modifier.weight(1f), + shows = shows, + onShowClick = onShowClick, + fetchQuickActionState = fetchQuickActionState, + onQuickToggleWatchlist = onQuickToggleWatchlist, + onQuickToggleWatched = onQuickToggleWatched + ) + } + } + } + } + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsScreenState.kt b/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsScreenState.kt new file mode 100644 index 00000000..d6def426 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsScreenState.kt @@ -0,0 +1,9 @@ +package tech.salroid.filmy.ui.shows + +import tech.salroid.filmy.data.model.TvShowPreview + +sealed class ShowsScreenState { + object Loading : ShowsScreenState() + data class Success(val shows: List) : ShowsScreenState() + data class Error(val errorMessage: String) : ShowsScreenState() +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsViewModel.kt new file mode 100644 index 00000000..39b5480b --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/shows/ShowsViewModel.kt @@ -0,0 +1,100 @@ +package tech.salroid.filmy.ui.shows + +import android.content.SharedPreferences +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import tech.salroid.filmy.data.local.db.entity.MovieDetails +import tech.salroid.filmy.data.local.model.TvShow +import tech.salroid.filmy.data.model.TvShowPreview +import tech.salroid.filmy.ui.common.components.QuickActionState +import tech.salroid.filmy.ui.home.MoviesRepository +import tech.salroid.filmy.utility.ImageConfig +import tech.salroid.filmy.utility.PreferenceHelper.selectedCountryFlow +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class ShowsViewModel @Inject constructor( + private val moviesRepository: MoviesRepository, + private val mapper: TvShowsPreviewMapper, + sharedPreferences: SharedPreferences +) : ViewModel() { + + private val _selectedCategory = MutableStateFlow(TvShow.ShowType.TRENDING) + val selectedCategory: StateFlow = _selectedCategory + + val showsPagingData: Flow> = combine( + _selectedCategory, + sharedPreferences.selectedCountryFlow() + ) { category, _ -> category } + .flatMapLatest { category -> + moviesRepository.getTvShows( + type = category.toApiTypeString(), + isTrending = category == TvShow.ShowType.TRENDING + ) + } + .map { pagingData -> pagingData.map(mapper::map) } + .cachedIn(viewModelScope) + + fun onCategorySelected(category: TvShow.ShowType) { + _selectedCategory.value = category + } + + /** + * Quick actions from the long-press menu on a poster - upserts against + * whatever local record already exists (preserving its other fields) + * rather than blindly overwriting, so this is safe even if the title was + * already saved with full details from a previous visit. + */ + suspend fun getQuickActionState(show: TvShowPreview): QuickActionState = withContext(Dispatchers.IO) { + val existing = moviesRepository.getMovieDetailsFromLocal(show.id, 1) + QuickActionState( + isWatchlisted = existing?.watchlist ?: false, + isWatched = existing?.watched ?: false + ) + } + + fun quickToggleWatchlist(show: TvShowPreview) { + viewModelScope.launch(Dispatchers.IO) { + val merged = mergedLocalDetails(show) + moviesRepository.addMovieDetailsToLocal(merged.copy(watchlist = !merged.watchlist)) + } + } + + fun quickToggleWatched(show: TvShowPreview) { + viewModelScope.launch(Dispatchers.IO) { + val merged = mergedLocalDetails(show) + moviesRepository.addMovieDetailsToLocal(merged.copy(watched = !merged.watched)) + } + } + + private fun mergedLocalDetails(show: TvShowPreview): MovieDetails { + val existing = moviesRepository.getMovieDetailsFromLocal(show.id, 1) + return (existing ?: MovieDetails(id = show.id, type = 1)).copy( + title = show.title, + posterPath = show.posterUrl.removePrefix(ImageConfig.BASE_URL) + ) + } + + private fun TvShow.ShowType.toApiTypeString(): String = when (this) { + TvShow.ShowType.TRENDING -> "day" + TvShow.ShowType.POPULAR -> "popular" + TvShow.ShowType.AIRING_TODAY -> "airing_today" + TvShow.ShowType.ON_TV -> "on_the_air" + TvShow.ShowType.TOP_RATED -> "top_rated" + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/shows/TvShowsPreviewMapper.kt b/app/src/main/java/tech/salroid/filmy/ui/shows/TvShowsPreviewMapper.kt new file mode 100644 index 00000000..98bfa383 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/shows/TvShowsPreviewMapper.kt @@ -0,0 +1,21 @@ +package tech.salroid.filmy.ui.shows + +import tech.salroid.filmy.data.local.model.TvShow +import tech.salroid.filmy.data.model.TvShowPreview +import tech.salroid.filmy.utility.ImageConfig +import tech.salroid.filmy.utility.formateReleaseDate +import javax.inject.Inject + +class TvShowsPreviewMapper @Inject constructor() { + private val posterBaseUrl = ImageConfig.BASE_URL + + fun map(tvShow: TvShow): TvShowPreview = + TvShowPreview( + id = tvShow.id, + title = tvShow.name.orEmpty(), + posterUrl = posterBaseUrl + tvShow.posterPath.orEmpty(), + firstAirReadableDate = tvShow.firstAirDate + ?.let(::formateReleaseDate) + .orEmpty() + ) +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/ui/shows/components/ShowItem.kt b/app/src/main/java/tech/salroid/filmy/ui/shows/components/ShowItem.kt new file mode 100644 index 00000000..ed4b1004 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/shows/components/ShowItem.kt @@ -0,0 +1,49 @@ +package tech.salroid.filmy.ui.shows.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import tech.salroid.filmy.data.model.TvShowPreview +import tech.salroid.filmy.ui.common.components.PreviewItem +import tech.salroid.filmy.ui.common.components.QuickActionState +import tech.salroid.filmy.ui.movies.dummyShowPreview + +@Composable +fun ShowItem( + modifier: Modifier = Modifier, + show: TvShowPreview, + onShowClick: () -> Unit, + fetchQuickActionState: (suspend () -> QuickActionState)? = null, + onToggleWatchlist: (() -> Unit)? = null, + onToggleWatched: (() -> Unit)? = null +) { + PreviewItem( + modifier = modifier, + title = show.title, + posterUrl = show.posterUrl, + readableDate = show.firstAirReadableDate, + contentDescription = "${show.title} - Show Item", + onItemClick = onShowClick, + fetchQuickActionState = fetchQuickActionState, + onToggleWatchlist = onToggleWatchlist, + onToggleWatched = onToggleWatched + ) +} + +@Preview( + name = "Show Item - Preview", + showBackground = true +) +@Composable +fun MovieItemPreview() { + ShowItem( + modifier = Modifier + .width(140.dp) + .padding(16.dp), + show = dummyShowPreview, + onShowClick = { } + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/shows/components/ShowList.kt b/app/src/main/java/tech/salroid/filmy/ui/shows/components/ShowList.kt new file mode 100644 index 00000000..3d02beda --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/shows/components/ShowList.kt @@ -0,0 +1,34 @@ +package tech.salroid.filmy.ui.shows.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.paging.compose.LazyPagingItems +import tech.salroid.filmy.data.model.TvShowPreview +import tech.salroid.filmy.ui.common.components.PaginatedPreviewList +import tech.salroid.filmy.ui.common.components.QuickActionState + +@Composable +fun ShowsList( + modifier: Modifier = Modifier, + shows: LazyPagingItems, + onShowClick: (Int) -> Unit, + fetchQuickActionState: suspend (TvShowPreview) -> QuickActionState = { QuickActionState(false, false) }, + onQuickToggleWatchlist: (TvShowPreview) -> Unit = {}, + onQuickToggleWatched: (TvShowPreview) -> Unit = {} +) { + PaginatedPreviewList( + modifier = modifier, + items = shows, + content = { show -> + ShowItem( + show = show, + onShowClick = { + onShowClick(show.id) + }, + fetchQuickActionState = { fetchQuickActionState(show) }, + onToggleWatchlist = { onQuickToggleWatchlist(show) }, + onToggleWatched = { onQuickToggleWatched(show) } + ) + } + ) +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/shows/details/ShowDetailScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/shows/details/ShowDetailScreen.kt new file mode 100644 index 00000000..ecde7aef --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/shows/details/ShowDetailScreen.kt @@ -0,0 +1,114 @@ +package tech.salroid.filmy.ui.shows.details + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import tech.salroid.filmy.ui.common.components.DetailsContent +import tech.salroid.filmy.ui.common.components.DetailsSkeletonLoader +import tech.salroid.filmy.ui.common.components.ErrorWidget +import tech.salroid.filmy.ui.details.MovieDetailsViewModel +import tech.salroid.filmy.ui.common.model.DetailsActions +import tech.salroid.filmy.ui.movies.details.components.RateMediaSheet +import tech.salroid.filmy.utility.openUrl +import tech.salroid.filmy.utility.openYoutubeTrailer +import tech.salroid.filmy.utility.shareMedia + +@Composable +fun ShowDetailsScreen( + showId: Int, + modifier: Modifier = Modifier, + viewModel: MovieDetailsViewModel = hiltViewModel(), + onViewAllCastClick: (Int, Boolean, String) -> Unit, + onViewAllReviewsClick: (Int, Boolean, String) -> Unit, + onMemberClick: (Int, Boolean) -> Unit, + onShowClick: (Int) -> Unit, + onBackNavigation: () -> Unit, + onSeasonClick: (Int, Int, String) -> Unit = { _, _, _ -> }, + onGalleryClick: (Int, Boolean) -> Unit = { _, _ -> }, + onKeywordClick: (Int, String) -> Unit = { _, _ -> } +) { + val state by viewModel.mediaDetailsUiState.collectAsStateWithLifecycle() + val showDetails by viewModel.uiStateTvDetails.collectAsStateWithLifecycle() + val movieDetails by viewModel.uiStateMovieDetails.collectAsStateWithLifecycle() + val isError by viewModel.uiStateError.collectAsStateWithLifecycle() + val context = LocalContext.current + + var showRateSheet by remember { mutableStateOf(false) } + + LaunchedEffect(showId) { + viewModel.fetchAllTvDetails(showId.toString(), 1) + } + + Crossfade( + targetState = state != null && showDetails != null, + animationSpec = tween(durationMillis = 600), + label = "details_fade" + ) { isLoaded -> + if (isLoaded) { + val currentState = state + val currentShowDetails = showDetails + val currentMovieDetails = movieDetails + + if (currentState != null && currentShowDetails != null) { + val actions = DetailsActions( + onWatchedToggle = { viewModel.toggleWatchedTv(currentShowDetails, currentMovieDetails) }, + onWatchlistToggle = { viewModel.toggleWatchlistTv(currentShowDetails, currentMovieDetails) }, + onViewAllCastClick = onViewAllCastClick, + onViewAllReviewsClick = onViewAllReviewsClick, + onMemberClick = onMemberClick, + onMediaClick = onShowClick, + onTrailerClick = { context.openYoutubeTrailer(it) }, + onShareClick = { + context.shareMedia( + title = currentState.title, + tagline = currentState.tagline, + imdbId = currentState.imdbId, + isTvShow = currentState.isTvShow + ) + }, + onBackNavigation = onBackNavigation, + onSeasonClick = onSeasonClick, + onRateClick = { showRateSheet = true }, + onGalleryClick = onGalleryClick, + onKeywordClick = onKeywordClick, + onLinkClick = { context.openUrl(it) } + ) + + DetailsContent( + state = currentState, + actions = actions, + modifier = modifier + ) + + if (showRateSheet) { + RateMediaSheet( + currentRating = currentState.userRating, + onSubmit = { rating -> + viewModel.rateTvShow(currentShowDetails, currentMovieDetails, rating) + showRateSheet = false + }, + onRemove = { + viewModel.rateTvShow(currentShowDetails, currentMovieDetails, null) + showRateSheet = false + }, + onDismiss = { showRateSheet = false } + ) + } + } + } else if (isError) { + ErrorWidget( + modifier = modifier, + message = "Couldn't load details. Check your connection.", + onRetryClick = { viewModel.fetchAllTvDetails(showId.toString(), 1) }, + onBackClick = onBackNavigation + ) + } else { + DetailsSkeletonLoader(modifier = modifier) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/textvalidation/TextProcessingScreen.kt b/app/src/main/java/tech/salroid/filmy/ui/textvalidation/TextProcessingScreen.kt new file mode 100644 index 00000000..07a0bdba --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/textvalidation/TextProcessingScreen.kt @@ -0,0 +1,62 @@ +/* +package tech.salroid.filmy.ui.textvalidation + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + + +// 1. UI Component -> Compose +// 2. Sending Text Stream -> VM +// 3. Processing Text Stream -> VM +// 4. UI State -> VM +// 4. Subscribe UI State -> UI Component +@Composable +fun TextProcessingScreen( + modifier: Modifier = Modifier, + viewModel: TextProcessingViewModel = viewModel(), +) { + + val textFieldState = remember { TextFieldState() } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val text = when (uiState) { + UiState.NotPalindrome -> "Not Palindrome" + UiState.Palindrome -> "Palindrome" + } + + LaunchedEffect(textFieldState) { + snapshotFlow { textFieldState.text.toString() } + .collect { + viewModel.onTextInput(it) + } + } + + Column(modifier = modifier) { + TextField( + state = textFieldState, + placeholder = { Text("Enter the text") }, + ) + + Text(text = text) + } +} + +@Preview +@Composable +fun TextScreenPreview() { + TextProcessingScreen( + modifier = Modifier.fillMaxSize() + ) +}*/ diff --git a/app/src/main/java/tech/salroid/filmy/ui/textvalidation/TextProcessingViewModel.kt b/app/src/main/java/tech/salroid/filmy/ui/textvalidation/TextProcessingViewModel.kt new file mode 100644 index 00000000..613f9570 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/textvalidation/TextProcessingViewModel.kt @@ -0,0 +1,66 @@ +/* +package tech.salroid.filmy.ui.textvalidation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class TextProcessingViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(UiState.NotPalindrome) + var uiState: StateFlow = _uiState.asStateFlow() + private var textProcessingJob: Job? = null + + fun onTextInput(text: String) { + textProcessingJob?.cancel() + textProcessingJob = viewModelScope.launch { + val result = checkPalindrome(text) + if (result) { + _uiState.value = UiState.Palindrome + } else { + _uiState.value = UiState.NotPalindrome + } + } + } + + private fun checkPalindrome(text: String): Boolean { + var start = 0 + var end = text.length - 1 + + while (start < end) { + if (text[start] !in 'A'..'Z' && + text[start] !in 'a'..'z' && + text[start] !in '0'..'9' + ) { + start++ + continue + } + + if (text[end] !in 'A'..'Z' && + text[end] !in 'a'..'z' && + text[end] !in '0'..'9' + ) { + end-- + continue + } + + if (text[start] == text[end]) { + start++ + end-- + } else { + break + } + } + return start >= end + } +} + + +sealed class UiState { + object Palindrome : UiState() + object NotPalindrome : UiState() +}*/ diff --git a/app/src/main/java/tech/salroid/filmy/ui/theme/Color.kt b/app/src/main/java/tech/salroid/filmy/ui/theme/Color.kt new file mode 100644 index 00000000..7e169623 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/theme/Color.kt @@ -0,0 +1,226 @@ +package tech.salroid.filmy.ui.theme + +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF276389) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFFCAE6FF) +val onPrimaryContainerLight = Color(0xFF004B70) +val secondaryLight = Color(0xFF50606E) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFD3E5F5) +val onSecondaryContainerLight = Color(0xFF384956) +val tertiaryLight = Color(0xFF65597B) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFEBDDFF) +val onTertiaryContainerLight = Color(0xFF4C4162) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF93000A) +val backgroundLight = Color(0xFFF7F9FF) +val onBackgroundLight = Color(0xFF181C20) +val surfaceLight = Color(0xFFF7F9FF) +val onSurfaceLight = Color(0xFF181C20) +val surfaceVariantLight = Color(0xFFDDE3EA) +val onSurfaceVariantLight = Color(0xFF41474D) +val outlineLight = Color(0xFF72787E) +val outlineVariantLight = Color(0xFFC1C7CE) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF2D3135) +val inverseOnSurfaceLight = Color(0xFFEEF1F6) +val inversePrimaryLight = Color(0xFF96CDF8) +val surfaceDimLight = Color(0xFFD7DADF) +val surfaceBrightLight = Color(0xFFF7F9FF) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFF1F4F9) +val surfaceContainerLight = Color(0xFFEBEEF3) +val surfaceContainerHighLight = Color(0xFFE5E8ED) +val surfaceContainerHighestLight = Color(0xFFE0E3E8) + +val primaryLightMediumContrast = Color(0xFF003A57) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF397299) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF283845) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF5E6F7D) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF3B3151) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF74678B) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF740006) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFCF2C27) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFF7F9FF) +val onBackgroundLightMediumContrast = Color(0xFF181C20) +val surfaceLightMediumContrast = Color(0xFFF7F9FF) +val onSurfaceLightMediumContrast = Color(0xFF0E1215) +val surfaceVariantLightMediumContrast = Color(0xFFDDE3EA) +val onSurfaceVariantLightMediumContrast = Color(0xFF31373D) +val outlineLightMediumContrast = Color(0xFF4D5359) +val outlineVariantLightMediumContrast = Color(0xFF686E74) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF2D3135) +val inverseOnSurfaceLightMediumContrast = Color(0xFFEEF1F6) +val inversePrimaryLightMediumContrast = Color(0xFF96CDF8) +val surfaceDimLightMediumContrast = Color(0xFFC3C7CC) +val surfaceBrightLightMediumContrast = Color(0xFFF7F9FF) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFF1F4F9) +val surfaceContainerLightMediumContrast = Color(0xFFE5E8ED) +val surfaceContainerHighLightMediumContrast = Color(0xFFDADDE2) +val surfaceContainerHighestLightMediumContrast = Color(0xFFCFD2D7) + +val primaryLightHighContrast = Color(0xFF002F48) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF024E73) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF1D2E3A) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF3B4B59) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF312746) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF4F4365) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF600004) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF98000A) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFF7F9FF) +val onBackgroundLightHighContrast = Color(0xFF181C20) +val surfaceLightHighContrast = Color(0xFFF7F9FF) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFDDE3EA) +val onSurfaceVariantLightHighContrast = Color(0xFF000000) +val outlineLightHighContrast = Color(0xFF272D32) +val outlineVariantLightHighContrast = Color(0xFF444A50) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF2D3135) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFF96CDF8) +val surfaceDimLightHighContrast = Color(0xFFB6B9BE) +val surfaceBrightLightHighContrast = Color(0xFFF7F9FF) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFEEF1F6) +val surfaceContainerLightHighContrast = Color(0xFFE0E3E8) +val surfaceContainerHighLightHighContrast = Color(0xFFD1D5DA) +val surfaceContainerHighestLightHighContrast = Color(0xFFC3C7CC) + +val primaryDark = Color(0xFF96CDF8) +val onPrimaryDark = Color(0xFF00344E) +val primaryContainerDark = Color(0xFF004B70) +val onPrimaryContainerDark = Color(0xFFCAE6FF) +val secondaryDark = Color(0xFFB7C9D9) +val onSecondaryDark = Color(0xFF22323F) +val secondaryContainerDark = Color(0xFF384956) +val onSecondaryContainerDark = Color(0xFFD3E5F5) +val tertiaryDark = Color(0xFFCFC0E8) +val onTertiaryDark = Color(0xFF362B4B) +val tertiaryContainerDark = Color(0xFF4C4162) +val onTertiaryContainerDark = Color(0xFFEBDDFF) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF101417) +val onBackgroundDark = Color(0xFFE0E3E8) +val surfaceDark = Color(0xFF101417) +val onSurfaceDark = Color(0xFFE0E3E8) +val surfaceVariantDark = Color(0xFF41474D) +val onSurfaceVariantDark = Color(0xFFC1C7CE) +val outlineDark = Color(0xFF8B9198) +val outlineVariantDark = Color(0xFF41474D) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFE0E3E8) +val inverseOnSurfaceDark = Color(0xFF2D3135) +val inversePrimaryDark = Color(0xFF276389) +val surfaceDimDark = Color(0xFF101417) +val surfaceBrightDark = Color(0xFF363A3E) +val surfaceContainerLowestDark = Color(0xFF0B0F12) +val surfaceContainerLowDark = Color(0xFF181C20) +val surfaceContainerDark = Color(0xFF1C2024) +val surfaceContainerHighDark = Color(0xFF262A2E) +val surfaceContainerHighestDark = Color(0xFF313539) + +val primaryDarkMediumContrast = Color(0xFFBDE1FF) +val onPrimaryDarkMediumContrast = Color(0xFF00283E) +val primaryContainerDarkMediumContrast = Color(0xFF5F96BF) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFCDDEEF) +val onSecondaryDarkMediumContrast = Color(0xFF172734) +val secondaryContainerDarkMediumContrast = Color(0xFF8293A2) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFFE5D5FF) +val onTertiaryDarkMediumContrast = Color(0xFF2B203F) +val tertiaryContainerDarkMediumContrast = Color(0xFF988AB0) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFD2CC) +val onErrorDarkMediumContrast = Color(0xFF540003) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF101417) +val onBackgroundDarkMediumContrast = Color(0xFFE0E3E8) +val surfaceDarkMediumContrast = Color(0xFF101417) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF41474D) +val onSurfaceVariantDarkMediumContrast = Color(0xFFD7DDE4) +val outlineDarkMediumContrast = Color(0xFFADB2B9) +val outlineVariantDarkMediumContrast = Color(0xFF8B9198) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFE0E3E8) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF262A2E) +val inversePrimaryDarkMediumContrast = Color(0xFF004D71) +val surfaceDimDarkMediumContrast = Color(0xFF101417) +val surfaceBrightDarkMediumContrast = Color(0xFF414549) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF05080B) +val surfaceContainerLowDarkMediumContrast = Color(0xFF1A1E22) +val surfaceContainerDarkMediumContrast = Color(0xFF24282C) +val surfaceContainerHighDarkMediumContrast = Color(0xFF2F3337) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF3A3E42) + +val primaryDarkHighContrast = Color(0xFFE4F2FF) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFF92C9F4) +val onPrimaryContainerDarkHighContrast = Color(0xFF000D17) +val secondaryDarkHighContrast = Color(0xFFE4F2FF) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFB3C5D5) +val onSecondaryContainerDarkHighContrast = Color(0xFF000D17) +val tertiaryDarkHighContrast = Color(0xFFF6ECFF) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFCBBCE4) +val onTertiaryContainerDarkHighContrast = Color(0xFF0F0523) +val errorDarkHighContrast = Color(0xFFFFECE9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFAEA4) +val onErrorContainerDarkHighContrast = Color(0xFF220001) +val backgroundDarkHighContrast = Color(0xFF101417) +val onBackgroundDarkHighContrast = Color(0xFFE0E3E8) +val surfaceDarkHighContrast = Color(0xFF101417) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF41474D) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFEBF0F8) +val outlineVariantDarkHighContrast = Color(0xFFBDC3CA) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFE0E3E8) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF004D71) +val surfaceDimDarkHighContrast = Color(0xFF101417) +val surfaceBrightDarkHighContrast = Color(0xFF4C5055) +val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) +val surfaceContainerLowDarkHighContrast = Color(0xFF1C2024) +val surfaceContainerDarkHighContrast = Color(0xFF2D3135) +val surfaceContainerHighDarkHighContrast = Color(0xFF383C40) +val surfaceContainerHighestDarkHighContrast = Color(0xFF43474B) + + + + + + + diff --git a/app/src/main/java/tech/salroid/filmy/ui/theme/Theme.kt b/app/src/main/java/tech/salroid/filmy/ui/theme/Theme.kt new file mode 100644 index 00000000..daf2dd93 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/theme/Theme.kt @@ -0,0 +1,278 @@ +package tech.salroid.filmy.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +@Immutable +data class ColorFamily( + val color: Color, + val onColor: Color, + val colorContainer: Color, + val onColorContainer: Color +) + +val unspecified_scheme = ColorFamily( + Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified +) + +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, + content: @Composable() () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkScheme + else -> lightScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content + ) +} + diff --git a/app/src/main/java/tech/salroid/filmy/ui/theme/Type.kt b/app/src/main/java/tech/salroid/filmy/ui/theme/Type.kt new file mode 100644 index 00000000..c37bcc8f --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/theme/Type.kt @@ -0,0 +1,78 @@ +package tech.salroid.filmy.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import tech.salroid.filmy.R + +val bodyFontFamily = FontFamily( + Font( + resId = R.font.snpro_regular, + weight = FontWeight.Normal, + style = FontStyle.Normal + ), + Font( + resId = R.font.snpro_medium, + weight = FontWeight.Medium + ), + Font( + resId = R.font.snpro_bold, + weight = FontWeight.Bold + ) +) + +val titleFontFamily = FontFamily( + Font( + resId = R.font.snpro_regular, + weight = FontWeight.Normal, + style = FontStyle.Normal + ), + Font( + resId = R.font.snpro_medium, + weight = FontWeight.Medium + ), + Font( + resId = R.font.snpro_bold, + weight = FontWeight.Bold + ) +) + +val displayFontFamily = FontFamily( + Font( + resId = R.font.borel_regular, + weight = FontWeight.Normal, + style = FontStyle.Normal + ), + Font( + resId = R.font.borel_regular, + weight = FontWeight.Medium + ), + Font( + resId = R.font.borel_regular, + weight = FontWeight.Bold + ) +) + +// Default Material 3 typography values +val baseline = Typography() + +val AppTypography = Typography( + displayLarge = baseline.displayLarge.copy(fontFamily = displayFontFamily), + displayMedium = baseline.displayMedium.copy(fontFamily = displayFontFamily), + displaySmall = baseline.displaySmall.copy(fontFamily = displayFontFamily), + headlineLarge = baseline.headlineLarge.copy(fontFamily = displayFontFamily), + headlineMedium = baseline.headlineMedium.copy(fontFamily = displayFontFamily), + headlineSmall = baseline.headlineSmall.copy(fontFamily = displayFontFamily), + titleLarge = baseline.titleLarge.copy(fontFamily = titleFontFamily), + titleMedium = baseline.titleMedium.copy(fontFamily = titleFontFamily), + titleSmall = baseline.titleSmall.copy(fontFamily = titleFontFamily), + bodyLarge = baseline.bodyLarge.copy(fontFamily = bodyFontFamily), + bodyMedium = baseline.bodyMedium.copy(fontFamily = bodyFontFamily), + bodySmall = baseline.bodySmall.copy(fontFamily = bodyFontFamily), + labelLarge = baseline.labelLarge.copy(fontFamily = bodyFontFamily), + labelMedium = baseline.labelMedium.copy(fontFamily = bodyFontFamily), + labelSmall = baseline.labelSmall.copy(fontFamily = bodyFontFamily), +) + diff --git a/app/src/main/java/tech/salroid/filmy/ui/widget/ToRateWidget.kt b/app/src/main/java/tech/salroid/filmy/ui/widget/ToRateWidget.kt new file mode 100644 index 00000000..60c344b0 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/widget/ToRateWidget.kt @@ -0,0 +1,250 @@ +package tech.salroid.filmy.ui.widget + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.material3.ColorProviders +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import coil3.asDrawable +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.db.entity.MovieDetails +import tech.salroid.filmy.ui.home.MoviesRepository +import tech.salroid.filmy.ui.theme.darkScheme +import tech.salroid.filmy.ui.theme.lightScheme + +class ToRateWidget : GlanceAppWidget() { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface ToRateWidgetEntryPoint { + fun moviesRepository(): MoviesRepository + } + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + ToRateWidgetEntryPoint::class.java + ) + val repository = entryPoint.moviesRepository() + + provideContent { + var itemsWithBitmaps by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(Unit) { + repository.getWatchedUnrated().collect { list -> + val topItems = list.reversed().take(5) + itemsWithBitmaps = topItems.map { item -> + val posterUrl = "https://image.tmdb.org/t/p/w185${item.posterPath}" + val bitmap = loadBitmap(context, posterUrl) + ToRateItemData(item, bitmap) + } + } + } + + GlanceTheme(colors = ToRateWidgetColorScheme) { + ToRateWidgetContent( + items = itemsWithBitmaps, + onItemClick = { data -> + val item = data.movie + val uri = if (item.type == 1) { + "http://tech.salroid.com/shows/${item.id}" + } else { + "http://tech.salroid.com/movies/${item.id}" + } + val intent = Intent(Intent.ACTION_VIEW, uri.toUri()).apply { + `package` = context.packageName + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + actionStartActivity(intent) + } + ) + } + } + } + + private suspend fun loadBitmap(context: Context, url: String): Bitmap? { + return try { + val loader = context.imageLoader + val request = ImageRequest.Builder(context) + .data(url) + .size(200, 300) + .build() + val result = loader.execute(request) + if (result is SuccessResult) { + result.image.asDrawable(context.resources).toBitmap() + } else { + null + } + } catch (e: Exception) { + null + } + } +} + +data class ToRateItemData(val movie: MovieDetails, val bitmap: Bitmap?) + +@Composable +fun ToRateWidgetContent( + items: List, + onItemClick: (ToRateItemData) -> Action +) { + Column( + modifier = GlanceModifier + .fillMaxSize() + .background(GlanceTheme.colors.surface) + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = GlanceModifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + provider = ImageProvider(R.mipmap.ic_launcher), + contentDescription = null, + modifier = GlanceModifier.size(24.dp) + ) + Spacer(modifier = GlanceModifier.width(8.dp)) + Text( + text = "To Rate", + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + ) + } + + Spacer(modifier = GlanceModifier.height(12.dp)) + + if (items.isEmpty()) { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "You're all caught up", + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = 14.sp + ) + ) + } + } else { + LazyColumn( + modifier = GlanceModifier.fillMaxWidth() + ) { + items(items) { item -> + ToRateItem( + data = item, + onClick = onItemClick(item) + ) + } + } + } + } +} + +@Composable +private fun ToRateItem( + data: ToRateItemData, + onClick: Action +) { + val item = data.movie + val bitmap = data.bitmap + + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .padding(8.dp) + .clickable(onClick), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + provider = if (bitmap != null) ImageProvider(bitmap) else ImageProvider(R.drawable.movie_skeleton), + contentDescription = item.title, + contentScale = ContentScale.Crop, + modifier = GlanceModifier + .width(56.dp) + .height(80.dp) + .cornerRadius(8.dp) + ) + + Spacer(modifier = GlanceModifier.width(12.dp)) + + Column(modifier = GlanceModifier.defaultWeight()) { + Text( + text = item.title ?: "Unknown", + style = TextStyle( + color = GlanceTheme.colors.onSecondaryContainer, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1 + ) + Text( + text = if (item.type == 1) "Show" else "Movie", + style = TextStyle( + color = GlanceTheme.colors.onSecondaryContainer, + fontSize = 12.sp + ) + ) + } + } +} + +private val ToRateWidgetColorScheme = ColorProviders( + light = lightScheme, + dark = darkScheme +) + +class ToRateWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = ToRateWidget() +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/widget/WatchlistWidget.kt b/app/src/main/java/tech/salroid/filmy/ui/widget/WatchlistWidget.kt new file mode 100644 index 00000000..d03d26a8 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/widget/WatchlistWidget.kt @@ -0,0 +1,269 @@ +package tech.salroid.filmy.ui.widget + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.action.Action +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.appwidget.cornerRadius +import androidx.glance.material3.ColorProviders +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import coil3.asDrawable +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import tech.salroid.filmy.R +import tech.salroid.filmy.data.local.db.entity.MovieDetails +import tech.salroid.filmy.ui.home.MoviesRepository +import tech.salroid.filmy.ui.theme.* + +class WatchlistWidget : GlanceAppWidget() { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface WatchlistWidgetEntryPoint { + fun moviesRepository(): MoviesRepository + } + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + WatchlistWidgetEntryPoint::class.java + ) + val repository = entryPoint.moviesRepository() + + provideContent { + var watchlistWithBitmaps by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(Unit) { + repository.getWatchlist().collect { list -> + val topItems = list.reversed().take(5) + val itemsWithBitmaps = topItems.map { item -> + val posterUrl = "https://image.tmdb.org/t/p/w185${item.posterPath}" + val bitmap = loadBitmap(context, posterUrl) + WatchlistItemData(item, bitmap) + } + watchlistWithBitmaps = itemsWithBitmaps + } + } + + GlanceTheme(colors = FilmyWidgetColorScheme) { + WatchlistWidgetContent( + watchlist = watchlistWithBitmaps, + onItemClick = { data -> + val item = data.movie + val uri = if (item.type == 1) { + "http://tech.salroid.com/shows/${item.id}" + } else { + "http://tech.salroid.com/movies/${item.id}" + } + val intent = Intent(Intent.ACTION_VIEW, uri.toUri()).apply { + `package` = context.packageName + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + actionStartActivity(intent) + } + ) + } + } + } + + private suspend fun loadBitmap(context: Context, url: String): Bitmap? { + return try { + val loader = context.imageLoader + val request = ImageRequest.Builder(context) + .data(url) + .size(200, 300) // Small size for widget to save memory + .build() + val result = loader.execute(request) + if (result is SuccessResult) { + result.image.asDrawable(context.resources).toBitmap() + } else { + null + } + } catch (e: Exception) { + null + } + } +} + +data class WatchlistItemData(val movie: MovieDetails, val bitmap: Bitmap?) + +@Composable +fun WatchlistWidgetContent( + watchlist: List, + onItemClick: (WatchlistItemData) -> Action +) { + Column( + modifier = GlanceModifier + .fillMaxSize() + .background(GlanceTheme.colors.surface) + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = GlanceModifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + provider = ImageProvider(R.mipmap.ic_launcher), + contentDescription = null, + modifier = GlanceModifier.size(24.dp) + ) + Spacer(modifier = GlanceModifier.width(8.dp)) + Text( + text = "Watchlist", + style = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + ) + } + + Spacer(modifier = GlanceModifier.height(12.dp)) + + if (watchlist.isEmpty()) { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No items in watchlist", + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = 14.sp + ) + ) + } + } else { + LazyColumn( + modifier = GlanceModifier.fillMaxWidth() + ) { + items(watchlist) { item -> + WatchlistItem( + data = item, + onClick = onItemClick(item) + ) + } + } + } + } +} + +@Composable +private fun WatchlistItem( + data: WatchlistItemData, + onClick: Action +) { + val item = data.movie + val bitmap = data.bitmap + + Row( + modifier = GlanceModifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .padding(8.dp) + .clickable(onClick), + verticalAlignment = Alignment.CenterVertically + ) { + + Image( + provider = if (bitmap != null) ImageProvider(bitmap) else ImageProvider(R.drawable.movie_skeleton), + contentDescription = item.title, + contentScale = ContentScale.Crop, + modifier = GlanceModifier + .width(56.dp) + .height(80.dp) + .cornerRadius(8.dp) + ) + + Spacer(modifier = GlanceModifier.width(12.dp)) + + Column(modifier = GlanceModifier.defaultWeight()) { + Text( + text = item.title ?: "Unknown", + style = TextStyle( + color = GlanceTheme.colors.onSecondaryContainer, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1 + ) + Text( + text = if (item.type == 1) "Show" else "Movie", + style = TextStyle( + color = GlanceTheme.colors.onSecondaryContainer, + fontSize = 12.sp + ) + ) + } + + Image( + provider = ImageProvider(R.drawable.ic_check), + contentDescription = "Mark watched", + modifier = GlanceModifier + .size(28.dp) + .padding(4.dp) + .clickable( + actionRunCallback( + actionParametersOf( + movieIdKey to item.id, + movieTypeKey to item.type + ) + ) + ) + ) + } +} + +private val FilmyWidgetColorScheme = ColorProviders( + light = lightScheme, + dark = darkScheme +) + +class WatchlistWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = WatchlistWidget() +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/widget/WidgetActions.kt b/app/src/main/java/tech/salroid/filmy/ui/widget/WidgetActions.kt new file mode 100644 index 00000000..247fddb2 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/widget/WidgetActions.kt @@ -0,0 +1,55 @@ +package tech.salroid.filmy.ui.widget + +import android.content.Context +import androidx.glance.GlanceId +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.action.ActionCallback +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import tech.salroid.filmy.ui.home.AccountSyncRepository +import tech.salroid.filmy.ui.home.MoviesRepository + +val movieIdKey = ActionParameters.Key("movie_id") +val movieTypeKey = ActionParameters.Key("movie_type") + +/** + * Marks a watchlist widget item as watched and drops it from the watchlist - + * for this widget, "watched" means the item is done and should leave the + * to-watch queue it's being shown in. + */ +class MarkWatchedAction : ActionCallback { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface MarkWatchedActionEntryPoint { + fun moviesRepository(): MoviesRepository + fun accountSyncRepository(): AccountSyncRepository + } + + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters + ) { + val id = parameters[movieIdKey] ?: return + val type = parameters[movieTypeKey] ?: return + + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + MarkWatchedActionEntryPoint::class.java + ) + val moviesRepository = entryPoint.moviesRepository() + val accountSyncRepository = entryPoint.accountSyncRepository() + + val existing = moviesRepository.getMovieDetailsFromLocal(id, type) ?: return + val updated = existing.copy(watched = true, watchlist = false) + moviesRepository.addMovieDetailsToLocal(updated) + + val pushed = accountSyncRepository.pushItemState(updated) + if (!pushed) { + moviesRepository.addMovieDetailsToLocal(existing) + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/ui/widget/WidgetRefresher.kt b/app/src/main/java/tech/salroid/filmy/ui/widget/WidgetRefresher.kt new file mode 100644 index 00000000..a1830bb2 --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/ui/widget/WidgetRefresher.kt @@ -0,0 +1,11 @@ +package tech.salroid.filmy.ui.widget + +import android.content.Context +import androidx.glance.appwidget.updateAll + +object WidgetRefresher { + suspend fun refresh(context: Context) { + WatchlistWidget().updateAll(context) + ToRateWidget().updateAll(context) + } +} diff --git a/app/src/main/java/tech/salroid/filmy/utility/DateFormatterUtil.kt b/app/src/main/java/tech/salroid/filmy/utility/DateFormatterUtil.kt new file mode 100644 index 00000000..4eafd36c --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/utility/DateFormatterUtil.kt @@ -0,0 +1,11 @@ +package tech.salroid.filmy.utility + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +fun formateReleaseDate(raw: String): String { + return runCatching { + LocalDate.parse(raw) + .format(DateTimeFormatter.ofPattern("dd MMM yyyy")) + }.getOrElse { "" } +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/utility/Extensions.kt b/app/src/main/java/tech/salroid/filmy/utility/Extensions.kt index 1373fda2..1aa8003d 100644 --- a/app/src/main/java/tech/salroid/filmy/utility/Extensions.kt +++ b/app/src/main/java/tech/salroid/filmy/utility/Extensions.kt @@ -2,30 +2,25 @@ package tech.salroid.filmy.utility import android.app.Activity import android.content.Context +import android.content.Intent +import android.content.Intent.ACTION_VIEW import android.content.res.Configuration import android.graphics.Color import android.util.TypedValue import android.view.View -import androidx.annotation.AttrRes -import androidx.annotation.ColorInt import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.core.text.HtmlCompat import androidx.core.view.WindowCompat import com.google.android.material.elevation.SurfaceColors import com.google.android.material.snackbar.Snackbar +import retrofit2.HttpException import tech.salroid.filmy.R +import java.io.IOException +import java.net.SocketTimeoutException import java.text.SimpleDateFormat import java.util.* -@ColorInt -fun Context.getColorFromAttr( - @AttrRes attrColor: Int, - typedValue: TypedValue = TypedValue(), - resolveRefs: Boolean = true -): Int { - theme.resolveAttribute(attrColor, typedValue, resolveRefs) - return typedValue.data -} - fun View.showSnackBar(message: String, positive: Boolean = true) { Snackbar.make(this, message, Snackbar.LENGTH_SHORT).run { setBackgroundTint( @@ -46,6 +41,22 @@ fun String.toReadableDate(): String { return date?.let { toDateFormat.format(it) } ?: this } +fun Long.toMoneyString(): String { + val value = this.toDouble() + return when { + value >= 1_000_000_000 -> String.format(Locale.getDefault(), "$%.1fB", value / 1_000_000_000) + value >= 1_000_000 -> String.format(Locale.getDefault(), "$%.1fM", value / 1_000_000) + value >= 1_000 -> String.format(Locale.getDefault(), "$%.1fK", value / 1_000) + else -> "$$value" + } +} + +fun String.parseHtml(): String { + return if (this.isEmpty()) this else { + HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_LEGACY).toString().trim() + } +} + fun Activity.themeSystemBars( lightStatusBar: Boolean = false, navigationColorAsStatus: Boolean = true, @@ -78,4 +89,54 @@ fun Activity.themeSystemBars( } fun Context.isDarkThemeActivated(): Boolean = - (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES \ No newline at end of file + (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + +fun Context.openYoutubeTrailer(source: String?) { + source?.let { + val intent = Intent( + ACTION_VIEW, + "vnd.youtube:$it".toUri() + ) + if (intent.resolveActivity(packageManager) == null) { + startActivity( + Intent( + ACTION_VIEW, + "https://www.youtube.com/watch?v=$it".toUri() + ) + ) + } else { + startActivity(intent) + } + } +} + +fun Context.shareMedia(title: String, tagline: String?, imdbId: String?, isTvShow: Boolean) { + val imdbPrefix = getString(R.string.imdb_link_prefix) + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + // IMDb uses the same /title/{id}/ URL scheme for movies and TV, so the + // link applies equally once a TV show's imdbId is available. + val link = if (imdbId != null) "\n$imdbPrefix$imdbId" else "" + putExtra( + Intent.EXTRA_TEXT, + "*$title*\n${tagline ?: ""}$link\n" + ) + } + startActivity(Intent.createChooser(shareIntent, "Share with")) +} + +fun Context.openUrl(url: String) { + startActivity(Intent(ACTION_VIEW, url.toUri())) +} + +/** + * Maps a network/parsing failure to copy a user can act on, instead of the + * raw exception message (e.g. "Unable to resolve host ...") that Retrofit/ + * OkHttp throwables carry by default. + */ +fun Throwable.toUserMessage(): String = when (this) { + is SocketTimeoutException -> "Request timed out. Please try again." + is IOException -> "No internet connection. Check your network and try again." + is HttpException -> "Something went wrong on our end. Please try again later." + else -> "Something went wrong. Please try again." +} diff --git a/app/src/main/java/tech/salroid/filmy/utility/ImageConfig.kt b/app/src/main/java/tech/salroid/filmy/utility/ImageConfig.kt new file mode 100644 index 00000000..1553cc1f --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/utility/ImageConfig.kt @@ -0,0 +1,5 @@ +package tech.salroid.filmy.utility + +object ImageConfig { + const val BASE_URL = "https://image.tmdb.org/t/p/w500" +} \ No newline at end of file diff --git a/app/src/main/java/tech/salroid/filmy/utility/IpGeolocation.kt b/app/src/main/java/tech/salroid/filmy/utility/IpGeolocation.kt new file mode 100644 index 00000000..bdb2e2fd --- /dev/null +++ b/app/src/main/java/tech/salroid/filmy/utility/IpGeolocation.kt @@ -0,0 +1,42 @@ +package tech.salroid.filmy.utility + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject + +/** + * Best-effort IP-based country detection for onboarding's default country + * guess. Deliberately uses its own bare [OkHttpClient] rather than the app's + * TMDB-scoped Retrofit client, since that one's interceptors unconditionally + * attach a TMDB bearer token to every request - not something to send to an + * unrelated third-party service. + * + * Uses ipwho.is (keyless, no rate-limit wall observed) rather than ipapi.co - + * the latter now sits behind Cloudflare bot-protection that blocks plain + * HTTP clients (403) and separately rate-limits its JSON endpoint (429), so + * it silently failed every call and this always fell back to + * Locale.getDefault().country instead of an actual IP-based lookup. + */ +object IpGeolocation { + private val client = OkHttpClient() + + suspend fun fetchCountryCode(): String? = withContext(Dispatchers.IO) { + try { + val request = Request.Builder() + .url("https://ipwho.is/") + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@withContext null + val body = response.body?.string() ?: return@withContext null + val json = JSONObject(body) + if (!json.optBoolean("success", false)) return@withContext null + val code = json.optString("country_code").trim().uppercase() + code.takeIf { it.length == 2 && it.all(Char::isLetter) } + } + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/main/java/tech/salroid/filmy/utility/PreferenceHelper.kt b/app/src/main/java/tech/salroid/filmy/utility/PreferenceHelper.kt index 82e08560..e777ac99 100644 --- a/app/src/main/java/tech/salroid/filmy/utility/PreferenceHelper.kt +++ b/app/src/main/java/tech/salroid/filmy/utility/PreferenceHelper.kt @@ -1,22 +1,96 @@ package tech.salroid.filmy.utility import android.content.Context +import android.content.SharedPreferences import androidx.preference.PreferenceManager import tech.salroid.filmy.R +import androidx.core.content.edit +import java.util.Locale +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged object PreferenceHelper { private const val COLD_START = "coldStart" const val SESSION_ID = "sessionID" + const val COUNTRY_KEY = "selectedCountry" + private const val RECENT_SEARCHES_KEY = "recentSearches" + private const val RECENT_SEARCHES_DELIMITER = "" + private const val RECENT_SEARCHES_LIMIT = 8 + + fun getSelectedCountry(context: Context): String = + PreferenceManager.getDefaultSharedPreferences(context).getString(COUNTRY_KEY, null) + ?: Locale.getDefault().country.takeIf { it.isNotBlank() } + ?: "US" + + private fun SharedPreferences.resolveSelectedCountry(): String = + getString(COUNTRY_KEY, null) + ?: Locale.getDefault().country.takeIf { it.isNotBlank() } + ?: "US" + + // Emits the current region whenever it changes, so reactive pipelines + // (e.g. Movies/Shows paging) can rebuild when the user changes region + // in Account settings, instead of only reading it once at request time. + fun SharedPreferences.selectedCountryFlow(): Flow = callbackFlow { + trySend(resolveSelectedCountry()) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> + if (key == COUNTRY_KEY) { + trySend(prefs.resolveSelectedCountry()) + } + } + registerOnSharedPreferenceChangeListener(listener) + awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } + }.distinctUntilChanged() + + fun setSelectedCountry(context: Context, countryCode: String) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit { + putString(COUNTRY_KEY, countryCode) + } + } fun isColdStart(context: Context) = PreferenceManager.getDefaultSharedPreferences(context).getBoolean(COLD_START, true) fun setColdStartDone(context: Context) { PreferenceManager.getDefaultSharedPreferences(context) - .edit() - .putBoolean(COLD_START, false) - .apply() + .edit { + putBoolean(COLD_START, false) + } + } + + fun SharedPreferences.recentSearches(): List = + getString(RECENT_SEARCHES_KEY, null) + ?.split(RECENT_SEARCHES_DELIMITER) + ?.filter { it.isNotBlank() } + ?: emptyList() + + fun SharedPreferences.addRecentSearch(query: String) { + val trimmed = query.trim() + if (trimmed.isEmpty()) return + val updated = listOf(trimmed) + recentSearches().filterNot { it.equals(trimmed, ignoreCase = true) } + edit { + putString( + RECENT_SEARCHES_KEY, + updated.take(RECENT_SEARCHES_LIMIT).joinToString(RECENT_SEARCHES_DELIMITER) + ) + } + } + + fun SharedPreferences.removeRecentSearch(query: String) { + edit { + putString( + RECENT_SEARCHES_KEY, + recentSearches().filterNot { it.equals(query, ignoreCase = true) } + .joinToString(RECENT_SEARCHES_DELIMITER) + ) + } + } + + fun SharedPreferences.clearRecentSearches() { + edit { remove(RECENT_SEARCHES_KEY) } } fun getCurrentThemeMode(context: Context): String? { @@ -25,4 +99,12 @@ object PreferenceHelper { return PreferenceManager.getDefaultSharedPreferences(context) .getString(themeKey, modeNightFollowSystem) } + + fun setThemeMode(context: Context, mode: String) { + val themeKey = context.getString(R.string.theme_key) + PreferenceManager.getDefaultSharedPreferences(context) + .edit { + putString(themeKey, mode) + } + } } \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/intro_b.webp b/app/src/main/res/drawable-hdpi/intro_b.webp deleted file mode 100644 index 5ae5509c..00000000 Binary files a/app/src/main/res/drawable-hdpi/intro_b.webp and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/intro_c.webp b/app/src/main/res/drawable-hdpi/intro_c.webp deleted file mode 100644 index 4a7a447b..00000000 Binary files a/app/src/main/res/drawable-hdpi/intro_c.webp and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/intro_d.webp b/app/src/main/res/drawable-hdpi/intro_d.webp deleted file mode 100644 index af1f1565..00000000 Binary files a/app/src/main/res/drawable-hdpi/intro_d.webp and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/intro_b.webp b/app/src/main/res/drawable-mdpi/intro_b.webp deleted file mode 100644 index da947ebe..00000000 Binary files a/app/src/main/res/drawable-mdpi/intro_b.webp and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/intro_c.webp b/app/src/main/res/drawable-mdpi/intro_c.webp deleted file mode 100644 index 474e5b4e..00000000 Binary files a/app/src/main/res/drawable-mdpi/intro_c.webp and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/intro_d.webp b/app/src/main/res/drawable-mdpi/intro_d.webp deleted file mode 100644 index f79c2fa3..00000000 Binary files a/app/src/main/res/drawable-mdpi/intro_d.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/intro_b.webp b/app/src/main/res/drawable-xhdpi/intro_b.webp deleted file mode 100644 index e0ac8544..00000000 Binary files a/app/src/main/res/drawable-xhdpi/intro_b.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/intro_c.webp b/app/src/main/res/drawable-xhdpi/intro_c.webp deleted file mode 100644 index eb8489d3..00000000 Binary files a/app/src/main/res/drawable-xhdpi/intro_c.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/intro_d.webp b/app/src/main/res/drawable-xhdpi/intro_d.webp deleted file mode 100644 index bfd1c733..00000000 Binary files a/app/src/main/res/drawable-xhdpi/intro_d.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/intro_b.webp b/app/src/main/res/drawable-xxhdpi/intro_b.webp deleted file mode 100644 index 6a01371c..00000000 Binary files a/app/src/main/res/drawable-xxhdpi/intro_b.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/intro_c.webp b/app/src/main/res/drawable-xxhdpi/intro_c.webp deleted file mode 100644 index d2a7e659..00000000 Binary files a/app/src/main/res/drawable-xxhdpi/intro_c.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/intro_d.webp b/app/src/main/res/drawable-xxhdpi/intro_d.webp deleted file mode 100644 index f745f582..00000000 Binary files a/app/src/main/res/drawable-xxhdpi/intro_d.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/intro_b.webp b/app/src/main/res/drawable-xxxhdpi/intro_b.webp deleted file mode 100644 index 4ee6307b..00000000 Binary files a/app/src/main/res/drawable-xxxhdpi/intro_b.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/intro_c.webp b/app/src/main/res/drawable-xxxhdpi/intro_c.webp deleted file mode 100644 index f02923eb..00000000 Binary files a/app/src/main/res/drawable-xxxhdpi/intro_c.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/intro_d.webp b/app/src/main/res/drawable-xxxhdpi/intro_d.webp deleted file mode 100644 index 79a92211..00000000 Binary files a/app/src/main/res/drawable-xxxhdpi/intro_d.webp and /dev/null differ diff --git a/app/src/main/res/drawable/icon_skeleton.xml b/app/src/main/res/drawable/icon_skeleton.xml index 76076880..4d469f20 100644 --- a/app/src/main/res/drawable/icon_skeleton.xml +++ b/app/src/main/res/drawable/icon_skeleton.xml @@ -1,5 +1,9 @@ - - - - - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/movie_skeleton.xml b/app/src/main/res/drawable/movie_skeleton.xml index 9ca9b5d5..c5e76c60 100644 --- a/app/src/main/res/drawable/movie_skeleton.xml +++ b/app/src/main/res/drawable/movie_skeleton.xml @@ -1,5 +1,9 @@ - - - - - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/poster_error_placeholder.xml b/app/src/main/res/drawable/poster_error_placeholder.xml new file mode 100644 index 00000000..a9265f42 --- /dev/null +++ b/app/src/main/res/drawable/poster_error_placeholder.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/title_skeleton.xml b/app/src/main/res/drawable/title_skeleton.xml index b73b5866..f3cfb9a1 100644 --- a/app/src/main/res/drawable/title_skeleton.xml +++ b/app/src/main/res/drawable/title_skeleton.xml @@ -1,5 +1,9 @@ - - - - - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/src/main/res/font/lobster_regular.ttf b/app/src/main/res/font/lobster_regular.ttf index 921c7dde..e69de29b 100644 Binary files a/app/src/main/res/font/lobster_regular.ttf and b/app/src/main/res/font/lobster_regular.ttf differ diff --git a/app/src/main/res/font/pacifico_regular.ttf b/app/src/main/res/font/pacifico_regular.ttf index b9265a2a..e69de29b 100644 Binary files a/app/src/main/res/font/pacifico_regular.ttf and b/app/src/main/res/font/pacifico_regular.ttf differ diff --git a/app/src/main/res/font/snpro_bold.ttf b/app/src/main/res/font/snpro_bold.ttf new file mode 100644 index 00000000..7d3c209e Binary files /dev/null and b/app/src/main/res/font/snpro_bold.ttf differ diff --git a/app/src/main/res/font/snpro_medium.ttf b/app/src/main/res/font/snpro_medium.ttf new file mode 100644 index 00000000..bd469bf8 Binary files /dev/null and b/app/src/main/res/font/snpro_medium.ttf differ diff --git a/app/src/main/res/font/snpro_regular.ttf b/app/src/main/res/font/snpro_regular.ttf new file mode 100644 index 00000000..a1617876 Binary files /dev/null and b/app/src/main/res/font/snpro_regular.ttf differ diff --git a/app/src/main/res/layout/fragment_intro.xml b/app/src/main/res/layout/fragment_intro.xml deleted file mode 100644 index 7303f8a5..00000000 --- a/app/src/main/res/layout/fragment_intro.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_intro_fragment_b.xml b/app/src/main/res/layout/fragment_intro_fragment_b.xml deleted file mode 100644 index 0475093b..00000000 --- a/app/src/main/res/layout/fragment_intro_fragment_b.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_intro_fragment_c.xml b/app/src/main/res/layout/fragment_intro_fragment_c.xml deleted file mode 100644 index 9d26ad07..00000000 --- a/app/src/main/res/layout/fragment_intro_fragment_c.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_intro_fragment_d.xml b/app/src/main/res/layout/fragment_intro_fragment_d.xml deleted file mode 100644 index 8b2929a9..00000000 --- a/app/src/main/res/layout/fragment_intro_fragment_d.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/top_navigation_item_content.xml b/app/src/main/res/layout/top_navigation_item_content.xml index 2a0925b7..49f14e6f 100644 --- a/app/src/main/res/layout/top_navigation_item_content.xml +++ b/app/src/main/res/layout/top_navigation_item_content.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="wrap_content" android:layout_height="@dimen/filmy44dp" - tools:parentTag="tech.salroid.filmy.ui.component.FilmyTopNavigationBarItem"> + tools:parentTag="tech.salroid.filmy.ui.components.FilmyTopNavigationBarItem"> - @drawable/ic_filmy - ?colorSurfaceVariant - false - ?android:attr/colorBackground - false - - \ No newline at end of file diff --git a/app/src/main/res/values-v27/themes.xml b/app/src/main/res/values-v27/themes.xml deleted file mode 100644 index 1cfeb527..00000000 --- a/app/src/main/res/values-v27/themes.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml index f20ad56b..dd6b86b9 100644 --- a/app/src/main/res/values-v31/themes.xml +++ b/app/src/main/res/values-v31/themes.xml @@ -4,16 +4,4 @@ @android:dimen/system_app_widget_background_radius @android:dimen/system_app_widget_inner_radius - - - \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index a99a59ce..7369cf3f 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,4 +1,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c2c8ef4..53e25d74 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,11 +7,11 @@ extended=full,images /movies?extended=images,full - http://www.imdb.com/title/ - http://img.youtube.com/vi/%1$s/0.jpg - http://image.tmdb.org/t/p/w342 - http://image.tmdb.org/t/p/w1280 - http://image.tmdb.org/t/p/ + https://www.imdb.com/title/ + https://img.youtube.com/vi/%1$s/0.jpg + https://image.tmdb.org/t/p/w342 + https://image.tmdb.org/t/p/w1280 + https://image.tmdb.org/t/p/ https://www.youtube.com/watch?v= Collections @@ -33,6 +33,9 @@ Settings Offline Movies medium + low + high + Image Quality Something went wrong ! Retrying Retry @@ -69,9 +72,6 @@ IN THEATERS UPCOMING Details - Low - High - Image Quality Made for Cinephiles @@ -182,16 +182,6 @@ that this copyright notice remain intact. ]]> - App Intro - -
-
-
-      App Intro by PaoloRotolo
-      https://github.com/PaoloRotolo/AppIntro
-
]]>
- Crashlytics @@ -272,7 +262,7 @@ limitations under the License. Crew Similar Home - Favorites + Watched Watch List Account Not Logged in @@ -281,10 +271,11 @@ limitations under the License. TMDB LOGIN Login Trailers - Favourite - Your favourite movies appear here. + Watched + Your watched movies appear here. Your watchlist appears here. No movies in the list. + Syncing with your TMDB account… There was an error initializing the YouTube Player Collections @@ -309,11 +300,26 @@ limitations under the License. Search - http://image.tmdb.org/t/p/w500%1$s - http://image.tmdb.org/t/p/w500%1$s - http://image.tmdb.org/t/p/w185%1$s + https://image.tmdb.org/t/p/w500%1$s + https://image.tmdb.org/t/p/w500%1$s + https://image.tmdb.org/t/p/w185%1$s Remove + More + Discover Movies + Discover Shows + Filters + Genre + Year + Any Year + Sort By + Minimum Rating + Reset + Apply + No results found + Most Popular + Top Rated + Newest + Oldest Popular Now Playing Upcoming @@ -364,10 +370,34 @@ limitations under the License. Shows Add + Watched + Watched? + Watchlist + Watchlisted + Add to List + Rate + Remove Rating + Your Rating + My Lists + Create new list + List name + You haven\'t created any lists yet. + This list is empty. + Delete this list? + Trending Popular Airing Today On TV Top Rated Top Rated + Latest + + Remove from collection + Are you sure you want to remove this movie from your watched list? + Are you sure you want to remove this movie from your watchlist? + Cancel + + Where to Watch + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 3bfd55f3..c2ac5f99 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -34,9 +34,9 @@