diff --git a/build.gradle.kts b/build.gradle.kts index 01aa79b16..e795bc8d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,7 @@ plugins { id("com.google.firebase.firebase-perf") version "1.4.2" apply false id("androidx.navigation.safeargs") version "2.8.0" apply false id("com.github.ben-manes.versions") version "0.51.0" apply true + id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false } allprojects { diff --git a/copy_mock_google_services_json.sh b/copy_mock_google_services_json.sh index 56d42eb4c..c33f738b3 100755 --- a/copy_mock_google_services_json.sh +++ b/copy_mock_google_services_json.sh @@ -12,6 +12,7 @@ cp mock-google-services.json auth/app/google-services.json cp mock-google-services.json config/app/google-services.json cp mock-google-services.json crash/app/google-services.json cp mock-google-services.json database/app/google-services.json +cp mock-google-services.json dataconnect/app/google-services.json cp mock-google-services.json dynamiclinks/app/google-services.json cp mock-google-services.json firestore/app/google-services.json cp mock-google-services.json functions/app/google-services.json diff --git a/dataconnect/.gitignore b/dataconnect/.gitignore new file mode 100644 index 000000000..5b4ab9b06 --- /dev/null +++ b/dataconnect/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +.dataconnect/ +.firebaserc diff --git a/dataconnect/README.md b/dataconnect/README.md new file mode 100644 index 000000000..761f54539 --- /dev/null +++ b/dataconnect/README.md @@ -0,0 +1,97 @@ +# Firebase Data Connect Quickstart + +## Introduction + +This quickstart is a movie review app to demonstrate the use of Firebase Data Connect + with a Cloud SQL database. +For more information about Firebase Data Connect visit [the docs](https://firebase.google.com/docs/data-connect/). + +## Getting Started + +Follow these steps to get up and running with Firebase Data Connect. For more detailed instructions, +check out the [official documentation](https://firebase.google.com/docs/data-connect/quickstart). + +### 1. Connect to your Firebase project + +1. If you haven't already, create a Firebase project. + 1. In the [Firebase console](https://console.firebase.google.com), click + **Add project**, then follow the on-screen instructions. + +2. Upgrade your project to the Blaze plan. This lets you create a Cloud SQL + for PostgreSQL instance. + + > Note: Though you set up billing in your Blaze upgrade, you won't be + charged for usage of Firebase Data Connect or the + [default Cloud SQL for PostgreSQL configuration](https://firebase.google.com/docs/data-connect/#pricing) + during the preview. + +3. Navigate to the [Data Connect section](https://console.firebase.google.com/u/0/project/_/dataconnect) + of the Firebase console, click on the "Get Started" button and follow the setup workflow: + - Select a location for your Cloud SQL for PostgreSQL database (this sample uses `us-central1`). If you choose a different location, you'll also need to change the `quickstart-android/dataconnect/dataconnect/dataconnect.yaml` file. + - Select the option to create a new Cloud SQL instance and fill in the following fields: + - Service ID: `dataconnect` + - Cloud SQL Instance ID: `fdc-sql` + - Database name: `fdcdb` +4. Allow some time for the Cloud SQL instance to be provisioned. After it's provisioned, the instance + can be managed in the [Cloud Console](https://console.cloud.google.com/sql). + +5. If you haven’t already, add an Android app to your Firebase project, with the android package name `com.google.firebase.example.dataconnect`. Download and then add the Firebase Android configuration file (`google-services.json`) to your app: + 1. Click **Download google-services.json** to obtain your Firebase Android config file. + 2. Move your config file into the `quickstart-android/dataconnect/app` directory. + +### 2. Set Up Firebase CLI + +Ensure the Firebase CLI is installed and up to date: + +```bash +npm install -g firebase-tools +``` + +### 3. Cloning the repository +This repository contains the quickstart to get started with the functionalities of Data Connect. + +1. Clone this repository to your local machine: + ```sh + git clone https://github.com/firebase/quickstart-android.git + ``` + +2. (Private Preview only) Checkout the `fdc-quickstart` branch (`git checkout fdc-quickstart`) + and open the project in Android Studio. + +### 4. Deploy the service to Firebase and generate SDKs + +1. Open the `quickstart-android/dataconnect/dataconnect` directory and deploy the schema with + the following command: + ```bash + firebase deploy + ``` +2. Once the deploy is complete, you should be able to see the movie schema in the + [Data Connect section](https://console.firebase.google.com/u/0/project/_/dataconnect) + of the Firebase console. + +3. Generate the Kotlin SDK by running: + ```bash + firebase dataconnect:sdk:generate + ``` + +### 5. Populating the database +1. Run `1_movie_insert.gql`, `2_actor_insert.gql`, `3_movie_actor_insert.gql`, and `4_user_favorites_review_insert.gql` files in the `./dataconnect` directory in order using the VS code extension, + +### 6. Running the app + +Press the Run button in Android Studio to run the sample app on your device. + +## 🚧 Work in Progress + +This app is still missing some features which will be added before Public Preview: + +- [ ] Search +- [ ] Movie review + - [x] Add a new review + - [ ] Update a review + - [ ] Delete a review +- [x] Actors + - [x] Show actor profile + - [x] Mark actor as favorite +- [ ] Error handling + Some errors may cause the app to crash, especially if there's no user logged in. diff --git a/dataconnect/app/.gitignore b/dataconnect/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/dataconnect/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/dataconnect/app/build.gradle.kts b/dataconnect/app/build.gradle.kts new file mode 100644 index 000000000..7484eb7c2 --- /dev/null +++ b/dataconnect/app/build.gradle.kts @@ -0,0 +1,84 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.google.services) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.google.firebase.example.dataconnect" + compileSdk = 34 + + defaultConfig { + applicationId = "com.google.firebase.example.dataconnect" + minSdk = 23 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.13" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + sourceSets.getByName("main") { + java.srcDirs("build/generated/sources") + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.compose.navigation) + implementation(libs.androidx.lifecycle.runtime.compose.android) + implementation(libs.coil.compose) + + // Firebase dependencies + implementation(libs.firebase.auth) + implementation(libs.firebase.dataconnect) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/dataconnect/app/proguard-rules.pro b/dataconnect/app/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/dataconnect/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/dataconnect/app/src/main/AndroidManifest.xml b/dataconnect/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..230081caf --- /dev/null +++ b/dataconnect/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MainActivity.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MainActivity.kt new file mode 100644 index 000000000..632b9ac78 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MainActivity.kt @@ -0,0 +1,135 @@ +package com.google.firebase.example.dataconnect + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.instance +import com.google.firebase.example.dataconnect.feature.actordetail.ActorDetailRoute +import com.google.firebase.example.dataconnect.feature.actordetail.ActorDetailScreen +import com.google.firebase.example.dataconnect.feature.genredetail.GenreDetailRoute +import com.google.firebase.example.dataconnect.feature.genredetail.GenreDetailScreen +import com.google.firebase.example.dataconnect.feature.genres.GenresRoute +import com.google.firebase.example.dataconnect.feature.genres.GenresScreen +import com.google.firebase.example.dataconnect.feature.moviedetail.MovieDetailRoute +import com.google.firebase.example.dataconnect.feature.moviedetail.MovieDetailScreen +import com.google.firebase.example.dataconnect.feature.movies.MoviesRoute +import com.google.firebase.example.dataconnect.feature.movies.MoviesScreen +import com.google.firebase.example.dataconnect.feature.profile.ProfileRoute +import com.google.firebase.example.dataconnect.feature.profile.ProfileScreen +import com.google.firebase.example.dataconnect.feature.search.searchScreen +import com.google.firebase.example.dataconnect.ui.theme.FirebaseDataConnectTheme + +data class TopLevelRoute(val labelResId: Int, val route: T, val icon: ImageVector) + +val TOP_LEVEL_ROUTES = listOf( + TopLevelRoute(R.string.label_movies, MoviesRoute, Icons.Filled.Home), + TopLevelRoute(R.string.label_genres, GenresRoute, Icons.Filled.Menu), + TopLevelRoute(R.string.label_profile, ProfileRoute, Icons.Filled.Person) +) + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + // Comment the line below to use a production environment instead + MoviesConnector.instance.dataConnect.useEmulator("10.0.2.2", 9399) + setContent { + FirebaseDataConnectTheme { + val navController = rememberNavController() + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + NavigationBar { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + TOP_LEVEL_ROUTES.forEach { topLevelRoute -> + val label = stringResource(topLevelRoute.labelResId) + NavigationBarItem( + icon = { Icon(topLevelRoute.icon, contentDescription = label) }, + label = { Text(label) }, + selected = currentDestination?.hierarchy?.any { + it.hasRoute(topLevelRoute.route::class) + } == true, + onClick = { + navController.navigate( + topLevelRoute.route, + { launchSingleTop = true } + ) + } + ) + } + } + } + ) { innerPadding -> + NavHost( + navController, + startDestination = MoviesRoute, + Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding), + ) { + composable() { + MoviesScreen( + onMovieClicked = { movieId -> + navController.navigate( + route = MovieDetailRoute(movieId), + builder = { + launchSingleTop = true + } + ) + } + ) + } + composable { + MovieDetailScreen( + onActorClicked = { actorId -> + navController.navigate( + ActorDetailRoute(actorId), + { launchSingleTop = true } + ) + } + ) + } + composable() { ActorDetailScreen() } + composable { + GenresScreen(onGenreClicked = { genre -> + navController.navigate( + GenreDetailRoute(genre), + { launchSingleTop = true } + ) + }) + } + composable { GenreDetailScreen() } + searchScreen() + composable { ProfileScreen() } + } + } + } + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailScreen.kt new file mode 100644 index 000000000..0441dbdc6 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailScreen.kt @@ -0,0 +1,149 @@ +package com.google.firebase.example.dataconnect.feature.actordetail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.google.firebase.dataconnect.movies.GetActorByIdQuery +import com.google.firebase.example.dataconnect.R +import com.google.firebase.example.dataconnect.ui.components.ErrorCard +import com.google.firebase.example.dataconnect.ui.components.LoadingScreen +import com.google.firebase.example.dataconnect.ui.components.Movie +import com.google.firebase.example.dataconnect.ui.components.MoviesList +import com.google.firebase.example.dataconnect.ui.components.ToggleButton +import kotlinx.serialization.Serializable + + +@Serializable +data class ActorDetailRoute(val actorId: String) + +@Composable +fun ActorDetailScreen( + actorDetailViewModel: ActorDetailViewModel = viewModel() +) { + val uiState by actorDetailViewModel.uiState.collectAsState() + ActorDetailScreen( + uiState = uiState, + onMovieClicked = { + // TODO + }, + onFavoriteToggled = { + actorDetailViewModel.toggleFavorite(it) + } + ) +} + +@Composable +fun ActorDetailScreen( + uiState: ActorDetailUIState, + onMovieClicked: (actorId: String) -> Unit, + onFavoriteToggled: (newValue: Boolean) -> Unit +) { + when (uiState) { + is ActorDetailUIState.Error -> ErrorCard(uiState.errorMessage) + + is ActorDetailUIState.Loading -> LoadingScreen() + + is ActorDetailUIState.Success -> { + Scaffold { innerPadding -> + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .padding(innerPadding) + .verticalScroll(scrollState) + ) { + ActorInformation( + actor = uiState.actor, + isActorFavorite = uiState.isFavorite, + onFavoriteToggled = onFavoriteToggled + ) + MoviesList( + listTitle = stringResource(R.string.title_main_roles), + movies = uiState.actor?.mainActors?.mapNotNull { + Movie(it.id.toString(), it.imageUrl, it.title) + }, + onMovieClicked = onMovieClicked + ) + Spacer(modifier = Modifier.height(8.dp)) + MoviesList( + listTitle = stringResource(R.string.title_supporting_actors), + movies = uiState.actor?.supportingActors?.mapNotNull { + Movie(it.id.toString(), it.imageUrl, it.title) + }, + onMovieClicked = onMovieClicked + ) + } + } + + } + } +} + +@Composable +fun ActorInformation( + modifier: Modifier = Modifier, + actor: GetActorByIdQuery.Data.Actor?, + isActorFavorite: Boolean, + onFavoriteToggled: (newValue: Boolean) -> Unit +) { + if (actor == null) { + ErrorCard(stringResource(R.string.error_actor_not_found)) + } else { + Column( + modifier = modifier + .padding(16.dp) + ) { + Text( + text = actor.name, + style = MaterialTheme.typography.headlineLarge + ) + Row { + AsyncImage( + model = actor.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(150.dp) + .aspectRatio(9f / 16f) + .padding(vertical = 8.dp) + ) + Text( + text = actor.biography ?: stringResource(R.string.biography_not_available), + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + ) + } + Spacer(modifier = Modifier.height(8.dp)) + ToggleButton( + iconEnabled = Icons.Filled.Favorite, + iconDisabled = Icons.Outlined.FavoriteBorder, + textEnabled = stringResource(R.string.button_remove_favorite), + textDisabled = stringResource(R.string.button_favorite), + isEnabled = isActorFavorite, + onToggle = onFavoriteToggled + ) + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailUIState.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailUIState.kt new file mode 100644 index 000000000..7f8ca2ff5 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailUIState.kt @@ -0,0 +1,18 @@ +package com.google.firebase.example.dataconnect.feature.actordetail + +import com.google.firebase.dataconnect.movies.GetActorByIdQuery +import com.google.firebase.dataconnect.movies.GetMovieByIdQuery + + +sealed class ActorDetailUIState { + data object Loading: ActorDetailUIState() + + data class Error(val errorMessage: String?): ActorDetailUIState() + + data class Success( + // Actor is null if it can't be found on the DB + val actor: GetActorByIdQuery.Data.Actor?, + val isUserSignedIn: Boolean = false, + var isFavorite: Boolean = false + ) : ActorDetailUIState() +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailViewModel.kt new file mode 100644 index 000000000..0ea3b4a2f --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailViewModel.kt @@ -0,0 +1,83 @@ +package com.google.firebase.example.dataconnect.feature.actordetail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.google.firebase.Firebase +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.auth +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class ActorDetailViewModel( + savedStateHandle: SavedStateHandle +) : ViewModel() { + private val actorDetailRoute = savedStateHandle.toRoute() + private val actorId: String = actorDetailRoute.actorId + + private val firebaseAuth: FirebaseAuth = Firebase.auth + private val moviesConnector: MoviesConnector = MoviesConnector.instance + + private val _uiState = MutableStateFlow(ActorDetailUIState.Loading) + val uiState: StateFlow + get() = _uiState + + init { + fetchActor() + } + + private fun fetchActor() { + viewModelScope.launch { + try { + val user = firebaseAuth.currentUser + val actor = moviesConnector.getActorById.execute( + id = UUID.fromString(actorId) + ).data.actor + + _uiState.value = if (user == null) { + ActorDetailUIState.Success(actor, isUserSignedIn = false) + } else { + val favoriteActor = moviesConnector.getIfFavoritedActor.execute( + id = user.uid, + actorId = UUID.fromString(actorId) + ).data.favoriteActor + + val isFavorite = favoriteActor != null + + ActorDetailUIState.Success( + actor, + isUserSignedIn = true, + isFavorite = isFavorite + ) + } + } catch (e: Exception) { + _uiState.value = ActorDetailUIState.Error(e.message) + } + } + } + + fun toggleFavorite(newValue: Boolean) { + viewModelScope.launch { + try { + if (newValue) { + moviesConnector.addFavoritedActor.execute(UUID.fromString(actorId)) + } else { + moviesConnector.deleteFavoriteActor.execute( + userId = firebaseAuth.currentUser?.uid ?: "", + actorId = UUID.fromString(actorId) + ) + } + // Re-run the query to fetch the actor details + fetchActor() + } catch (e: Exception) { + _uiState.value = ActorDetailUIState.Error(e.message) + } + } + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailScreen.kt new file mode 100644 index 000000000..c7e9e0e4b --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailScreen.kt @@ -0,0 +1,76 @@ +package com.google.firebase.example.dataconnect.feature.genredetail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.firebase.example.dataconnect.R +import com.google.firebase.example.dataconnect.ui.components.ErrorCard +import com.google.firebase.example.dataconnect.ui.components.LoadingScreen +import com.google.firebase.example.dataconnect.ui.components.Movie +import com.google.firebase.example.dataconnect.ui.components.MoviesList +import kotlinx.serialization.Serializable + +@Serializable +data class GenreDetailRoute(val genre: String) + +@Composable +fun GenreDetailScreen( + moviesViewModel: GenreDetailViewModel = viewModel() +) { + val movies by moviesViewModel.uiState.collectAsState() + GenreDetailScreen(movies) +} + +@Composable +fun GenreDetailScreen( + uiState: GenreDetailUIState +) { + when (uiState) { + GenreDetailUIState.Loading -> LoadingScreen() + + is GenreDetailUIState.Error -> ErrorCard(uiState.errorMessage) + + is GenreDetailUIState.Success -> { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .verticalScroll(scrollState) + ) { + Text( + text = stringResource(R.string.title_genre_detail, uiState.genreName), + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(8.dp) + ) + MoviesList( + listTitle = stringResource(R.string.title_most_popular), + movies = uiState.mostPopular.mapNotNull { + Movie(it.id.toString(), it.imageUrl, it.title, it.rating?.toFloat()) + }, + onMovieClicked = { + // TODO + } + ) + MoviesList( + listTitle = stringResource(R.string.title_most_recent), + movies = uiState.mostRecent.mapNotNull { + Movie(it.id.toString(), it.imageUrl, it.title, it.rating?.toFloat()) + }, + onMovieClicked = { + // TODO + } + ) + } + } + } +} + diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailUIState.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailUIState.kt new file mode 100644 index 000000000..442776c3c --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailUIState.kt @@ -0,0 +1,16 @@ +package com.google.firebase.example.dataconnect.feature.genredetail + +import com.google.firebase.dataconnect.movies.ListMoviesByGenreQuery + +sealed class GenreDetailUIState { + + data object Loading: GenreDetailUIState() + + data class Error(val errorMessage: String?): GenreDetailUIState() + + data class Success( + val genreName: String, + val mostPopular: List, + val mostRecent: List + ) : GenreDetailUIState() +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailViewModel.kt new file mode 100644 index 000000000..2d327d101 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailViewModel.kt @@ -0,0 +1,44 @@ +package com.google.firebase.example.dataconnect.feature.genredetail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class GenreDetailViewModel( + savedStateHandle: SavedStateHandle +) : ViewModel() { + private val genre = savedStateHandle.toRoute().genre + private val moviesConnector: MoviesConnector = MoviesConnector.instance + + private val _uiState = MutableStateFlow(GenreDetailUIState.Loading) + val uiState: StateFlow + get() = _uiState + + init { + fetchGenre() + } + + private fun fetchGenre() { + viewModelScope.launch { + try { + val data = moviesConnector.listMoviesByGenre.execute(genre.lowercase()).data + val mostPopular = data.mostPopular + val mostRecent = data.mostRecent + _uiState.value = GenreDetailUIState.Success( + genreName = genre, + mostPopular = mostPopular, + mostRecent = mostRecent + ) + } catch (e: Exception) { + _uiState.value = GenreDetailUIState.Error(e.message) + } + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/GenresScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/GenresScreen.kt new file mode 100644 index 000000000..efa98a37b --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/GenresScreen.kt @@ -0,0 +1,44 @@ +package com.google.firebase.example.dataconnect.feature.genres + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.serialization.Serializable + +@Serializable +object GenresRoute + +@Composable +fun GenresScreen( + onGenreClicked: (genre: String) -> Unit = {} +) { + // Hardcoding genres for now + val genres = arrayOf("Action", "Crime", "Drama", "Sci-Fi") + + LazyColumn { + items(genres) { genre -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp) + .clickable { + onGenreClicked(genre) + } + ) { + Text( + text = genre, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(8.dp) + ) + } + } + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailScreen.kt new file mode 100644 index 000000000..9ca23be69 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailScreen.kt @@ -0,0 +1,206 @@ +package com.google.firebase.example.dataconnect.feature.moviedetail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.google.firebase.dataconnect.movies.GetMovieByIdQuery +import com.google.firebase.example.dataconnect.R +import com.google.firebase.example.dataconnect.ui.components.Actor +import com.google.firebase.example.dataconnect.ui.components.ActorsList +import com.google.firebase.example.dataconnect.ui.components.ErrorCard +import com.google.firebase.example.dataconnect.ui.components.LoadingScreen +import com.google.firebase.example.dataconnect.ui.components.ReviewCard +import com.google.firebase.example.dataconnect.ui.components.ToggleButton +import kotlinx.serialization.Serializable + +@Serializable +data class MovieDetailRoute(val movieId: String) + +@Composable +fun MovieDetailScreen( + onActorClicked: (actorId: String) -> Unit, + movieDetailViewModel: MovieDetailViewModel = viewModel() +) { + val uiState by movieDetailViewModel.uiState.collectAsState() + Scaffold { padding -> + when (uiState) { + is MovieDetailUIState.Error -> { + ErrorCard((uiState as MovieDetailUIState.Error).errorMessage) + } + + MovieDetailUIState.Loading -> LoadingScreen() + + is MovieDetailUIState.Success -> { + val ui = uiState as MovieDetailUIState.Success + val movie = ui.movie + val scrollState = rememberScrollState() + Column( + modifier = Modifier.verticalScroll(scrollState) + ) { + MovieInformation( + modifier = Modifier.padding(padding), + movie = movie, + isMovieWatched = ui.isWatched, + isMovieFavorite = ui.isFavorite, + onFavoriteToggled = { newValue -> + movieDetailViewModel.toggleFavorite(newValue) + }, + onWatchToggled = { newValue -> + movieDetailViewModel.toggleWatched(newValue) + } + ) + // Main Actors list + ActorsList( + listTitle = stringResource(R.string.title_main_actors), + actors = movie?.mainActors?.mapNotNull { + Actor(it.id.toString(), it.name, it.imageUrl) + }, + onActorClicked = { onActorClicked(it) } + ) + // Supporting Actors list + ActorsList( + listTitle = stringResource(R.string.title_supporting_actors), + actors = movie?.supportingActors?.mapNotNull { + Actor(it.id.toString(), it.name, it.imageUrl) + }, + onActorClicked = { onActorClicked(it) } + ) + UserReviews( + onReviewSubmitted = { rating, text -> + movieDetailViewModel.addRating(rating, text) + }, + movie?.reviews + ) + } + + } + } + } +} + +@Composable +fun MovieInformation( + modifier: Modifier = Modifier, + movie: GetMovieByIdQuery.Data.Movie?, + isMovieWatched: Boolean, + isMovieFavorite: Boolean, + onWatchToggled: (newValue: Boolean) -> Unit, + onFavoriteToggled: (newValue: Boolean) -> Unit +) { + if (movie == null) { + ErrorCard(stringResource(R.string.error_movie_not_found)) + } else { + Column( + modifier = modifier + .padding(16.dp) + ) { + Text( + text = movie.title, + style = MaterialTheme.typography.headlineLarge + ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = movie.releaseYear.toString(), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(end = 4.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon(Icons.Outlined.Star, "Favorite") + Text( + text = movie.rating?.toString() ?: "0.0", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 2.dp) + ) + } + Row { + AsyncImage( + model = movie.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(150.dp) + .aspectRatio(9f / 16f) + .padding(vertical = 8.dp) + ) + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Row { + movie.tags?.let { movieTags -> + movieTags.filterNotNull().forEach { tag -> + SuggestionChip( + onClick = { }, + label = { Text(tag) }, + modifier = Modifier + .padding(horizontal = 4.dp) + ) + } + } + } + Text( + text = movie.description ?: stringResource(R.string.description_not_available), + modifier = Modifier.fillMaxWidth() + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row { + ToggleButton( + iconEnabled = Icons.Filled.CheckCircle, + iconDisabled = Icons.Outlined.Check, + textEnabled = stringResource(R.string.button_unmark_watched), + textDisabled = stringResource(R.string.button_mark_watched), + isEnabled = isMovieWatched, + onToggle = onWatchToggled + ) + Spacer(modifier = Modifier.width(8.dp)) + ToggleButton( + iconEnabled = Icons.Filled.Favorite, + iconDisabled = Icons.Outlined.FavoriteBorder, + textEnabled = stringResource(R.string.button_remove_favorite), + textDisabled = stringResource(R.string.button_favorite), + isEnabled = isMovieFavorite, + onToggle = onFavoriteToggled + ) + } + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailUIState.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailUIState.kt new file mode 100644 index 000000000..1f6e04028 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailUIState.kt @@ -0,0 +1,18 @@ +package com.google.firebase.example.dataconnect.feature.moviedetail + +import com.google.firebase.dataconnect.movies.GetMovieByIdQuery + + +sealed class MovieDetailUIState { + data object Loading: MovieDetailUIState() + + data class Error(val errorMessage: String?): MovieDetailUIState() + + data class Success( + // Movie is null if it can't be found on the DB + val movie: GetMovieByIdQuery.Data.Movie?, + val isUserSignedIn: Boolean = false, + var isWatched: Boolean = false, + var isFavorite: Boolean = false + ) : MovieDetailUIState() +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailViewModel.kt new file mode 100644 index 000000000..fc19a4c36 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailViewModel.kt @@ -0,0 +1,131 @@ +package com.google.firebase.example.dataconnect.feature.moviedetail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.google.firebase.Firebase +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.auth +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance +import java.util.UUID +import kotlin.math.roundToInt +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class MovieDetailViewModel( + savedStateHandle: SavedStateHandle +) : ViewModel() { + private val movieDetailRoute = savedStateHandle.toRoute() + private val movieId: String = movieDetailRoute.movieId + + private val firebaseAuth: FirebaseAuth = Firebase.auth + private val moviesConnector: MoviesConnector = MoviesConnector.instance + + private val _uiState = MutableStateFlow(MovieDetailUIState.Loading) + val uiState: StateFlow + get() = _uiState + + init { + fetchMovie() + } + + private fun fetchMovie() { + viewModelScope.launch { + try { + val user = firebaseAuth.currentUser + val movie = moviesConnector.getMovieById.execute( + id = UUID.fromString(movieId) + ).data.movie + + _uiState.value = if (user == null) { + MovieDetailUIState.Success(movie, isUserSignedIn = false) + } else { + val isWatched = moviesConnector.getIfWatched.execute( + id = user.uid, + movieId = UUID.fromString(movieId) + ).data.watchedMovie != null + + val isFavorite = moviesConnector.getIfFavoritedMovie.execute( + id = user.uid, + movieId = UUID.fromString(movieId) + ).data.favoriteMovie != null + + MovieDetailUIState.Success( + movie = movie, + isUserSignedIn = true, + isWatched = isWatched, + isFavorite = isFavorite + ) + } + } catch (e: Exception) { + _uiState.value = MovieDetailUIState.Error(e.message) + } + } + } + + fun toggleFavorite(newValue: Boolean) { + viewModelScope.launch { + try { + if (newValue) { + moviesConnector.addFavoritedMovie.execute(UUID.fromString(movieId)) + } else { + // TODO(thatfiredev): investigate whether this is a schema error + // userId probably shouldn't be here. + moviesConnector.deleteFavoritedMovie.execute( + userId = firebaseAuth.currentUser?.uid ?: "", + movieId = UUID.fromString(movieId) + ) + } + // Re-run the query to fetch movie + fetchMovie() + } catch (e: Exception) { + _uiState.value = MovieDetailUIState.Error(e.message) + } + } + } + + fun toggleWatched(newValue: Boolean) { + viewModelScope.launch { + try { + if (newValue) { + moviesConnector.addWatchedMovie.execute(UUID.fromString(movieId)) + } else { + // TODO(thatfiredev): investigate whether this is a schema error + // userId probably shouldn't be here. + moviesConnector.deleteWatchedMovie.execute( + userId = firebaseAuth.currentUser?.uid ?: "", + movieId = UUID.fromString(movieId) + ) + } + // Re-run the query to fetch movie + fetchMovie() + } catch (e: Exception) { + _uiState.value = MovieDetailUIState.Error(e.message) + } + } + } + + fun addRating(rating: Float, text: String) { + viewModelScope.launch { + try { + moviesConnector.addReview.execute( + movieId = UUID.fromString(movieId), + // TODO(thatfiredev): this might have been an error in the mutation definition + // rating shouldn't be an Int!! + rating = rating.roundToInt(), + reviewText = text + ) + // TODO(thatfiredev): should we have a way of only refetching the reviews? + // Re-run the query to fetch movie + fetchMovie() + } catch (e: Exception) { + _uiState.value = MovieDetailUIState.Error(e.message) + } + } + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/UserReviews.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/UserReviews.kt new file mode 100644 index 000000000..0b677d48d --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/UserReviews.kt @@ -0,0 +1,84 @@ +package com.google.firebase.example.dataconnect.feature.moviedetail + +import androidx.compose.foundation.layout.Column +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.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.google.firebase.dataconnect.movies.GetMovieByIdQuery +import com.google.firebase.example.dataconnect.R +import com.google.firebase.example.dataconnect.ui.components.ReviewCard + +@Composable +fun UserReviews( + onReviewSubmitted: (rating: Float, text: String) -> Unit, + reviews: List? = emptyList() +) { + var reviewText by remember { mutableStateOf("") } + Text( + text = "User Reviews", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + var rating by remember { mutableFloatStateOf(3f) } + Text("Rating: ${rating}") + Slider( + value = rating, + // Round the value to the nearest 0.5 + onValueChange = { rating = (Math.round(it * 2) / 2.0).toFloat() }, + steps = 9, + valueRange = 1f..5f + ) + TextField( + value = reviewText, + onValueChange = { reviewText = it }, + label = { Text(stringResource(R.string.hint_write_review)) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + onReviewSubmitted(rating, reviewText) + reviewText = "" + } + ) { + Text(stringResource(R.string.button_submit_review)) + } + } + Column { + // TODO(thatfiredev): Handle cases where the list is too long to display + reviews.orEmpty().forEach { + ReviewCard( + userName = it.user.username, + date = it.reviewDate, + rating = it.rating?.toDouble() ?: 0.0, + text = it.reviewText ?: "" + ) + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesScreen.kt new file mode 100644 index 000000000..c76090264 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesScreen.kt @@ -0,0 +1,66 @@ +package com.google.firebase.example.dataconnect.feature.movies + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.firebase.example.dataconnect.R +import com.google.firebase.example.dataconnect.ui.components.ErrorCard +import com.google.firebase.example.dataconnect.ui.components.LoadingScreen +import com.google.firebase.example.dataconnect.ui.components.Movie +import com.google.firebase.example.dataconnect.ui.components.MoviesList +import kotlinx.serialization.Serializable + +@Serializable +object MoviesRoute + +@Composable +fun MoviesScreen( + onMovieClicked: (movie: String) -> Unit, + moviesViewModel: MoviesViewModel = viewModel() +) { + val movies by moviesViewModel.uiState.collectAsState() + MoviesScreen(movies, onMovieClicked) +} + +@Composable +fun MoviesScreen( + uiState: MoviesUIState, + onMovieClicked: (movie: String) -> Unit +) { + when (uiState) { + MoviesUIState.Loading -> LoadingScreen() + is MoviesUIState.Error -> ErrorCard(uiState.errorMessage) + is MoviesUIState.Success -> { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .verticalScroll(scrollState) + ) { + MoviesList( + listTitle = stringResource(R.string.title_top_10_movies), + movies = uiState.top10movies.mapNotNull { + Movie(it.id.toString(), it.imageUrl, it.title, it.rating?.toFloat()) + }, + onMovieClicked = onMovieClicked + ) + Spacer(modifier = Modifier.height(16.dp)) + MoviesList( + listTitle = stringResource(R.string.title_latest_movies), + movies = uiState.latestMovies.mapNotNull { + Movie(it.id.toString(), it.imageUrl, it.title, it.rating?.toFloat()) + }, + onMovieClicked = onMovieClicked + ) + } + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesUIState.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesUIState.kt new file mode 100644 index 000000000..3f428b2e5 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesUIState.kt @@ -0,0 +1,16 @@ +package com.google.firebase.example.dataconnect.feature.movies + +import com.google.firebase.dataconnect.movies.MoviesRecentlyReleasedQuery +import com.google.firebase.dataconnect.movies.MoviesTop10Query + +sealed class MoviesUIState { + + data object Loading: MoviesUIState() + + data class Error(val errorMessage: String?): MoviesUIState() + + data class Success( + val top10movies: List, + val latestMovies: List + ) : MoviesUIState() +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesViewModel.kt new file mode 100644 index 000000000..932dca76e --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesViewModel.kt @@ -0,0 +1,32 @@ +package com.google.firebase.example.dataconnect.feature.movies + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class MoviesViewModel( + private val moviesConnector: MoviesConnector = MoviesConnector.instance +) : ViewModel() { + + private val _uiState = MutableStateFlow(MoviesUIState.Loading) + val uiState: StateFlow + get() = _uiState + + init { + viewModelScope.launch { + try { + val top10Movies = moviesConnector.moviesTop10.execute().data.movies + val latestMovies = moviesConnector.moviesRecentlyReleased.execute().data.movies + + _uiState.value = MoviesUIState.Success(top10Movies, latestMovies) + } catch (e: Exception) { + _uiState.value = MoviesUIState.Error(e.localizedMessage) + } + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/AuthScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/AuthScreen.kt new file mode 100644 index 000000000..a9cf6e1df --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/AuthScreen.kt @@ -0,0 +1,94 @@ +package com.google.firebase.example.dataconnect.feature.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +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.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp + +@Composable +fun AuthScreen( + onSignUp: (email: String, password: String, displayName: String) -> Unit, + onSignIn: (email: String, password: String) -> Unit, +) { + var isSignUp by remember { mutableStateOf(false) } + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf("") } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") } + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation() + ) + Spacer(modifier = Modifier.height(8.dp)) + if (isSignUp) { + OutlinedTextField( + value = displayName, + onValueChange = { displayName = it }, + label = { Text("Name") } + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { + if (isSignUp) { + onSignUp(email, password, displayName) + } else { + onSignIn(email, password) + } + }) { + Text( + text = if (isSignUp) { + "Sign up" + } else { + "Sign in" + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = if (isSignUp) { + "Already have an account?" + } else { + "Don't have an account?" + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + isSignUp = !isSignUp + }) { + Text( + text = if (isSignUp) { + "Sign in" + } else { + "Sign up" + } + ) + } + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt new file mode 100644 index 000000000..c020552b6 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt @@ -0,0 +1,173 @@ +package com.google.firebase.example.dataconnect.feature.profile + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.firebase.dataconnect.movies.GetUserByIdQuery +import com.google.firebase.example.dataconnect.R +import com.google.firebase.example.dataconnect.ui.components.Actor +import com.google.firebase.example.dataconnect.ui.components.ActorsList +import com.google.firebase.example.dataconnect.ui.components.ErrorCard +import com.google.firebase.example.dataconnect.ui.components.LoadingScreen +import com.google.firebase.example.dataconnect.ui.components.Movie +import com.google.firebase.example.dataconnect.ui.components.MoviesList +import com.google.firebase.example.dataconnect.ui.components.ReviewCard +import kotlinx.serialization.Serializable + +@Serializable +object ProfileRoute + +@Composable +fun ProfileScreen( + profileViewModel: ProfileViewModel = viewModel() +) { + val uiState by profileViewModel.uiState.collectAsState() + when (uiState) { + is ProfileUIState.Error -> { + ErrorCard((uiState as ProfileUIState.Error).errorMessage) + } + + is ProfileUIState.AuthState -> { + AuthScreen( + onSignUp = { email, password, displayName -> + profileViewModel.signUp(email, password, displayName) + }, + onSignIn = { email, password -> + profileViewModel.signIn(email, password) + } + ) + } + + is ProfileUIState.ProfileState -> { + val ui = uiState as ProfileUIState.ProfileState + ProfileScreen( + ui.username ?: "User", + ui.reviews.orEmpty(), + ui.watchedMovies.orEmpty(), + ui.favoriteMovies.orEmpty(), + ui.favoriteActors.orEmpty(), + onSignOut = { + profileViewModel.signOut() + } + ) + } + + ProfileUIState.Loading -> LoadingScreen() + } +} + +@Composable +fun ProfileScreen( + name: String, + reviews: List, + watchedMovies: List, + favoriteMovies: List, + favoriteActors: List, + onSignOut: () -> Unit +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .padding(vertical = 16.dp) + .verticalScroll(scrollState) + ) { + Text( + text = "Welcome back, $name!", + style = MaterialTheme.typography.displaySmall, + modifier = Modifier.padding(horizontal = 16.dp) + ) + TextButton( + onClick = { + onSignOut() + }, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text("Sign out") + } + Spacer(modifier = Modifier.height(16.dp)) + + MoviesList( + listTitle = stringResource(R.string.title_watched_movies), + movies = watchedMovies.mapNotNull { + Movie(it.movie.id.toString(), it.movie.imageUrl, it.movie.title, it.movie.rating?.toFloat()) + }, + onMovieClicked = { + // TODO + } + ) + Spacer(modifier = Modifier.height(16.dp)) + + MoviesList( + listTitle = stringResource(R.string.title_favorite_movies), + movies = favoriteMovies.mapNotNull { + Movie(it.movie.id.toString(), it.movie.imageUrl, it.movie.title, it.movie.rating?.toFloat()) + }, + onMovieClicked = { + // TODO + } + ) + Spacer(modifier = Modifier.height(16.dp)) + + ActorsList( + listTitle = stringResource(R.string.title_favorite_actors), + actors = favoriteActors.mapNotNull { + Actor(it.actor.id.toString(), it.actor.name, it.actor.imageUrl) + }, + onActorClicked = { + // TODO + } + ) + Spacer(modifier = Modifier.height(16.dp)) + + ProfileSection(title = "Reviews", content = { ReviewsList(name, reviews) }) + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +fun ProfileSection(title: String, content: @Composable () -> Unit) { + Column { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + content() + } +} + +@Composable +fun ReviewsList( + userName: String, + reviews: List +) { + Column { + // TODO(thatfiredev): Handle cases where the list is too long to display + reviews.forEach { review -> + ReviewCard( + userName = userName, + date = review.reviewDate, + rating = review.rating?.toDouble() ?: 0.0, + text = review.reviewText ?: "" + ) + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileUIState.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileUIState.kt new file mode 100644 index 000000000..660c62abe --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileUIState.kt @@ -0,0 +1,19 @@ +package com.google.firebase.example.dataconnect.feature.profile + +import com.google.firebase.dataconnect.movies.GetUserByIdQuery + +sealed class ProfileUIState { + data object Loading: ProfileUIState() + + data class Error(val errorMessage: String?): ProfileUIState() + + data object AuthState: ProfileUIState() + + data class ProfileState( + val username: String?, + val reviews: List? = emptyList(), + val watchedMovies: List? = emptyList(), + val favoriteMovies: List? = emptyList(), + val favoriteActors: List? = emptyList() + ) : ProfileUIState() +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileViewModel.kt new file mode 100644 index 000000000..22a85ea90 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileViewModel.kt @@ -0,0 +1,101 @@ +package com.google.firebase.example.dataconnect.feature.profile + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.Firebase +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuth.AuthStateListener +import com.google.firebase.auth.UserProfileChangeRequest +import com.google.firebase.auth.auth +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +class ProfileViewModel( + private val auth: FirebaseAuth = Firebase.auth, + private val moviesConnector: MoviesConnector = MoviesConnector.instance +) : ViewModel() { + private val _uiState = MutableStateFlow(ProfileUIState.Loading) + val uiState: StateFlow + get() = _uiState + + private val authStateListener: AuthStateListener + + init { + authStateListener = AuthStateListener { + val currentUser = auth.currentUser + if (currentUser != null) { + displayUser(currentUser.uid) + } else { + _uiState.value = ProfileUIState.AuthState + } + } + auth.addAuthStateListener(authStateListener) + } + + fun signUp( + email: String, + password: String, + displayName: String + ) { + viewModelScope.launch { + try { + val signInResult = auth.createUserWithEmailAndPassword(email, password).await() + signInResult.user?.updateProfile( + UserProfileChangeRequest.Builder() + .setDisplayName(displayName) + .build() + )?.await() + moviesConnector.upsertUser.execute(username = displayName) + } catch (e: Exception) { + _uiState.value = ProfileUIState.Error(e.message) + e.printStackTrace() + } + } + } + + fun signIn(email: String, password: String) { + viewModelScope.launch { + try { + auth.signInWithEmailAndPassword(email, password).await() + } catch (e: Exception) { + _uiState.value = ProfileUIState.Error(e.message) + } + } + } + + fun signOut() { + auth.signOut() + } + + private fun displayUser( + userId: String + ) { + viewModelScope.launch { + try { + val user = moviesConnector.getUserById.execute(id = userId).data.user + _uiState.value = ProfileUIState.ProfileState( + user?.username, + favoriteMovies = user?.favoriteMovies, + watchedMovies = user?.watched, + favoriteActors = user?.favoriteActors, + reviews = user?.reviews + ) + Log.d("DisplayUser", "$user") + } catch (e: Exception) { + _uiState.value = ProfileUIState.Error(e.message) + } + } + } + + override fun onCleared() { + super.onCleared() + auth.removeAuthStateListener(authStateListener) + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/search/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/search/Navigation.kt new file mode 100644 index 000000000..fee3bdf59 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/search/Navigation.kt @@ -0,0 +1,21 @@ +package com.google.firebase.example.dataconnect.feature.search + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable + +const val SEARCH_ROUTE = "search_route" + +fun NavController.navigateToSearch(navOptions: NavOptionsBuilder.() -> Unit) = + navigate(SEARCH_ROUTE, navOptions) + +fun NavGraphBuilder.searchScreen( + +) { + composable(route = SEARCH_ROUTE) { + // TODO: Call composable + } +} + + diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorsList.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorsList.kt new file mode 100644 index 000000000..9e783df8d --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorsList.kt @@ -0,0 +1,107 @@ +package com.google.firebase.example.dataconnect.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +val ACTOR_CARD_SIZE = 64.dp + +/** + * Used to represent an actor in a list UI + */ +data class Actor( + val id: String, + val name: String, + val imageUrl: String +) + +/** + * Displays a scrollable horizontal list of actors. + */ +@Composable +fun ActorsList( + modifier: Modifier = Modifier, + listTitle: String, + actors: List? = emptyList(), + onActorClicked: (actorId: String) -> Unit +) { + Column( + modifier = modifier.padding(horizontal = 16.dp) + .fillMaxWidth() + ) { + Text( + text = listTitle, + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyRow { + items(actors.orEmpty()) { actor -> + ActorTile(actor, onActorClicked) + } + } + } +} + +/** + * Used to display each actor item in the list. + */ +@Composable +fun ActorTile( + actor: Actor, + onActorClicked: (actorId: String) -> Unit +) { + Card( + modifier = Modifier + .padding(end = 8.dp) + .clickable { + onActorClicked(actor.id) + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .sizeIn( + maxWidth = 160.dp, + maxHeight = ACTOR_CARD_SIZE + 16.dp + ) + .padding(8.dp) + .fillMaxWidth() + ) { + AsyncImage( + model = actor.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(end = 8.dp) + .size(ACTOR_CARD_SIZE) + .clip(CircleShape) + ) + Text( + text = actor.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ErrorCard.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ErrorCard.kt new file mode 100644 index 000000000..f8c127124 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ErrorCard.kt @@ -0,0 +1,37 @@ +package com.google.firebase.example.dataconnect.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.firebase.example.dataconnect.R + +@Composable +fun ErrorCard( + errorMessage: String? +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.padding(16.dp) + .fillMaxWidth() + ) { + Text( + text = errorMessage ?: stringResource(R.string.unknown_error), + modifier = Modifier.padding(16.dp) + .fillMaxWidth() + ) + } +} + +@Composable +@Preview +fun ErrorCardPreview() { + ErrorCard("Something went terribly wrong :(") +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/LoadingScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/LoadingScreen.kt new file mode 100644 index 000000000..df9a5a438 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/LoadingScreen.kt @@ -0,0 +1,21 @@ +package com.google.firebase.example.dataconnect.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** + * A screen that displays a loading spinner in the center. + */ +@Composable +fun LoadingScreen() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator() + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MoviesList.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MoviesList.kt new file mode 100644 index 000000000..0be44d7ab --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MoviesList.kt @@ -0,0 +1,103 @@ +package com.google.firebase.example.dataconnect.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +/** + * Used to represent a movie in a list UI + */ +data class Movie( + val id: String, + val imageUrl: String, + val title: String, + val rating: Float? = null +) + +/** + * Displays a scrollable horizontal list of movies. + */ +@Composable +fun MoviesList( + modifier: Modifier = Modifier, + listTitle: String, + movies: List? = emptyList(), + onMovieClicked: (movieId: String) -> Unit +) { + Column( + modifier = modifier.padding(horizontal = 16.dp) + .fillMaxWidth() + ) { + Text( + text = listTitle, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + LazyRow { + items(movies.orEmpty()) { movie -> + MovieTile( + movie = movie, + onMovieClicked = { + onMovieClicked(movie.id.toString()) + } + ) + } + } + } +} + +/** + * Used to display each movie item in the list. + */ +@Composable +fun MovieTile( + modifier: Modifier = Modifier, + tileWidth: Dp = 150.dp, + movie: Movie, + onMovieClicked: (movieId: String) -> Unit +) { + Card( + modifier = modifier + .padding(4.dp) + .sizeIn(maxWidth = tileWidth) + .clickable { + onMovieClicked(movie.id) + }, + ) { + AsyncImage( + model = movie.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(9f / 16f) + ) + Text( + text = movie.title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(8.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + movie.rating?.let { + Text( + text = "Rating: $it", + modifier = Modifier.padding(bottom = 8.dp, start = 8.dp, end = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + } + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ReviewCard.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ReviewCard.kt new file mode 100644 index 000000000..4df00e3c0 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ReviewCard.kt @@ -0,0 +1,68 @@ +package com.google.firebase.example.dataconnect.ui.components + +import android.widget.Space +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.Date +import java.util.Locale + + +@Composable +fun ReviewCard( + userName: String, + date: Date, + rating: Double, + text: String +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Column( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.secondaryContainer) + .padding(16.dp) + ) { + Text( + text = userName, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Row( + modifier = Modifier.padding(bottom = 8.dp) + ) { + Text( + text = SimpleDateFormat( + "dd MMM, yyyy", + Locale.getDefault() + ).format(date) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "Rating: ") + Text(text = "$rating") + } + Text( + text = text, + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ToggleButton.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ToggleButton.kt new file mode 100644 index 000000000..0d73abb8b --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ToggleButton.kt @@ -0,0 +1,36 @@ +package com.google.firebase.example.dataconnect.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun ToggleButton( + iconEnabled: ImageVector, + iconDisabled: ImageVector, + textEnabled: String, + textDisabled: String, + isEnabled: Boolean, + onToggle: (newValue: Boolean) -> Unit +) { + val onClick = { + onToggle(!isEnabled) + } + if (isEnabled) { + FilledTonalButton(onClick) { + Icon(iconEnabled, textEnabled) + Text(textEnabled, modifier = Modifier.padding(horizontal = 4.dp)) + } + } else { + OutlinedButton(onClick) { + Icon(iconDisabled, textDisabled) + Text(textDisabled, modifier = Modifier.padding(horizontal = 4.dp)) + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Color.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Color.kt new file mode 100644 index 000000000..e4c2b612a --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.google.firebase.example.dataconnect.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Theme.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Theme.kt new file mode 100644 index 000000000..b327e3af2 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.google.firebase.example.dataconnect.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun FirebaseDataConnectTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Type.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Type.kt new file mode 100644 index 000000000..deec73173 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.google.firebase.example.dataconnect.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/dataconnect/app/src/main/res/drawable/firebase_data_connect.xml b/dataconnect/app/src/main/res/drawable/firebase_data_connect.xml new file mode 100644 index 000000000..e2106454e --- /dev/null +++ b/dataconnect/app/src/main/res/drawable/firebase_data_connect.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/dataconnect/app/src/main/res/drawable/ic_launcher_background.xml b/dataconnect/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/dataconnect/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..8eeb203f9 --- /dev/null +++ b/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..8eeb203f9 --- /dev/null +++ b/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher.png b/dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..40f804ae7 Binary files /dev/null and b/dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.png b/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..64276c653 Binary files /dev/null and b/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..aa4fd7ba1 Binary files /dev/null and b/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..8abe93867 Binary files /dev/null and b/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b7be5d464 Binary files /dev/null and b/dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/dataconnect/app/src/main/res/values/colors.xml b/dataconnect/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/dataconnect/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/dataconnect/app/src/main/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..15c39cb40 --- /dev/null +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -0,0 +1,45 @@ + + Firebase Data Connect + An unknown error occurred + + + Movies + Genres + Search + Profile + + + Top 10 Movies + Latest Movies + + + %s Movies + Most Popular + Most Recent + + + Couldn\'t find movie in the database + Description not available + Mark as watched + Watched + Add to favorites + Favorite + Main Actors + Supporting Actors + User Reviews + Write your review + Submit Review + + + Couldn\'t find actor in the database + Biography not available + Main Roles + Supporting Roles + + + Watched Movies + Favorite Movies + Favorite Actors + Reviews + + \ No newline at end of file diff --git a/dataconnect/app/src/main/res/values/themes.xml b/dataconnect/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..761a1fca9 --- /dev/null +++ b/dataconnect/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +