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