From 984eefae210fe9d0227767920a200cd169c3f3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ros=C3=A1rio=20P=2E=20Fernandes?= Date: Wed, 2 Oct 2024 21:57:29 +0100 Subject: [PATCH] Add Firebase Data Connect quickstart (#1671) * Initial commit for dataconnect sample app * chore: add Firebase dependencies * add first schema * update with new schema * add kotlin serialization and androidx.lifecycle * firebase init * setup bottom navigation * feat: create movies screen * add firebase data connect logo * add top 10 movies and latest to the MoviesScreen * create GenresScreen * create GenreDetailScreen * create MovieDetailScreen * chore: bump gradle plugins * test: delete test modules * chore: add compose compiler * chore: move location from firebase.json to dataconnect.yaml * feat: create an auth screen * create user profile favorites list and sign out button * ellipsize text in Movie Tile * refactor: reuse MovieTile UI component * delete UserRepository * refactor: delete MovieRepository * refactor: delete the data package * feat: list actors in the movie details screen * refactor: movie review list to bottom of profile screen * feat: add reviews to movie detail screen * refactor: dateformat for reviews * feat: mark movies as watched and/or favorite * refactor: move AuthScreen into its own file * docs: add a README file * feat: support reviewing movies :) * feat: create actor details screen * refactor: make actor list reusable * refactor: make movies list reusable * refactor: make error and loading reusable components * refactor: make toggle button reusable * refactor: make actor details screen stateless * refactor: move UserReviews into its own dedicated file * refactor: turn actor tile into a card * docs: update the getting started * docs: update README.md * docs: update README.md * add data_seed.gql and useEmulator() * Update README.md Co-authored-by: Marina Coelho * refactor: use Navigation type safety * refactor: delete extra uiState * refactor favoriteActor for readability * refactor: make error messages nullable and add default error message * refactor: ensure a user is logged in before marking as favorite * update GenreDetailScreen * chore: use v1beta * ci: remove Data Connect from build * chore: upgrade to 16.0.0-beta01 * ci: remove -i parameter * ci: output file * ci: overwrite instead of append * chore: agp 8.7.0 * testing * revert ci changes * ci: add python script to remove fdc * docs: update README.md * docs: remove deploy step --------- Co-authored-by: Marina Coelho --- .github/workflows/android.yml | 3 + build.gradle.kts | 1 + copy_mock_google_services_json.sh | 1 + dataconnect/.gitignore | 17 + dataconnect/README.md | 73 +++ dataconnect/app/.gitignore | 1 + dataconnect/app/build.gradle.kts | 85 +++ dataconnect/app/proguard-rules.pro | 21 + dataconnect/app/src/main/AndroidManifest.xml | 31 + .../example/dataconnect/MainActivity.kt | 135 +++++ .../feature/actordetail/ActorDetailScreen.kt | 149 +++++ .../feature/actordetail/ActorDetailUIState.kt | 18 + .../actordetail/ActorDetailViewModel.kt | 85 +++ .../feature/genredetail/GenreDetailScreen.kt | 76 +++ .../feature/genredetail/GenreDetailUIState.kt | 16 + .../genredetail/GenreDetailViewModel.kt | 44 ++ .../feature/genres/GenresScreen.kt | 44 ++ .../feature/moviedetail/MovieDetailScreen.kt | 206 +++++++ .../feature/moviedetail/MovieDetailUIState.kt | 18 + .../moviedetail/MovieDetailViewModel.kt | 137 +++++ .../feature/moviedetail/UserReviews.kt | 86 +++ .../feature/movies/MoviesScreen.kt | 66 ++ .../feature/movies/MoviesUIState.kt | 16 + .../feature/movies/MoviesViewModel.kt | 32 + .../dataconnect/feature/profile/AuthScreen.kt | 94 +++ .../feature/profile/ProfileScreen.kt | 173 ++++++ .../feature/profile/ProfileUIState.kt | 19 + .../feature/profile/ProfileViewModel.kt | 101 ++++ .../dataconnect/feature/search/Navigation.kt | 21 + .../dataconnect/ui/components/ActorsList.kt | 107 ++++ .../dataconnect/ui/components/ErrorCard.kt | 37 ++ .../ui/components/LoadingScreen.kt | 21 + .../dataconnect/ui/components/MoviesList.kt | 103 ++++ .../dataconnect/ui/components/ReviewCard.kt | 68 +++ .../dataconnect/ui/components/ToggleButton.kt | 36 ++ .../example/dataconnect/ui/theme/Color.kt | 11 + .../example/dataconnect/ui/theme/Theme.kt | 58 ++ .../example/dataconnect/ui/theme/Type.kt | 34 ++ .../res/drawable/firebase_data_connect.xml | 18 + .../res/drawable/ic_launcher_background.xml | 170 ++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5439 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2765 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 7233 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 12140 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 18036 bytes .../app/src/main/res/values/colors.xml | 10 + .../app/src/main/res/values/strings.xml | 45 ++ .../app/src/main/res/values/themes.xml | 5 + .../app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + dataconnect/build.gradle.kts | 7 + .../dataconnect/connectors/connector.yaml | 13 + .../dataconnect/connectors/mutations.gql | 113 ++++ .../dataconnect/connectors/queries.gql | 565 ++++++++++++++++++ dataconnect/dataconnect/data_seed.gql | 546 +++++++++++++++++ dataconnect/dataconnect/dataconnect.yaml | 11 + dataconnect/dataconnect/schema/schema.gql | 103 ++++ dataconnect/firebase.json | 5 + dataconnect/gradle.properties | 23 + dataconnect/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + dataconnect/gradlew | 185 ++++++ dataconnect/gradlew.bat | 89 +++ dataconnect/settings.gradle.kts | 31 + gradle/libs.versions.toml | 46 ++ mock-google-services.json | 19 + scripts/ci_remove_fdc.py | 8 + settings.gradle.kts | 1 + 70 files changed, 4307 insertions(+) create mode 100644 dataconnect/.gitignore create mode 100644 dataconnect/README.md create mode 100644 dataconnect/app/.gitignore create mode 100644 dataconnect/app/build.gradle.kts create mode 100644 dataconnect/app/proguard-rules.pro create mode 100644 dataconnect/app/src/main/AndroidManifest.xml create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MainActivity.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailScreen.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailUIState.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailViewModel.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailScreen.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailUIState.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailViewModel.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/GenresScreen.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailScreen.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailUIState.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailViewModel.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/UserReviews.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesScreen.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesUIState.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesViewModel.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/AuthScreen.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileUIState.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileViewModel.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/search/Navigation.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorsList.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ErrorCard.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/LoadingScreen.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MoviesList.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ReviewCard.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ToggleButton.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Color.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Theme.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Type.kt create mode 100644 dataconnect/app/src/main/res/drawable/firebase_data_connect.xml create mode 100644 dataconnect/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 dataconnect/app/src/main/res/values/colors.xml create mode 100644 dataconnect/app/src/main/res/values/strings.xml create mode 100644 dataconnect/app/src/main/res/values/themes.xml create mode 100644 dataconnect/app/src/main/res/xml/backup_rules.xml create mode 100644 dataconnect/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 dataconnect/build.gradle.kts create mode 100644 dataconnect/dataconnect/connectors/connector.yaml create mode 100644 dataconnect/dataconnect/connectors/mutations.gql create mode 100644 dataconnect/dataconnect/connectors/queries.gql create mode 100644 dataconnect/dataconnect/data_seed.gql create mode 100644 dataconnect/dataconnect/dataconnect.yaml create mode 100644 dataconnect/dataconnect/schema/schema.gql create mode 100644 dataconnect/firebase.json create mode 100644 dataconnect/gradle.properties create mode 100644 dataconnect/gradle/wrapper/gradle-wrapper.jar create mode 100644 dataconnect/gradle/wrapper/gradle-wrapper.properties create mode 100755 dataconnect/gradlew create mode 100644 dataconnect/gradlew.bat create mode 100644 dataconnect/settings.gradle.kts create mode 100644 gradle/libs.versions.toml create mode 100644 scripts/ci_remove_fdc.py diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 55dd574bcf..8a635beff6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -18,6 +18,9 @@ jobs: uses: gradle/gradle-build-action@v2 - name: Check Snippets run: python scripts/checksnippets.py + # TODO(thatfiredev): remove this once github.com/firebase/quickstart-android/issues/1672 is fixed + - name: Remove Firebase Data Connect from CI + run: python scripts/ci_remove_fdc.py - name: Copy mock google_services.json run: ./copy_mock_google_services_json.sh - name: Build with Gradle (Pull Request) diff --git a/build.gradle.kts b/build.gradle.kts index 319ded571e..0d80477aec 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.1" 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 56d42eb4cd..c33f738b3b 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 0000000000..5b4ab9b06c --- /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 0000000000..73160a9df5 --- /dev/null +++ b/dataconnect/README.md @@ -0,0 +1,73 @@ +# 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). + +### 0. Prerequisites +- Latest version of [Android Studio](https://developer.android.com/studio) +- Latest version of [Visual Studio Code](https://code.visualstudio.com/) +- The [Firebase Data Connect VS Code Extension](https://marketplace.visualstudio.com/items?itemName=GoogleCloudTools.firebase-dataconnect-vscode) + +### 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`. + Click **Download google-services.json** to obtain your Firebase Android config file. + +### 2. Cloning the repository + +1. Clone this repository to your local machine: + ```sh + git clone https://github.com/firebase/quickstart-android.git + ``` + +2. Move the `google-services.json` config file (downloaded in the previous step) into the + `quickstart-android/dataconnect/app/` directory. + +### 3. Open in Visual Studio Code (VS Code) + +1. Open the `quickstart-android/dataconnect` directory in VS Code. +2. Click on the Firebase Data Connect icon on the VS Code sidebar to load the Extension. + a. Sign in with your Google Account if you haven't already. +3. Click on "Connect a Firebase project" and choose the project where you have set up Data Connect. +4. Click on "Start Emulators" - this should generate the Kotlin SDK for you and start the emulators. + +### 4. Populate the database +In VS Code, open the `quickstart-android/dataconnect/dataconnect/data_seed.gql` file and click the + `Run (local)` button at the top of the file. + +If you’d like to confirm that the data was correctly inserted, +open `quickstart-android/dataconnect/connectors/queries.gql` and run the `ListMovies` query. + +### 5. Running the app + +Press the Run button in Android Studio to run the sample app on your device. diff --git a/dataconnect/app/.gitignore b/dataconnect/app/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /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 0000000000..3d24730fa4 --- /dev/null +++ b/dataconnect/app/build.gradle.kts @@ -0,0 +1,85 @@ +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) + implementation(libs.kotlinx.serialization.core) + + 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 0000000000..481bb43481 --- /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 0000000000..230081caf0 --- /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 0000000000..632b9ac786 --- /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 0000000000..0441dbdc63 --- /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 0000000000..7f8ca2ff52 --- /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 0000000000..99df5ca7eb --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailViewModel.kt @@ -0,0 +1,85 @@ +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) { + // TODO(thatfiredev): hide the button if there's no user logged in + val uid = firebaseAuth.currentUser?.uid ?: return + viewModelScope.launch { + try { + if (newValue) { + moviesConnector.addFavoritedActor.execute(UUID.fromString(actorId)) + } else { + moviesConnector.deleteFavoriteActor.execute( + userId = 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 0000000000..79fddb640e --- /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) { + is 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 0000000000..442776c3c0 --- /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 0000000000..2d327d1015 --- /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 0000000000..efa98a37b5 --- /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 0000000000..9ca23be697 --- /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 0000000000..1f6e04028d --- /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 0000000000..90bfd2dfac --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailViewModel.kt @@ -0,0 +1,137 @@ +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) { + // TODO(thatfiredev): hide the button if there's no user logged in + val uid = firebaseAuth.currentUser?.uid ?: return + 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 = 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) { + // TODO(thatfiredev): hide the button if there's no user logged in + val uid = firebaseAuth.currentUser?.uid ?: return + 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 = 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) { + // TODO(thatfiredev): hide the button if there's no user logged in + if (firebaseAuth.currentUser?.uid == null) return + 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 0000000000..1ae3723602 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/UserReviews.kt @@ -0,0 +1,86 @@ +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 = { + if (!reviewText.isNullOrEmpty()) { + 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 0000000000..c760902649 --- /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 0000000000..3f428b2e57 --- /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 0000000000..932dca76ee --- /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 0000000000..a9cf6e1df9 --- /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 0000000000..c020552b65 --- /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 0000000000..660c62abe1 --- /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 0000000000..22a85ea906 --- /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 0000000000..fee3bdf593 --- /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 0000000000..9e783df8df --- /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 0000000000..f8c1271244 --- /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 0000000000..df9a5a438d --- /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 0000000000..0be44d7abd --- /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 0000000000..4df00e3c05 --- /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 0000000000..0d73abb8b9 --- /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 0000000000..e4c2b612af --- /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 0000000000..b327e3af29 --- /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 0000000000..deec731739 --- /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 0000000000..e2106454ef --- /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 0000000000..07d5da9cbf --- /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 0000000000..8eeb203f95 --- /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 0000000000..8eeb203f95 --- /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 0000000000000000000000000000000000000000..40f804ae7dfe13bf5e8439bbbaf16d7db792f971 GIT binary patch literal 5439 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..64276c653d2509aad307e5cf1587bd5a930b7f9e GIT binary patch literal 2765 zcmV;;3NrPHP)U~L6+UkWQ4*=5AMyhlr|Jh%$*K|v5VHi#5-=eqge9alOIXSl<0TkFD0@=k5^&6F z45pB#am^AEwN0ZaYSk}UTGN(F^@p^!F z&Uo(gJnwVPd)`Yl9K$gj!@*1{Rz~U3LsS%fQ`LlascXR!73j)WcHk$<_AOU|jwR|^ z+k92il0`*LGbx>kl=pZ3=fNjvlzoZ{ma4rHaFQ75BWZxcfE5fsKlNs}$e- zvEud@6}Qe)+%jA7jZDQ&lT}^gBr5#+2^!`7-ETbre574P1=eZhkDpK+JEFMvZN-r{ z6+?XvZdDBR8S1ldnPRBVP@h}oDsG+$@Y57upRBksLvh0d<*%PW1z$ZuBfa?8`9vjlys(ld*2}muB2toDP=iO-G*&!C~uX+K8HZyfKfmEQxGVI`gtI* z*4XDJ?9=`N2s~#9$O_zmg_@pmNlZ4`G{wzll+`qqmU-`gPr=71n~wuL6qmH&c^k@G z03So)_PzlEyW#)s@V{%H0PN$-#R@Ej|Am0804&rp3nZqSLKY&)l=st3jWo|vR&&-6 ztpu0RXf9Jh24JVmqz%toC|V2PW3WyyI!|OL2$X<8Aqb%R1XdXWdIc7Mgw#Nmy9z== zfF=^}Tb?zawW845p3~nC`d+-pnviZ2-%qs`Kw7YgjY72&iT80_kHI>Tsx>LA>3ZmPH zqW~90cj<-4dv_VY$=qo^i=cSJuusos6fp|8(q^hOqBt z^S^=R$XBSqi|dZUznVt%Y$iXFL+C0YSR-77++viDl%!;_0WaSRz6uh19_Pq=8@cD` zBK9wTNZY7mq3K2)3k(_gn^0Ol7oz?g1G@7SBxzhke42ZWUPJf?lgZ zb8wUSO)cCOD)i#}#*jHBkV|#`T*8kdWhrrkz)E9{#Emqm_2z~`E|~WaJCQDSVquP4 z*vhfLT+$@$j(YCQPt(@W_7Kpbwg8vk#P4+*wY3N#2ofqXD=XHF>`-+wZ+USJ7;TFv z{pMm~olD60GND#Zia+J%?s9jo0f2338gTrdpXuv?l_R+K4;6;QO^_%`GoVF`giS<< z^)B!NW5ozYkqBCHG*NSVq}x%t7w3S{mQ6)SmTaF-y2!L|7xsBJS!_ZmX@QaKbhW}a zaSrbK5qAo~ek2;WyZC$DwZ`~cubS8~-_%z>;L;$_o@3Z!V$$*6q9QNO0i$g$Rkvml z>zqq^7u9VJU88-zO9llPC>g_{bDO!RVg(1*J)&18RJp=<|KDH2Zuc7!t3cw9hD7vI zF|775hfmJq==mHD?|)1;2C<6nbWl77;Bf$t{|t$}#=@v#9ac47oC8MlOuC9>owJbc zCZl_Imf2{+AC+l9iEx5G?%JIJ68CZZAD`-Lk@xfaI?CD+94gD?(19`R_!|2D5d|Or zxLCL27_1WCZ-C1t=>liFT<`wZyf_Dp8`G#2$;r$lJ%lB8&A^^)eQ&GdI(MJS@fm$v|Bl&aCxyMDUwojib#5Q03qD-E zRlkq_{kPil(y>Cjzn?&SOE_PYYVh_!Gpfj}B?F^#9EkzasZ_QX=YY{Tk%EmG#5yN} zh~y-9YQz*7`dyX2sc-unGsD1I(a*2wYk`t+y3hM_A7HEDs=gMkT&fE;wC5WLd|Ar& z+wQzoZiYt2$c_n^8D|f!8U%rc3Dnv+fpE|O0m-L@jG5hq$j$BZ^-Znz>)drLk3%0C z3pwF#ZeR4E?p8Ok3SGON(jIrZ+c>!85p)|^sQLC5a;h`>fp-9&0p2m-na>TvLXG3- zYQs1XNaT2va(WUX*Fhv$)4zxl5&nGzI@Ac=YNf?SKRKkY$3H)#+hye7V~HPIyE&BbNtgpK%#B+816ih&wh%DV=29U3_yEx?5!kpam!I~nrXA`D?p;XUIUJPR)KTz2;D;M z8-Fv`_I{UR*k$F6Ast7o!vY@NhWml1RPd5_K1#?i2f%f=BakEkJ2eUksfolY%rF~X z$Lc;3u|Hqr=*8Di1Q8U759;5~@qMoPunr$d0bhwcL$`vjrojz!5&&GNK|kqcDv4xG zm<}bYAnjFbByCkj58t{pJxV77#Tu*0<=8HxzJr!2$5hru3y_sG5P0Ta4|1HAkSAsU z>`#*LTB#oav*F2(-SVu=P8a|e5c`%r$e|NUI9ij(kbUSf;FF}2PDnlMco* z!JoYi_zUF6-@zYfBmlNxKpWi&fu)35h%iDB#$>{gl%PIAeE=&*9!FLnPkX?>Polbj zm%O_prGz~01K8SIz~5paGj80NZV0r^BkTnr@G=Oj0)bZv?UOL9LH$yIdIx#zHsIg$ zfWJt!@?gP>Yfu5OqX9a2OFNa#O?K%F4}sYr@H_}C#7Qp497%ifN!!PI1E3C(v5xru zcHnC?aOO)C{A5wce?6} zbrMA@;NNw@i{BIAFO!8Fng?5nz<99?psl3xdOk`~KhvBYgdN z>qyZi!1ooasLuQWz+V~SM~jK5A5Yl;9@OCVgz6Trs6PYt5%oRx2^E;))t%=E8Q)it zv5kOF`28~S3T*S?5G(Huc?1sNwc0kKyc~Gk;?>>f1$hVn`>cTa8(^PesJ{>3M15(Q z;`?)CY$M=}WkmVfA@lry3+YZ!z5urI3wUjvEaUx-MXvg5+~VDlqJF|U(kjI^2D~5O z3;3&h>^653UuV)NC|?Nc)M@KT>xB0MD^a|n{uZ-wCDoVBN_;Qf<^slp%T##Ro77N- z%uY~09qJbaa;XODU+dXSf#~aG+Xu*o`T?ka4P!!$OqDX24SKd2j^P-N|Ihdj27{PU T({?pW00000NkvXXu0mjfu=P;| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..aa4fd7ba1708e64d30918dede5a9fb2679a22348 GIT binary patch literal 7233 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b7be5d464e06ab7f7797df2b8ce122e72b588f21 GIT binary patch literal 18036 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 literal 0 HcmV?d00001 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 0000000000..f8c6127d32 --- /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 0000000000..15c39cb40e --- /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 0000000000..761a1fca9d --- /dev/null +++ b/dataconnect/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +