From 2648e40810895a2477026e535c54f427a69f04ea Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 8 May 2024 19:36:12 +0100 Subject: [PATCH 01/63] Initial commit for dataconnect sample app --- copy_mock_google_services_json.sh | 1 + dataconnect/.gitignore | 15 ++ dataconnect/app/.gitignore | 1 + dataconnect/app/build.gradle.kts | 69 +++++++ dataconnect/app/proguard-rules.pro | 21 ++ .../dataconnect/ExampleInstrumentedTest.kt | 24 +++ dataconnect/app/src/main/AndroidManifest.xml | 28 +++ .../example/dataconnect/MainActivity.kt | 47 +++++ .../example/dataconnect/ui/theme/Color.kt | 11 ++ .../example/dataconnect/ui/theme/Theme.kt | 58 ++++++ .../example/dataconnect/ui/theme/Type.kt | 34 ++++ .../drawable-v24/ic_launcher_foreground.xml | 30 +++ .../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.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../app/src/main/res/values/colors.xml | 10 + .../app/src/main/res/values/strings.xml | 3 + .../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 ++ .../example/dataconnect/ExampleUnitTest.kt | 17 ++ dataconnect/build.gradle.kts | 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 | 31 +++ mock-google-services.json | 19 ++ settings.gradle.kts | 1 + 41 files changed, 978 insertions(+) create mode 100644 dataconnect/.gitignore 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/androidTest/java/com/google/firebase/example/dataconnect/ExampleInstrumentedTest.kt 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/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-v24/ic_launcher_foreground.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.webp create mode 100644 dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp 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/app/src/test/java/com/google/firebase/example/dataconnect/ExampleUnitTest.kt create mode 100644 dataconnect/build.gradle.kts 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 diff --git a/copy_mock_google_services_json.sh b/copy_mock_google_services_json.sh index 9b275d413..1eddf3b18 100755 --- a/copy_mock_google_services_json.sh +++ b/copy_mock_google_services_json.sh @@ -12,6 +12,7 @@ cp mock-google-services.json auth/app/google-services.json cp mock-google-services.json config/app/google-services.json cp mock-google-services.json crash/app/google-services.json cp mock-google-services.json database/app/google-services.json +cp mock-google-services.json dataconnect/app/google-services.json cp mock-google-services.json dynamiclinks/app/google-services.json cp mock-google-services.json firestore/app/google-services.json cp mock-google-services.json functions/app/google-services.json diff --git a/dataconnect/.gitignore b/dataconnect/.gitignore new file mode 100644 index 000000000..aa724b770 --- /dev/null +++ b/dataconnect/.gitignore @@ -0,0 +1,15 @@ +*.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 diff --git a/dataconnect/app/.gitignore b/dataconnect/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/dataconnect/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/dataconnect/app/build.gradle.kts b/dataconnect/app/build.gradle.kts new file mode 100644 index 000000000..2463e0da5 --- /dev/null +++ b/dataconnect/app/build.gradle.kts @@ -0,0 +1,69 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) +} + +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}" + } + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + 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) + 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) +} \ No newline at end of file diff --git a/dataconnect/app/proguard-rules.pro b/dataconnect/app/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/dataconnect/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/dataconnect/app/src/androidTest/java/com/google/firebase/example/dataconnect/ExampleInstrumentedTest.kt b/dataconnect/app/src/androidTest/java/com/google/firebase/example/dataconnect/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..2ab99c2c8 --- /dev/null +++ b/dataconnect/app/src/androidTest/java/com/google/firebase/example/dataconnect/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.google.firebase.example.dataconnect + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.google.firebase.example.dataconnect", appContext.packageName) + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/AndroidManifest.xml b/dataconnect/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b5ed84e4f --- /dev/null +++ b/dataconnect/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> + + <application + android:allowBackup="true" + android:dataExtractionRules="@xml/data_extraction_rules" + android:fullBackupContent="@xml/backup_rules" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/Theme.FirebaseDataConnect" + tools:targetApi="31"> + <activity + android:name=".MainActivity" + android:exported="true" + android:label="@string/app_name" + android:theme="@style/Theme.FirebaseDataConnect"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> + +</manifest> \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MainActivity.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MainActivity.kt new file mode 100644 index 000000000..03e2481f7 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MainActivity.kt @@ -0,0 +1,47 @@ +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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.google.firebase.example.dataconnect.ui.theme.FirebaseDataConnectTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + FirebaseDataConnectTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Greeting( + name = "Android", + modifier = Modifier.padding(innerPadding) + ) + } + } + } + } +} + +@Composable +fun Greeting(name: String, modifier: Modifier = Modifier) { + Text( + text = "Hello $name!", + modifier = modifier + ) +} + +@Preview(showBackground = true) +@Composable +fun GreetingPreview() { + FirebaseDataConnectTheme { + Greeting("Android") + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Color.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Color.kt new file mode 100644 index 000000000..e4c2b612a --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.google.firebase.example.dataconnect.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Theme.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Theme.kt new file mode 100644 index 000000000..b327e3af2 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.google.firebase.example.dataconnect.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun FirebaseDataConnectTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Type.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Type.kt new file mode 100644 index 000000000..deec73173 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.google.firebase.example.dataconnect.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/dataconnect/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/dataconnect/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/dataconnect/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> + <aapt:attr name="android:fillColor"> + <gradient + android:endX="85.84757" + android:endY="92.4963" + android:startX="42.9492" + android:startY="49.59793" + android:type="linear"> + <item + android:color="#44000000" + android:offset="0.0" /> + <item + android:color="#00000000" + android:offset="1.0" /> + </gradient> + </aapt:attr> + </path> + <path + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" + android:strokeWidth="1" + android:strokeColor="#00000000" /> +</vector> \ No newline at end of file diff --git a/dataconnect/app/src/main/res/drawable/ic_launcher_background.xml b/dataconnect/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/dataconnect/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#3DDC84" + android:pathData="M0,0h108v108h-108z" /> + <path + android:fillColor="#00000000" + android:pathData="M9,0L9,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,0L19,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,0L29,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,0L39,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,0L49,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,0L59,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,0L69,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,0L79,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M89,0L89,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M99,0L99,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,9L108,9" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,19L108,19" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,29L108,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,39L108,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,49L108,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,59L108,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,69L108,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,79L108,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,89L108,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,99L108,99" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,29L89,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,39L89,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,49L89,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,59L89,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,69L89,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,79L89,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,19L29,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,19L39,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,19L49,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,19L59,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,19L69,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,19L79,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> +</vector> diff --git a/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> \ No newline at end of file diff --git a/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> + <monochrome android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> \ No newline at end of file diff --git a/dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cm<R)d19?=)E<{+g@mp0C)CAX%7ksNnpX=jPlJEkqD9%o*fC(U7iySOYHHS zCLH@bXPyI|^Z)Mc^PG7Oc+NfBJO`d7p5;U`M53Wbd!w|M(MUoNRc2m{@^!wFKzL?& zx_RAc-^9Azxo%DmXW^9GV0~;n_G9&dym)|QrMEx!y_F=oDunn;1y)cvAc6z{0MHiz zodGIH07w8nF%*bGq9Gv`YHnm80|d1T$uW=u4vGV%tX#>e5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!<r*4*+?g*sysFgiN}}d!-T(>jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#<RA5Hg_mG&BZS>9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG<W<SKpuS2eaDGkL-J- z!+&UV_N0e<DV*|igwqdC*l{lj0T&&`uq=ycU@f9x0i8jzLa(y*X(YV?PCtUw;ks}p z4zFn7N(-OG^(9ot1ZhYISOWe9?+l%f6v41n+j<OM$_uJgNP?ZJy}hEMMH=;WiG4I` zs(bIWwSD<LJnAN(E(Xjr4k(^UYF37F_3f{;E%%FEa&I3I0GbdH@{pD5$m+1PN5CW) zyZ&*9o#8wstdx@^rci;0B4BP2+H4Y<KbJI5L(bXG(k@`Kp=d>8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqro<oln8GL@_6LPC)kg1!M)Y!|NCn7b*0sG zEN=&c2xMM<>a($ne7EUK;#3V<N-jQ7j($tREa0F&-HzYCQtR#fTCZMRN*ZSm;T7a+ zuxa$}zDL8R9wGYkHb$+gJoiM@z+u{u7a_VUBwtd)bPzHxH}C`W=^2PsBr`s7taBMG zm#Ss=-o`)W8%%x%>YkXaew%Kh^3OrMht<?zOY6P}#rBhn_hrWY$_P`{#CBR9w?+E6 zt7r#NN-tjxFY{9q75P<|L<ZJBHwn4FhG(&i>jYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)<UE%?IC0Du41FrE~F_qc8nOq>P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9<vs%8>j@06@(!<!eaZcF<_le1MVaYMg=gRy*f2#IaBH-mJIpy+L z=Gsbhd6=3>{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuH<!ckn_w-(t15itRHmqN0O$B3XH(E|jyV^QXq8=yM`Q**vy zpEpgQd+no=J<Tlv&+_>B%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}<p z;bEy|mw1;}P&gp|0ssKe4*;D3Dlh;r06r}ehC-pC4mGy`3;|+W04+#B4r_x~@mHHy z)H}bD|I2-n_L$pW;*I)~?=#N<)`92&<$3IR`#<SH6@I&FRQa6xBmQ5wPwJ2PAI(ne z$L2Yb@JHxb`+bLk*AjR$^`b?pr|?!6=+AboIQ2D-p)UI7x(J0|5(5~ur$_+)`>C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ<hjKiZs6mOSFB&+cIl`GV$93-<ciUjF#*1^<p~gh ziQ_{)r0dA7$It&Fe=obxu8n!+elxmgqxPbUL!FxW0;AOfqz@8JOz9Qbm)m-9!^7D) z480@BoIIb<oT``+rVla8L)8fXO&6}3P9n4v$`6WG<DUNWuKb9J9rUsAn7d-_YWT^U z{NXl@OAPIJ!>3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^Q<WhKYr9rXr6*~Tmpuq6NjnD6;;NNBGIg-1ZvfACQ4{ocrwM0)?`oL2ts zCXY5KT@`(ir63J0?%+_(-dDgf<6R$u{lCdy6Zi5d+Bf;1OXyD;xe3#Gug*&T|0o41 zD8;$|JvUv&@vsLIH&C5+S{!k&{~Z54^y@9r>X7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!T<mO literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..62b611da081676d42f6c3f78a2c91e7bcedddedb GIT binary patch literal 1772 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 O>FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 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 literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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 literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..28d4b77f9f036a47549d47db79c16788749dca10 GIT binary patch literal 2884 zcmV-K3%m4ENk&FI3jhFDMM6+kP&il$0000G0001w0055w06|PpNY()W00EFA*|uso z=UmW3;Ri7@GcyiBW{ey$jes55b5S`|ZVZ{(x$xch{z?D+^{yErVgleVwa9qvGt40r z42;MG=7<0QySlzE=Ig6%01!FBK^$Fsxe@Hfe6aCy?Wh2r0~}@_lQAF90oTUi0FhEr z#(<GhM2CTE5->*;kTC<I6%bkw<P!?WpaDI10CfmFPKu1G=p;%VjNdWOy5JfR^IubB zmWbY`5VOa4^Co4?lA*0$&a)>(r!tQk6;gxj4h%FdHAt(^M3YvYj(!tOeN)+Hvj6+< zzyJRG?^lZfWuR#t!tUKP&(?%3v&Zd<cNCc=qEAh>$R2YN>lB(Lq`OInY48%4%yTv2 zYe1{G`3)(PDEio5Y@-I5tUf`c%%O<RR41hl;a9a>CJMtSW56g3iEg%3`$7XSJJHyA z<|7&N)5Xrlgv~%BO24eFd;Hd;uiK%D`EdK|quUeRZDqbh9l)%j%J#0lfrZumvA<_w zu&=AVvdChf6}eqh(bUz`(`Ue*p01{fBAcTgKyDYLs_I+YyJEk+rM@avU~>fB$n)HS zM7pfJydu`i%gfS<LN|{i=ttzza$L{zW8L#y$C4ZoauSg-Za~HmA&1d`@TXE%P&gn! z2><{PF94kZDv$t>06sAkheDzu40NJ$5CMW%n^Lls?8^p^QGWURbKu3ZduZQZ((s2? zzE`}<{<HFD`<Klx_&=&1@jStQ!GBWei{=~oAN3#D|91cP9<hA@KTtaAf1`iE_5l7p z{_oR6_8;Nf_OJGn;4k(%=}+Tjk)AMkcQv~rTZfv5m>;Zt7<$C|9R8A~DJ~@%x>TfP zF>TX8)@v|t)q4GjRt<}5s6hLHwRel7>V@&r-O|Av(yh;Q1A{E>Ir>p+%dHD|=l+lT zpr(Dg&>#Nu=!)6bCLr-ZS%|;h)Ij$+e@r8_{qO19QvDe=&1tmpY*0lcA^Cc-#{9fQ z<~$*<&P$Q<_jy#<$40PMofM7aQ}C=jphI`4kLg}Z7CIN#<BNcF_3BC#1Or(Pa6X(x zm*>26D{-4v-_CA-LiE@(%{y!BzsU%gG`Q?sjLUf%qFSl0y)2#ae*+EI><i9^|McQ6 z&u-wt#o7y!{eZ1mUVdD*Za<g;gIMqY0RI1A80-K3n!MCY=3j$fj0a3c7yP%@*`6F< zo`Ip>s|i`d^V$Dn)qmzqRq6VJRY|{4ujsIU%#bnqU6MR&-1I_43=|5(6Jr;Jvert) zE?S|Tmn}Tv<-??sxV5@9t}3D=>YZ0JrQe$CO~|EY=Lj9RM&4svQHPQL6%pV5fPFiH zfXDx;l@~et{*{U*#c#Dvzu)<y{p<iKevLm%@24Es{u1>|znDO7$#CRx)Z&yp-}<F^ z`~J$vWM;oQpQO>SrD{&|(MQtfUz~n35@RLfUy=aqrhCX0M}J_r5QsK~NmRCR|Nm&L z41UdsLjWxSUlL41r^0K&nCCK>fdR-!MYjFg(z9_mF^C|#ZQw?`)f6uVzF^`bRnVY& zo}@M06J&_+>w9@jpaO4snmU;0t-(zYW1qVBHtuD!d?%?AtN7Plp><-1Y8Rqb20ZaP zTCgn*-Sri4Q8Xn>=gNaWQ57%!D35UkA@ksOlPB*Dvw}t02ENAqw|kFhn%ZyyW%+t{ zNdM!uqEM^;2}f+tECHbwLmH*!nZVrb$-az%t50Y2pg(HqhvY-^-lb}>^6l{$jOI6} zo_kBzj%8aX|6H5M0Y<)7pzz_wLkIpRm!;PzY)9+24wk2&TT{w--phDGDCOz{cN_ca zpnm7`$oDy=HX%0i-`769*0M6(e5j-?(?24%)<)&46y0e&6@HCDZAm9W6Ib#Y#BF6- z=30crHGg+RRTe%VBC>T00OV6F+gQDAK38<n*vA8r%O6>Ne3N9bm|62tPccBJi)5{B z4zc^Db72XiBd}v$CF|yU{Z=M|DZ%-(XarYNclODlb1Kz1_EKLy(NSLCN`eUl(rBCL zT*jx@wNvze0|TSqgE(QArOZU)_?qH(sj#TwzElLs9q)(0u!_P|R%Cy_0JFQxgGV>1 zz4?_uq<8_gM0`c*Hh|;UMz~vrg1gQXp{ufg`hM_qU;U>+zmvc5blCLSq@PrEBSGR# z&8=2Z4uXN`F3p73ueD1l{s{k$WipAvSh5W7ABe?4)t;r@V?y`bNB5FvBuE|0VRTb< zM1Hn^?DSsJY+sX@T5xW=#>T9VEV|?<(=6|ge$X6Sb05!LFdjDcoq*gM(Zq<f*af)i zNrX<tMgmsmg+`)u<gVRy&HOky#ont<pVW|J_-$wrA`xxK6{hhd+PXR8vNn*oM*H0| z1qYtJ28e684_5Ps?yhMANn+G%uO1h`$vWv3s;1>=t;_)Le&jyt(&9jzR73noru`a# zN*<`KwGa^gZU3-)MSLF0aFag#f0<>E(bYTeHmtdbns#|I)-$)mJ`q9ctQ8g0=ET?| zdO}eZ*b_p>ygRTtR^5Ggdam=Zb5wmd{}np+Jn1d_=M`~P=M67jj})fH4ztb5yQqQW z^C|C&^LHAK-u+ooIK)yM)QM?t;|<{P;;{`p=BclzAN#JzL4jCwXkQB1Dy{=^KR`=~ zTrr)y7eiYBzSNs_DvO=4A6#EgGS-zY%Vi)N*Yb`U;6o}KR}dq{r9pT5wqZ@3NOE8- z9-(}D|Nc5732CSYQbL)!gPQ#RbD8BhK3dl{sUuPvei0tkvnJBxDE<YT0)IF6ZR)Bk z@)a0nBbA1w8SkQ(D#i5&8jGNWcVh3%MMH8Vt0#Cqs{7rj9lAfnOxdi%ON~J_Lk4Vr zr{*Y)igLGP+Xld7jyNiw*|X1cmPqh_jE+%>AYTesU8H$)g(Plra{VH(v3u^CO1~(+ zU0O7#)jaS4{NcwA+LuSm&VBcX2#Im3xg)W}ySNw%->orn1taZ&+d)}8gJTqA!u|5P z{yv?zol_3|(1(%M(EVU=cp?L`{Pi|ixk<Zz{d_OJ{%(afPiA`kGm0)dQ`ag~77r|Y z&C+7i1_BU!*UJRd(^@b?4zBGXgdZlcPU8~&SFU-ec*eK#s8l5P4x$+w-ol8WnhVHs z<8AXv+lumqmDSsBEq_1%nCKJHKDdY<XS%xm_eRL@MHf03BP@ZPs+4efWYQybye<P; z!YgDeDt`-=e#48=xgFFnb3ip6+;21bca6@PSyeFDq6U)Bi{elQF$F^{M8$^wE9+h9 zp|0OT-Yl*F^H*Gl@RJ6Ygk#_Hwne|c{O*=S8hR2WOY7QEb^oD<fAVQQx1i_#15%F~ zSB12atfnDt>{U)*guFML3P!OSlz;zGA#T+E@8@cgQ_mv1o7RSU=Zo_82F?&&2r;WE z@wk}JHYEZ9nYUc(Vv~iTCa3u8e4q(yq<29VoNbKk<beKrau_(DO?g1SPxl?tXR8kl zm@B7yS{4nzYa-BC)B<s3ZV|tCLVRY=S6W|%ltS7#@=YN0E{Q~^h`zp6^Ds5_kY-c@ znjlqvzdNqVg-)ddJh>|`mq%I6u)My=gPIDuUb&lzf4`M<g#L>EA9^g8u<af%@W-r> z)vp8|$$HE9m_BTV?lOosIGa4jud=jIbw)O2eCMfyw2*S8?hjWw^nqws$O*M$3I1)x zR0PWFb3$ySOcGTe1dz%N0l;RPc`x%05FtT^f^j{Y<u?Msf@VVK=mBY*;G{h}T6alh i;_JuyfJ;~Um+rnc{a6{0b-ci|^HsjhJK1mm0001WTfUJ1 literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..9287f5083623b375139afb391af71cc533a7dd37 GIT binary patch literal 5914 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�(p-{x$&=IACIfoAR<Nvvu>!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@<pC<8PLRXJuvO4y>xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5<Pf8lV3fF#Ppu>Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@<t56P}GC(ea zcuDo~%vU?vTqAIp#flPNMI2`5;t8&98^HQwmsAsoPNK_fx^Bggb1WVe*N(RqH8#4x zCQN_rN*!W39u4A)O3%B(eX4-oO~1PlFj1j%A~@Wj>9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`<bqO>pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0x<PZ)*(STjdw~o@Rw-Or2Ax?U!if0^R7qyS!JDYN`R9pBUrvi5{Lgqu za0I&Zd*A54UPC}|lJz-2f!1VYM+A-ElFU{V`W)8LtCk5Mx|)Il)$QH*gA63%JZFt} zGq@eFEWXJ~R>Ps?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeN<ek%udeE*tim(T?PyxRjZ^lIknLzS6Fbvpe_110000002i;2E&u=k literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..aa7d6427e6fa1074b79ccd52ef67ac15c5637e85 GIT binary patch literal 3844 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 G0001O&w8c+ literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..9126ae37cbc3587421d6889eadd1d91fbf1994d4 GIT binary patch literal 7778 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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 000000000..f8c6127d3 --- /dev/null +++ b/dataconnect/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="purple_200">#FFBB86FC</color> + <color name="purple_500">#FF6200EE</color> + <color name="purple_700">#FF3700B3</color> + <color name="teal_200">#FF03DAC5</color> + <color name="teal_700">#FF018786</color> + <color name="black">#FF000000</color> + <color name="white">#FFFFFFFF</color> +</resources> \ No newline at end of file diff --git a/dataconnect/app/src/main/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..8c9ac0b83 --- /dev/null +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ +<resources> + <string name="app_name">Firebase Data Connect</string> +</resources> \ No newline at end of file diff --git a/dataconnect/app/src/main/res/values/themes.xml b/dataconnect/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..761a1fca9 --- /dev/null +++ b/dataconnect/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <style name="Theme.FirebaseDataConnect" parent="android:Theme.Material.Light.NoActionBar" /> +</resources> \ No newline at end of file diff --git a/dataconnect/app/src/main/res/xml/backup_rules.xml b/dataconnect/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 000000000..fa0f996d2 --- /dev/null +++ b/dataconnect/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Sample backup rules file; uncomment and customize as necessary. + See https://developer.android.com/guide/topics/data/autobackup + for details. + Note: This file is ignored for devices older that API 31 + See https://developer.android.com/about/versions/12/backup-restore +--> +<full-backup-content> + <!-- + <include domain="sharedpref" path="."/> + <exclude domain="sharedpref" path="device.xml"/> +--> +</full-backup-content> \ No newline at end of file diff --git a/dataconnect/app/src/main/res/xml/data_extraction_rules.xml b/dataconnect/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..9ee9997b0 --- /dev/null +++ b/dataconnect/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + Sample data extraction rules file; uncomment and customize as necessary. + See https://developer.android.com/about/versions/12/backup-restore#xml-changes + for details. +--> +<data-extraction-rules> + <cloud-backup> + <!-- TODO: Use <include> and <exclude> to control what is backed up. + <include .../> + <exclude .../> + --> + </cloud-backup> + <!-- + <device-transfer> + <include .../> + <exclude .../> + </device-transfer> + --> +</data-extraction-rules> \ No newline at end of file diff --git a/dataconnect/app/src/test/java/com/google/firebase/example/dataconnect/ExampleUnitTest.kt b/dataconnect/app/src/test/java/com/google/firebase/example/dataconnect/ExampleUnitTest.kt new file mode 100644 index 000000000..7c4c4ce12 --- /dev/null +++ b/dataconnect/app/src/test/java/com/google/firebase/example/dataconnect/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.google.firebase.example.dataconnect + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/dataconnect/build.gradle.kts b/dataconnect/build.gradle.kts new file mode 100644 index 000000000..f74b04bf2 --- /dev/null +++ b/dataconnect/build.gradle.kts @@ -0,0 +1,5 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false +} \ No newline at end of file diff --git a/dataconnect/gradle.properties b/dataconnect/gradle.properties new file mode 100644 index 000000000..20e2a0152 --- /dev/null +++ b/dataconnect/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/dataconnect/gradle/wrapper/gradle-wrapper.jar b/dataconnect/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 zcma&O1CT9Y(k9%tZQHhO+qUh#ZQHhO+qmuS+qP|E@9xZO?0h@l{(r>DQ>P;GjjD{w zH}lENr;dU&FbEU?00aa80D$0M0RRB{U*7-#kbjS|qAG&4l5%47zyJ#WrfA#1$1Ctx zf&Z_d{GW=lf^w2#qRJ|CvSJUi(^E3iv~=^Z(zH}F)3Z%V3`@+rNB7gT<C4E+e^X1+ z079LInxq~UYf-kNla?M9Qw5`wqM;O{-8tPk0sfaO{=LZmzBQ1)zwMpO|F66HKXsu0 zsblVBXkugf|5Qc(cU5;MLk9;_r~hk73h82L2Ot0dCNKa1{eNB}WN+`{?DBWLtf8fy zvWuaUi>VU{Bb~90p|f+0(v;nz01EG7yDMX9@S~__vVgv%rS$+?IH+oZ03D5zYrv|^ zC1J)SruYHmCki$jLBlTaE5&dFG9-kq3!^i>^UQL`%gn6)jz54$WDmeYdsBE9;PqZ_ zoGd=P4+|(-u4U1dbAVQrFWoNgNd;0nrghPFbQrJctO>nwDdI`Q^i0XJDUYm|T|RWc zZ3^Qgo_Qk$%Fvjj-G}1NB#ZJqIkh;kX%V{THPqOyiq)d)0+(r9o(qKlSp*hmK#iIY zA^)Vr$-Hz<#SF=0@tL@;dCQsm`V9s1vYNq}K1B)!XSK?=I1)tX+bUV52$YQu*<ra! z!v%7ZiKpO7g;NmE(;dSwu}#Qr14TWb<rzbgaS}{2FVDKaeCbmIt`T?_&=oa1ox)Gi zqwS3lX?Fkmj%*6-JQ8ia`$(tFUJ#ol59+HHQxhli%Jb#vc@r`6ZP-EsfP2S!rwy#d z;DP`C{cFdu2M4~`pHtE1KsUc1?BTR?&fOjbQh0|PcMiZgx_Kq$bLD%;`Ig2_LE~#` zt32~lMNxY>0%fnWEukW>mxkz+%3-S!oguE8u#MGzST8_Dy^#U?fA@S#K$<FiPyhgu zzq^L^|GyXf(+AWxl#$gjesG=F>S@9msUiX!gd_ow>08w5)nX{-KxqMOo7d?k2&?Vf z&diGDtZr(0cwPe9z9FAUSD9KC)7(n^lMWuayCfxzy8EZsns%OEblHFSzP=cL6}?J| z0U$H!4S_<U3#TVDkQ!s%Ox_BnFc2H6iNU0q=!|+Z9mk`Nbw?whndl6tI(Fj=$tl!^ zIOq<7BPlTvwG$fSu#@_%M(FvF2tpewu1-c35x~(IN{;#g5`-28n}V56vUKDyHalgc zVFs4DD7(uszamXg!+b}p?!s)SZXGtIED*Jww1@^#7%op*kD~rw8S#!ebx(C|Oi-ci zN~c@b8rVJSYHe*Cyn5uEa+-wenYQT6aAn!pd*%?%r>TVjj<`6dy^2j`V`)mC;cB%* z8{>_%E1^FH!*{>4a7*C1v>~1*@TMcLK{7nEQ!_igZC}ikJ$*<$yHy>7)oy79A~#xE zWavoJOIOC$5b6*q*F_qN1>2#MY)AXVyr$6x4b=$x^*aqF*L?vmj>Mgv+|ITnw_BoW zO?jwHvNy^prH{9$rrik1#fhyU^MpFqF2fYEt(;4`Q&XWOGDH8k6<goGkkqwiLLr*g z5z6x8$sF`?<e^h`j@COy$E+qY=Oj=v!b*KAnIYc*AP7qC0C#dwjl&srabEh<e-#E9 zv&sl%zw~Grb~0?}V1_A9--Q~b&NxawFDL54EJtT<qO4Z~5p7>M=%@fics4ajI;st# zCU^r1CK&|jzUhRMv;+W~6N;u<;#DI6cCw-<Kad;hI?@=R3TXw!Cr}=BbI5m+uEl-w zqErRfdJ>otsc@IsN3MoSD^O`eNflIoR~l4*&-%RBYk@gb^|-JXs&~KuSEmMxB}xSb z@K76cXD=Y|=I&SNC2E+>Zg?R6E%DGCH5J1nU!A|@eX9oS(WPaMm==k2s_ueCqdZw| z&hqHp)47`c{BgwgvY2{xz%OIkY1xDwkw!<0veB#yF4ZKJyabhyyVS`gZepcFIk%e2 zTcrmt2@-8`7i-@5Nz>oQWFuMC_KlroCl(PLSodswHqJ3fn<;gxg9=}~3x_L3P`9Sn zChIf}8vCHvTriz~T2~FamRi?rh?>3bX1j}%bLH+uFX+p&+^aXbOK7clZxdU~6Uxgy z8R=obwO4dL%pmVo*Ktf=lH6hnl<C=NTH%5`gfxBHGJj`iNz(A#<w&(&isR(NdjRau zf|I4<<<u=|XXZxd2JFGs>z_5k3cG;m8lgaPp~?eD!Yn2kf)tU6PF{kLyn|oI@eQ`F z3IF7~Blqg8-uwUuWZScRKn%c2_}dXB6Dx_&xR*n9M9LXasJhtZdr$vBY!rP{c@=)& z#!?L$2UrkvClwQO>U*fSMs67oSj2mxiJ$t;E|>q%Kh_GzzWWO&3;ufU%2z%ucBU8H z3WIwr$n)cfCXR&>tyB7BcSInK>=ByZA%;cVEJhcg<#6N{aZC4>K41XF>ZgjG`z_u& zGY?;Ad?-sgiOnI`oppF1o1Gurqbi*;#x2>+SSV6|1^G@ooVy<y93LDm;wIOTj=5c$ zC-QhzvAl0y_;%{)gWRy`;Bf=?TtTe*SY!MP@9W|edu{l86Kr1<Cmr((Tem3gt4{#y z3D<WVud@WzG8_>@fg?wyf@0Y!UZ4!}nGuLeC^l)6pwkh|oRY`s1Pm$>zZ3u-83T|9 z<QZfPf@8vDvzJ5u4A^A<#$7q&=f7lp3&n5W!oLxA;ja+?=SVAJ?`~&fZ)ozb9P1k` z3pL1q5VB*z+Ct?<9|-*itS69vS4hVra5Z!lDKSySn;jjmUpRtte+Bax7QXjI?`90S zA4?c)l!1W6+}k;06I}~wRC@!%R<xI9L>GaKJIV3_x+u1>cRibsaJpJqhcm%?0-L;2 zitBrdRxNmb0OO2J%Y&Ym(6*`_P3&&5Bw157{o7LFguvxC$4&zTy#U=W*l&(Q2MNO} zfaUwYm{<Z#B+303&8&j|1AYBZQ1ef~@-GzfzfBY|H8XUzarxJ|f|I?ulc}?_jHR=S zshz3QKN3ud>XtILD$3864IA_nn34oVa_g^FRuHL5wdUd)+W-p-iWCKe8m_cMHk+=? zeKX)M?Dt(|{r5t7IenkAXo%&EXIb-i^w+0CX0D=xApC=|Xy(`xy+QG^UyF<x+1@YN z-ZMAue9y-N{P;V-w=mmGh-1)C76Xu!U?t<}ByuZz$q|bl4S<l@37Jh~Glu1WLl}(l zthb2~6ne4Q83FI<<Ay4c4`8D(It&am2#(!Ony)app0o62Q+)KCL_LQOA)tF@50mMJ z<<yYk$({rlv3BkbtJ+SuacOk#dTZz@Qrop4gQ~UO=spb=-piAtn0x3U*bMbJdwI>e z+#J6h_&T5i#sV)hj3D4WN%z;2+jJcZxcI3*CHXGmOF3^)JD5j&wfX)e?-|V0GPuA+ zQFot%aEqGNJJHn$!_}#PaAvQ^{3-Ye7b}rWwrUmX53(|~i0v{}G_sI9uDch_brX&6 zWl5Ndj-AYg(W9CGfQf<6!YmY>Ey)+uYd<oGrQ)76s0B|$EDBcWW*bGf!XzhAcke;% zrh(E+RSxuWf~{)Nc=B!JS!X`$2DA4*+k72=D%60mnI%QDRarAyE}--rmK`Z^V+Yae z&vDIckd3^cYT8wuAh(uIoqyu^LYf=&(`lZI>_J<fvopl!qQMd6Z#ZYajSPWAp}`qN zjM~mJhn3N452gfOT)3x7am8MT`AA+VSSTZ}v@@8Ef-MrsX1vV|`Qx}Yh}~|pD;qlf zbIL{96T!~st%_2?)f30K4Wp&xR_;627NS?#a?ONO1)C^TbaxNEe^u!aMYPy7;-Hq3 z8kFD}u19!l+a?p#Hl9<7(I=E6f85Gdi%U@U>NXH=>|`OH-CDCmcH(0%iD_aLlNHKH z7bcW-^5+QV$jK?R*)wZ>r9t}loM@XN&M-Pw=F#xn(;u3!(3SXXY^@=aoj70;_=QE9 zGghsG3e<X)8z!NOUQ-K7Tg*M~$*Lq1YL~>kq#N||u{4We_25U=y#T*S{4I{++Ku)> zQ!DZW;pVcn>b;&g2;YE#+V`v*Bl&Y-i@X6D*OpNA{G@JAXho&aOk(_j^weW{#3X5Y z%$q_wpb07EYPdmyH(1^09i$ca<gJ(BN8tBcV!2)N5jxRqNZX(-f?Oe`25b>{O<}7) zRWncXdSPgBE%BM#by!E>tdnc$8RwUJg1*x($6$}ae$e9Knj8gvVZe#bLi!<+&BkFj zg@nOpDneyc+hU9P-;jmOSMN|*H#>^Ez#?;%C3hg_65leSUm;iz)UkW)jX#p)e&S&M z1|a?wDzV5NVnlhRBCd_;F87wp>6c<&nkgvC+!@KGiIqWY4l}=&1w7|r6{oBN8xyzh zG$b#2=RJp_iq6)#t5%yLkKx(0@D=C3w+oiXtSuaQ%I1WIb-eiE$d~!)b@|4XLy!CZ z9p=t=%3ad@Ep+<9003D2KZ5VyP~_n$=;~r&YUg5UZ0KVD&tR1DHy9x)qWtKJp#Kq# zP*8p#W(8JJ_*h_3W}FlvRam?<4Z+<U)YVE|@-H{P0L;lgD=4M+K83d`J=;XZ6cLXJ z^Pb^7ai96hX*$t^`}X?;F@T^K^_U|s%%#VBR44vL@CQr;#z>-H77^$Lvi+#vmhL9J zJ<1SV45xi;SrO2f=-OB(7#iNA5)x1uNC-yNxUw|!00vcW2PufRm>e~<GrW@0xg&;k zL?iysf{CN}V{o=!B5cYHv{+Y1t=R-R>toH;M0Q85MQLWd?3O{i8H+5VkR@l9Dg-ma ze2fZ%>G(u5(k9EHj2L6!;(KZ8%8|*-1V|B#EagbF(rc+5iL_5;Eu)L4Z-V;0HfK4d z*{utLse_rvHZeQ>V5H=f78M3Ntg1BPxFCVD{HbNA6?9*^YIq;B-DJd{Ca2L#)qWP? zvX^NhFmX?CTWw&Ns}lgs;r3i+Bq@y}Ul+U%pzOS0Fcv9~aB(0!>GT0)NO?p=25LjN z2bh>6RhgqD7bQj#k-KOm@JLgMa6>%-ok1WpOe<hhSU`7#vu9^7%H57&(`pARdXnZM z;eION-Jf1cVd1-MCh?1mFC&L}n&D)2PEzUHkSiE_t+j1!E~K#qcYI-`hc5a+<MQay z<1}>)FS^XOU{c?d5shG(lIn3GiVBxmg`u%-j=)^v&pX1JecJics3&jvPI)mDut52? z3jEA)DM%}BYbxxKrizVYwq?(P&19EXlwD9^-6J+4!}9{ywR9Gk42jjAURAF&EO|~N z)?s>$Da@ikI4|^z0e{r`J8zIs>SpM~Vn^{3fArRu;?+43>lD+^XtUcY1HidJwnR6+ z!;oG2=B6Z_=M%*{z-RaHc(n|1RTKQdNjjV!Pn9lFt^4w|AeN06*j}Zyhq<HLE#a+A zJ{@Y`FHfEI5bv;8o$ZEGCqCOp4`6-5eeTUyKzzSKd~k>Z^!-=cyGP_ShV1rGxkx8t zB;8`h!S{LD%ot``700d0@Grql(DTt4Awgmi+Yr0@#jbe=2#UkK%rv=OLqF)9D7D1j z!~McAwMYkeaL$~kI~90)5vBhBzWYc3Cj1WI0RS`z000R8-@ET0dA~*r(gS<U)h`aD zLE6Dn5Q+3O*c2B9elF29G1VrakVK+eBtDr^Gf}-iF&NZnZ$t<sha>iCJmQMN&4%1D zyVNf0?}sBH8zNbBLn>~(W{d3%@kL_eQ6jEcR{l><V*L*c8i9aN^J3k5@LC4D{kHc* ztiPGM*MYdYK5-%KXLXp`;*IAihHn*4Pn*cYN8uZA$oHJE-8(VkYwPa2amx3wu)oxf z;@K?9yEDA%g1*No{aF{@JJ45(#kW@dSB%B?ig4GNH`G^-kpE2q*E={oZNZKA>C|JK z(R-fA!z|TTRG40|zv}7E@PqCAXP3n`;%|SCQ|ZS%ym$I{`}t3KPL&^l5`3>yah4*6 zifO#{VNz3)?<E9P2g)v<M^QTEwJXCARf%0EK}z-2B8QN$*7-J-mD@w9d6fzs+FVv4 zrJ6y#^xsh;O&ToHBFf~MC54(~Sb2j}sCotWQt($NS~kJz72WH?<eo8;ekt}!o;CT~ zFlF~#@UZt@DeNAn7u||SULov|N<ot`RP2<Gm?2rbIJ;;m_J~Yu)ZL<c+N#@?_iQO` zRo%izIfJ9RLf%!Awp2T%_jV~S<vv}L?&aO;G&bt(!h60lS`2vX)v@WhGFXzOtRK;c zyr@kt8OV3(72@wS&Pz;*6SWo#Z2jYhpl6$h+sV9U!&ep>ZL$be;NEaAk9b#{tV?V7 zP|wf5YA*1;s<)9A4~l3BHzG&HH`1xNr#%){4xZ!jq%o=7nN*wMuXlFV{HaiQLJ`5G zBhDi#D(m`Q1pLh@Tq+L;OwuC52RdW7b8}~60WCOK5iYMUad9}7aWBuILb({5=z~YF zt<!zJa=c$qO%0sJrsowK;!t%<S=X7Y&Jxn+nGDY&f(=_-CL}c~4K|D7ux0QbLt&^f zSr24bBRsz?=*UjQu7gZoZ09|ewuhGV`hI~v^swhOT82+78;Tj2xT>?*Jr<A3W3T-3 z<1&Nz$ui_+E7n$IbPK2Rrimn|(;7EPx1h6Fi)-3?>5NG+WadM{mDL>GyiByCuR)hd zA=HM?J6l1Xv0Dl+LW@w$OTcEoOda^nFCw*Sy^I@$sSuneMl{4ys)|RY#9&NxW4S)9 zq|%83IpslTLoz~&vTo!Ga@?rj_kw{|k{nv+w&Ku?fyk4Ki4I?);M|5Axm)t+BaE)D zm(`AQ#k^DWrjbuXoJf2{Aj<e%564WZLSS(Vd#ROb?}iRVX%`%#SRY^TSh{u=kI84e z3Ow&PB(uhOFrXgEsH|%j)I-Ug(1-{oj6dcgiDH#{Xclz$DM?xphY_S^6Z_M-j<Q{M z3m5`Vg`@HsKN#jbtysG34Af*<`=E}BAe7{&&983YEEQfM{J8#P!otJO%!Ug4=|diY zavSVyOcEEa#lU9-1SEeUY5cJ=<Ds##5c*EUsRi@fN(`2}Y5bh+k`b9uFL%rwlbF%M zdSW_2pP9rtReb`cMyUFd*B2fwpi645^+_wI6EzB|mef<{4KFd8`yOM9scKF$vrUM| zCoNlGyffVj5PnZZrY&Y5n|uW=PHnHX<YXTSdl@8HE*;J7fEIy#D!|gwR}m6V5SwWq z(a}Z41Prc!Wk&AE!j3s3VBd*3Lw?gbM3E&oKBI9kltN8F1T6wcC{`?P4s@QUU>^KT zFb1zMSqxq|vceV+Mf-)$oPflsO$@*A0n0Z!R{&(xh8s}=;t(lIy<Pl3uQSpJ4S8Y> zv$S8x>m;vQNHuRzoaOo?eiWFe{0;$s`Bc+Osz~}Van${u;g(su`3lJ^TEfo~nERfP z)?aFzpDgnLYiER<rBlW-W;>sKPu<X=38r|I{0&8J8r0^`tKAAKA8jUsvBhopXc14T zgUW&TYxF3i;mZl;v5E+2qh>|0tq4l2wT)Atr6Qb%m-AUn6HnCue*yWICp7TjW$@sO zm5rm4aTcPQ(rfi7a`xP7cKCFrJD}*&_~xgLyr^-bmsL}y;A5P|al8J3WUoBSjqu%v zxC;mK!g(7r6RRJ852Z~feoC&sD3(6}^5-uLK8o)9{8L_%%rItZK9C){UxB|;G>JbP zsRRtS4-3B*5c+K2kvmgZK8472%l>3cntWUO<l-B0`McQr%h#X$@@JrQb8#@ppQUG9 z)`zI{d#E;id*aB>VHxB|{Ay~aOg5RN<wtviA9uMGSHMzxbdkCR`SfK34yQ^bEpjN~ zPf^Bz$`=daT8!L25oqT&4i=+dRE^=E#-D#x^n<}e%YqXyp_!ut)H)b@E0UPjTon!Z zEyxi=e>;{PJgeVD*H%ac+y!h#wi%o2bF2Ca8IyMyH{>4#{E_8u^@+l-+n=V}Sq?$O z{091@v%Bd*<G_U#2l9N@Y9~(ygCl-h&9i8Yj9_3cV?DM!lxf11kY_LYnfWL$a@7F3 zk0$$mP<ka_iUf&lMdUX}1{nJ-EWCBw#je~5Smwg)p`tEv>3pk0^2UtiF9Z+(a@wy6 zUdw8J*ze$K#=$48IBi1U%;hmhO>lu!uU;+RS}p&6@rQila7WftH->*A4=5W|Fmtze z)7E}jh@cbmr9iup^i%*(uF%LG&!+Fyl@LFA-}Ca#bxRfDJAiR2dt6644TaYw1Ma79 zt8&DYj31j^5WPNf5P&{)J?WlCe@<3u^78wnd(Ja4^a>{^Tw}W>|Cjt^If|7l^l)^Q zbz|7~CF(k_9~n|h;ysZ+jHzkXf<NcXr7lv4BLC0%brrpzF{-obsvQr%!_>(*O*@5m zLzUmbHp=x!Q|!9NVXyipZ3)^GuIG$k;D)EK!a5=8MFLI_lpf`HPKl=-Ww%z8H_0$j ztJ||IfFG1lE9nmQ<au0Ml%cF(=R}3nZqu!gn~ulcq{4`|I}2tuFXsdOn=eq9Qz{LW zP^cfInuhA7QFIUcH%13E<=O+Vy|WwD8Y7tHV5z9{b#53O<eDk6+LD+O<*lHC*Sh-< zQQ*d{QylAFdIrjkp%D|@{O_AHu^XIETj$pt=r^Iy${cPPEHt@N92tqR6yn>0+jPQy zCBdKkjArH@K7jVcMNz);Q(Q^R{d5G?-kk;Uu_IXSyWB)~KGIizZL(^&qF;|1PI7!E zTP`%l)gpX|OFn&)M%txpQ2F!hdA~hX1Cm5)IrdljqzRg!f{mN%G~H1&oqe`5eJCIF zHdD7O;AX-{XEV(a`gBFJ9ews#CVS2y!&>Cm_dm3C8*n3MA*e67(WC?uP@8TXuMroq z{#<dYp{{+xCUwQw6)w9(r^`^0<F6$P{m>w$%z@CBIkRM7?}Xib+>hRjy?%G!fiw8! z8(gB+8J~KOU}yO7UGm&1g_MDJ$IXS!`+*b*QW2x)9>K<JF7b%^NJe_9Fs0_(5~kP{ zjLlh(Gb|iDD!%2k=};N1Ay-P7$Wu_KP|<Nv<px9DnR*Bhp`@^|=#?&n9#0)w-FDC^ zYYwq=gkfiuN_5CjVN9Ao)scz%pogt!6S`BY&a-LBZ0vR8Hs`uYWwn}-oY<_#H(3U4 z+C^OQlGq~g8s<Q&bZc>~Y*E&bYMnjl6h!{17_8d!%&9D`a7r&LKZjC<&XOvTRaKJ1 zUY@hl5^R&kZl3lU3njk`3dPzxj$2foOL26r(9zsVF3n_F#<NB6ex9*oBW}l?xfbxx zL%E!#nXs+R%q=x9ZnSy_@kOHMCrx3@5&BuHZd_<OOwDuA?g-PK!m67qWTCZlm0wKU zv#i7p-7isYt{yv)OR8I5o;!7xop*Zm6Rp~J_EdjQW~sUFuwoyaW1U&NaV|M4U1?nR z@)EUG9kIA_lYfhiyvI%HWy;Ge%?fKvOXHCrMb%0xeab5(rJo188D&|PUo17GFJ{-P zs?t#M)7w#0RHw{KaZQP;u<ZWKm`x0&oP9ITEjmgxyDYbPrK*U@Usmmw0c2<WSXv-G zvb6FvJhqfc@v@g(G}B*mRq{2pTwn0X@yc3sxqx=5S4D&>v)s5vv3<FgSd?p+)!kNm zTv$3*E<J6vu2~)lp%4nMdtWE@=#AViY%^W&8R2_<v(+Qa%Mf{$Tn~RH;PHc?^uW;B zl33*<G2>@dgs|lP#eylq62{<-vczqP!RpVBTgI>@O6&sU>W|do17+#OzQ7o5A$ICH z?GqwqnK^n2%LR;$^oZM;)+>$X3s2n}2jZ7CdWIW0lnGK-<n4*9i9?XprV!b-2yz1y z82;&r=GhqHm&zBz3e~(LKRa(8ZwPt%3TYt5`IuWWTX+)iO!EB5qiR4W$RDCd`g{-j zZuyRBvdZnk><gEOlAyZgBQiT7{>b#EG01)P@aU`pg}th&J-TrU`tIpb5t((0eu|!u zQz+3ZiOQ^?RxxK4;zs=l8q!-n7X{@jSwK(iqNFiRColuEOg}!7cyZi`iBX4g1pNBj zAPzL?P^Ljhn;1$r8?bc=#n|Ed7wB&oHcw()&*k#SS#h}jO?ZB246EGItsz*;^&tzp zu^YJ0=lwsi`eP_pU8}6JA7MS<SY@ZXteVGu2D;{e_dFnzL(OQbWI#IL1J(@}vxKG~ z{>;9pfD;DsSsLo~ogzMNP70@@;Fm8f0^;>$Z>~<d_jp#pvX)$*IF^kRoeC(j+t)Ur zPx*FO4h)ZG=iqY;QK6W@ekh%ILEiXbr05ou^~><kZopq=@-e)e2pdVj4ZJKNAd~~= zIWD3^k4g6+ly88l1dUP7-<u$%X=hfWc{o8z>}GWRw!W5J3tNX*^2+1f3hz{~rIzJo z6W%J(H!g-eI_J1>0juX$X4Cl6i+3wbc~k146UIX&G22}WE<iA2ePZPFMn_vlC3@`( zC;LX5HFTJ@i$J`Hbx0sRaj^uiUmU^s{mchNU>>0ga#WLsn9tY(&29zBvH1$`iWtTe zG2jYl@P!P)eb<5DsR72BdI7-zP&cZNI{7q3e@?N8IKc4DE#UVr->|-ryuJXk^u^>4 z$3wE~=q390;XuOQP~TNoDR?#|NSPJ%sTMInA6*rJ%go|=YjGe!B>z6u$IhgQSwoV* zjy3F2#I>uK{42{&IqP59)Y(1*Z>>#W8rCf4_eVsH)`v!P#^;BgzKDR`ARGEZzkNX+ zJUQu=*-ol=Xqqt5=`=pA@BIn@6a9G8C{c&`i^(i+BxQO9?YZ3iu%$$da&Kb?2kCCo zo7t$UpSFWqmydXf@l3bVJ=%K?SSw)|?srhJ-1ZdFu*5QhL$~-IQS!K1s@XzAtv6*Y zl8@(5BlWYL<vlUo^g==w+bwzmrMIfe1OGTk=p=b_r!$VPWnqI4+K-Q0Kn&YY_INtV ze5P(@!H@m0AM%dGQN5FSnZ`He_+j(1bNGu}ee2>t1yAWy?rMD&bwze8bC3-GfNH=p zynNFCdxyX?K&G(ZZ)afguQ2|<I|ToEQs5dh02L^JB6>r;XoV^=^(;Cku#qYn4<V}b zA&FlaPRwlltsTXe9~a58?uH)L#hLx*;|`>Lus`UeKt6rAlFo_rU`|Rq<F_mt<XG?> z&G?~iWMB<P-m~>io<78of-2X(ZYHx~=U0Vz4btyXkctMKdc9UM!vYr~B-(>)(Hc|D zMzkN4!PBg%tZoh+=Gba!0++d193gbMk2&krfDgcbx0jI92cq?FFESVg0D$>F+bil} zY~$)|>1HZsX=5sAZ2WgPB5P=8X#TI+NQ(M~GqyVB53c6IdX=k>Wu@A0Svf5#?uHaF zsYn|koIi3$(%GZ2+G+7Fv^lHTb#5b8sAHSTnL^qWZLM<(1|9|QFw9pnRU{svj}_Al zL)b9>fN{QiA($8peNEJyy`(a{&uh-T4_kdZFIVsKKVM(?05}76E<BOuTKNZ>Ez?#W za^fiZOAd14IJ4zLX-n7Lq0qlQ^lW8Cvz4U<X+>KkV9~P}>sq0?xD3vg+$4vLm~C(+ zM{-3Z#qnZ09bJ>}j?6ry^h+@PfaD7*jZxBEY4)UG&daWb??6)TP+|3#Z&?GL?<NWw zpo!n^_Mnx`#l`Mtp<H71ndq}LH(ZXJlzYuNAHINUglm=WWXJLtuPRY2?rIWUs=h@; z1r9;ol65y`xO*SzvRa8o!D+_s;~<Bb-h5u71zg5ymFjIboenFkEakiwkF#l0c-sut znM=(poO3Xq&}pnDV!O6|+_sscTBqqe%{pj$E^ku;b!#-zj>1i+280CFsE|vIXQbm| zM}Pk!U`U5NsNbyKzkrul-DzwB{X?n3E6?TUHr{M&+R*2%yOiXdW-_2Yd6?38M9Vy^ z*lE%gA{wwoSR~vN0=no}tP2Ul5Gk5M(Xq`<W>$nw#ndFk`tcpd5A=Idue`XZ!FS>Q zG^0w#>P4pPG<EP*xHW9WCTGn>+*NC9gLP4x2m=cKP}YuS!l^?sHSFf<ssGXChcP9X za)*XSF8xSAX6O&AkMIyXtB<sR`J2gFTR8ck#bl<tOy9ZU%W%Jooi$>tZy{4CoQrb_ z^20(NnG`wAhMI=eq)SsIE~&Gp9Ne0nD4%Xiu|0Fj1UFk?6avDqjdXz{O1nKao*46y zT8~iA%Exu=G#{x=KD;_C&M+Zx4+n`sHT>^>=-1YM;H<72k>$py1?F3#T1*ef9mLZw z5naLQr?n7K;2l+{_uIw*_1nsTn~I|kkCgrn;|G~##hM;9l7Jy$yJfmk+&}W@JeKcF zx@@Woiz8qdi|D%aH3XTx5*wDlbs?dC1_nrFpm^QbG@wM=i2?Zg;$VK!c^Dp8<}BTI zyRhAq@#%2pGV49*Y5_mV4+OICP|%I(dQ7x=6Ob}>EjnB_-_18*xrY?b%-yEDT(wrO z9RY2QT0`_OpGfMObKHV;QLVnrK%mc?$WAdIT`kJQT^n%GuzE7|9@k3ci5fYOh(287 zuIbg!GB3xLg$YN=n)^pHGB0jH+_iIiC=nUcD;G6LuJsjn2VI1cyZx=a?ShCsF==QK z;q~*m&}L<-cb+mDDXzv<F>vrRsybcgQ;Vg21P(uLv5I+eGc7o7tc6`;OA9{soHFOz zT~2?>Ts}gprIX$wRBb4yE>ot<8+*Bv`qbSDv*VtRi|cyWS>)Fjs>fkNOH-+PX&4(~ z&)T8Zam2L6puQl?;5zg9h<}k4#|yH9czHw;1jw-pwBM*O2hUR6yvHATrI%^mvs9q_ z&ccT0>f#eDG<^WG^q@oVqlJrhxH)dcq2cty@l3~|5#UDdExyXUmLQ}f4#;6fI{f^t zDCsgIJ~0`af%YR%Ma<z>5VQq-p21k`vaBu6WE?66+5=XUd%Ay%D$irN>5LhluRWt7 zov-=f>QbMk*G##&DTQyou$s7UqjjW@k6=!I@!k+S{pP8R(2=e@io;N8E`EOB;OGoI zw6Q+{X1_I{OO0HPpBz!X!@`5YQ2)t{+!?M_iH25X(d~-Zx~cXnS9z>u?+If|iNJbx zyFU2d1!ITX64D|lE0Z{dLRqL1Ajj=CCMfC4lD3&mYR_R_VZ>_7_~|<^o*%_&jevU+ zQ4|qzci=0}Jydw|LXLCrO<tY^5#SIaz%jR-X`&*7)+bTVSc1BE2BLn%vVH`Mbz1R- z>l1_P6Xf@c0$ieK2^7@A9UbF{@V_0p%lqW|L?5k>bVM8|p5v&2g;~r>B8uo<4N+`B zH{J)h;SYiIVx@#jI&p-v3dwL5QNV1oxPr8J%ooezTnLW>i*3Isb49%5i!&ac_dEXv zvXmVUck^QHmyrF8>CGXijC_R-y(Qr{3Zt~EmW)-nC!tiH`wlw5D*W7Pip;T?&j%kX z6DkZX4&}iw>hE(boLyjOoupf6JpvBG8}jIh!!VhnD0>}KSMMo{1#uU6kiFcA04~|7 zVO8eI&x1`g4CZ<2cYUI(n#wz2MtVFHx47yE5eL~8bot~>EHbevSt}LLMQX?odD{Ux zJMnam{d)W4da{l7&y-JrgiU~qY3$~}_F#G7|MxT)e;G{U`In&?`j<5D->}cb{}<s= z)!EcZ!rs<Y#@^oA)#0D)Gvy7r4FLpRXcD*RfTd(<C=@A5lICI1^#y3rLIVLJon(Px z9#Jw)(4Yq5v4TSV<tUJH3ExZMzKTk&i(qL2_(Map=flfs&WkPnAHQ!Ph9FQ-#b`+n zGGm<qkbNX1D53P^JDqBMk-0!hNJ&trQIk`mzGOz)`{-cJ&~H;?Q%CleBz;+Wy0Yj` zyHSagKdo#qV6?6Vcv+pcT%^1Q-l@v({R}QyX*+WEw%BJmI(}Q@j4m`CawF`x{MPHL za$h&MF~4nMuyWgQrt}RgV#pg|Y^CiI*j!3z!tB-Jp4;1uuh(==9iU5dSb3$ZFATE! z>{T(4DF0BOk<QYr>-=1195KB-E*o@c?`>y#4=dMtYtSY=&L{!TAjFVcq0y@AH`vH! z$41+u!Ld&}F^COPgL(EE{0X7LY&%D7-(?!kjFF7=qw<;`V{nwWBq<)1QiGJgUc^Vz ztMUlq1bZqKn17|6x6iAHbWc~l1HcmAxr%$Puv!znW)!JiukwIrqQ00|H$Z)OmGG@= zv%A8*4cq}(?qn4rN6o`$Y))(MyXr8R<2S^J+v(wmFmtac!%VOfN?&(8Nr!T@kV`N; z*Q33V3t`^rN&aBiHet)18wy{*wi1=W!B%B-Q6}SCrUl$~Hl{@!95ydml@FK8P=u4s z4e*7gV2s=YxEvskw2Ju!2%{8h01<bQ)d%wXmu1Zpj%~-&jsiWxq+->rx-3`NCPc(O zH&J0VH5etNB2KY6k4R@2Wvl^Ck$MoR3=)|SEclT2ccJ!RI9Nuter7u9@;<u*AgOz( zn=C13+36e?Wp?2OIME<hmtjrXzJ<Zd*?$>sWf-%um;GfI!=eEIQ2l2p_YWUd{|6EG ze{yO6;lMc>;2tPrsNdi@&1K6(1;|$xe8vLgiouj%QD%gYk`4p{Ktv9|j+!OF-P?@p z;}SV|<w!@0*CQ5@xp9@`8c_*)IC@^rAGd{(#wPf?$`(^V&!%1qI&#?UztvBAF!4M; z_oxBXB0!;X3yhd^D}+Xx4sUHZH*0n|si;UgfM!*1c|d1h4nY076_94CJP`FR$D}_! zDgwP#mZV0tbmF7vmG7Log$Afqr(GuMl<urHsSR(EhO7^7wNPIUT%rDwjj%sGil746 zDLtAZLp-7)K|QJh+bT3@0I$b@q3|9LuBZk*!Xn-Gb?+~>oIK)iwlBs+`ROXkhd&NK zzo__r!B>tOXpBJMDcv!Mq54P+n4(@dijL^EpO1wdg~q+!DT3lB<>9AA<tf}r`cy*Y zjhdtI5OMNT6H0#L@X?3Sm%kGA7Vl5JMh4bZuEy3uPM@!CETCEPH`bN;-XzRi=Uj<* zy1%%&-XKAU$eorwmA2>NSe!T1XgC=J^)IP0XEZ()_vpu!!3HQyJhwh?r`Ae%Yr~b% zO*NY9t9#qWa@GCPYOF9aron7thfWT`eujS4`t2uG6)~JRTI;f(ZuoRQwjZjp5Pg34 z)rp$)Kr?R+KdJ;IO;pM{$6|2y<ydqUT>=k_siqvp%)2||cHTe|b5Ht8&A{wazGNca zX$Ol?H)E_R@SDi~4{d-|8nGFhZPW;Cts1;08TwUvLLv&_2$O6Vt=M)X;g%HUr$&06 zISZb(6)Q3%?;3r~*3~USIg=HcJhFtHhIV(siOwV&QkQe#J%H9&E21!C*d@ln3E@J* zVqRO^<)V^ky-R|%{(9`l-(JXq9J)1r$`uQ8a}$vr9E^nNiI*thK8=&UZ0dsFN_eSl z(q~lnD?EymWLsNa3|1{CRPW60>DSkY9YQ;$4o3W7Ms&@&lv9eH!tk~N&dhqX&>K@} zi1g~GqglxkZ5pEFkllJ)Ta<O6(=j@N_YA4+m(2{`eegY!ZQ2^vALR<RgI6}@oJ;#Q znz8Q+-c~%`4rP2NC%pl75Va8USgVLiZL*d#F+vsZ>1I^c&Bt6#r(QLQ02yHTaJB~- zCcE=5tmi`UA>@P=1LBfBiqk)HB4t8D?02;9eXj~kVPwv?m{5&!&TFY<knKM4n)!Ph zud#tQR<C%y^0~@DM`a6)LueXb{y5yQ{QdB(pAh_Nx5%(@`(@LGcfv~*Wnh>hu>3=_ zsGmYZ^mo*-j69-42y&Jj0cBLLEulNRZ9vXE)8~mt9C#;tZs;=#M=1*hebkS;7(aGf zcs7zH(I8Eui9UU4L--))yy<O?Y9%&u#eL}xwSxY`c_7W7JcW|iGpGkga)aNR1NtnV zsQ!z$?wFhYyP2W>`&d&$In&VA2?DAEss4LAPCLd>-$i?lpXvn!gu^JJ$(DoUlc<UX zkfN^{^6VUfk4qkMm$Kwn8iV7sNct*PLMd9WvC5vZDv+`U&Q-ZGnQrAf`3aDr3@9ll zcH!Spxa>6wE98VLZ*z`QGQov5l4Fm_h?V-;mHLYDVOwKz7>e4+%AzeO>P6v}ndPW| zM>m#6Tnp7K?0mbK=>gV}=@k*0Mr_PVAgGMu$j+pWxzq4MAa&jpCDU&-5eH27Iz>m^ zax1?*HhG%pJ((tkR(V(O(L%7v7L%!_X->IjS3H5kuXQT2!ow(;%FDE>16&3r){!ex zhf==oJ!}YU89C9@mfDq!P3S4yx$aGB?rbtVH?sHpg?J5C->!_FHM%Hl3#D4eplxzQ zRA+<@LD%LKSkTk2NyWCg7u=$%F#;SIL44~S_OGR}JqX}X+=bc@swpiClB`Zbz|f!4 z7Ysah7OkR8liXfI`}IIwtEoL}(URrGe;IM8%{>b1SsqXh)~w}P>yiFRaE>}rEnNkT z!HXZUtxUp1NmFm)Dm@-{FI^aRQqpSkz}ZSyKR%Y}Y<Q&WCZ#r6E1BX>HNzBk)ZIp} zMtS=aMvkgWKm9&oTcU0?S|L~CDqA+sHpOxwnswF-fEG)cXCzUR?ps@tZa$=O)=L+5 zf%m58cq8g_o}3?Bhh+c!w4(7AjxwQ3>WnVi<{{38g7yFboo>q|+7qs<$8CPXUFAN< zG&}BHbbyQ5n|qqSr?U~GY{@GJ{(Jny{bMaOG{|IkUj7tj^9pa9|FB_<+KHLxSxR;@ zHpS$4V)PP+tx}22fWx(Ku9y+}Ap;VZqD0AZW4gCDTPCG=zgJmF{|x;(rvdM<f^n;1 zK8nxqYR0vKH0j_lDlpPKZ&MGwB{uMn`Y?-pX_!Y3B)-jRTo&O^8pthSIE0&!0lK-e z!icB)w$6c%`*wHe1Fz?U<?TZjyK8JuG0UC$ZCke^hB6%|%?F1X$1uXP^O6o2PCxRi zb_5>|2|9a}cex6xrMkERnkE;}jvU-kmzd%_J50$M`lIPCKf+^*zL=@LW`1SaEc%=m zQ+lT06Gw+wVwvQ9fZ~#qd430v2HndFsBa9WjD0P}K(rZYdAt^5WQIvb%D^Q|pkVE^ z<umx#lsJRB+%c;<9rnPd8EZLPtdXF8y%S2H@ouSr1qOSZk)i$^f3$XJg9S<yWHU+7 zyJJ%uaeqUu9^B#_n2Ir_nIYz}G3SZ_C|5(`c6Umw#_yBgS{UuH<_&oA!pzsBV%g@= zbOzA1`OA)5@fUbFgE?}SVsAWLGVW?bLByY#LXc_UE*PeNYf={+;y-vKh@$+ue#tsu zvCKGNIefnaDUw~m>te$&#~zmULFACGfS#g=2OLOnIf2Of-k!(BIHjs77nr!5Q1*I9 z1%?=~#Oss!rV~?-6Gm~BWJiA4mJ5TY&iPm_$)H1_rTltuU1F3<q3^BkZ&_=uH2O^K z_@P51{S(`LSn2c$B(%yIo=USrBfbZd`d}}bPg&0tq#f2!iBZc9Z>I(qTQ^U$S>%$l z)Wx1}R?ij0idp@8w-p!Oz{&*W;v*IA;JFHA9%nUvVDy7Q8woheC#|8QuDZb-L_5@R zOqHwrh|mVL9b=+$nJxM`3eE{O$sCt$UK<z2Kci>^2@L$R(r^-_+z?lOo+me-VW=Zw z-Bn>$4ovfWd%S<yVaGCiuR4}{b|0tZrdZYdj>PY`ab-u9{INc*k2h+yH%toDHIyqQ zO68=u`N}RIIs7lsn1D){)~%>ByF<>i@qFb<-axvu(Z+6t7v<^z&gm9McRB~BIaDn$ z#xSGT!rzgad8o>~kyj#h1?7g96tOcCJniQ+*#=b7wPio>|6a1Z?_(TS{)KrPe}(8j z!#&A=k(&Pj^F;r)CI=Z{LVu>uj!_W1q4b`N1}<u;U_b=ty|*S;DXz*F-)!)F0Pv+Q zRm=!T^zTn*A6)$bH1cl>E(i%;BWjbEcnD=mv$FL$l?zS6bW!{$7j1GR5ocn94P2u{ z70tAAcpqtQo<@cXw~@i-@6B23;317|l~S>CB?hR5qJ%J3EFgyBdJd^fH<ao26B*v) zGUaiB1_W^rk+d9W+h~_tj2D}FfPY~B-BL~)lzp|oFVck~{r8sIIlCCz*!+vHo}=OE zgW`_*^W8W`lLWY+AcSs_rDfwxzeg23BqYRWi$p*e3{sqP3719K#C&l{6X2y_TO;0c zk>Zu7AzHF(BQ!tyAz<BOKd)9J&U=CXtSstlZ^pj1MMKG$H~T%~{<Zzl`|=?>^L0`X z23S4Fe{2X$W0$zu9gm%rg~A>ijaE#GlYlrF9$ds^QtaszE#4M(OLVP2O-;XdT(XIC zatwzF*)1c+t~c{L=fMG8Z=k5lv>U0;C{caN1NItnuSMp)6G3mbahu>E#sj&oy94KC zpH}8oEw{G@N3pvHhp{^-YaZeH;K+T_1AUv;IKD<=mv^&Ueegrb!yf`4VlRl$M?wsl zZyFol(2|_QM`e_2lYSABpKR{{NlxlDSYQNkS;J66aT#MSiTx~;tUmvs-b*CrR4w=f z8+0;*th6kfZ3|5!Icx3RV11sp=?`0Jy3Fs0N4GZQMN=8HmT6%x9@{Dza)k}UwL6JT zHRDh;%!XwXr6yuuy`4;Xsn0zlR$k%r%9abS1;_v?`HX_hI|+EibVnlyE@3aL5vhQq zlIG?tN^w@0(v9M*&L+{_+RQZw=o|&BRPGB>e5=ys7H`nc8nx)|-g;s7mRc7hg{GJC zAe^vCIJhaj<rU9d-Ny$?&}6qolDp7MDNB|ftJI`wi&EhaNhHyV!qQfb_Q>mm7C6g! zL&!WAQ~5d_5)00?w_*|*H>3$loHrvFbitw#WvLB!JASO?#5Ig5$Ys10n>e4|3d;tS zELJ0|R4n3Az(Fl3-r^QiV_C;)lQ1_CW{5bKS15U|E9?ZgLec@%kXr84>5jV2a5v=w z?pB1GPdxD$IQL4)G||B_lI+A=08MUFFR4MxfGOu07vfIm+j=z9tp~5i_6jb`tR>qV z$#`=BQ*jpCjm$F<t%->0+F)L%xRlnS%#&gro6PiRfu^l!EVan|r3y}AHJQOORGx4~ z&<)3=K-tx518DZyp%|!EqpU!+X3Et7n2AaC5(AtrkW>_57i}$eqs$rupubg0a1+WO zGHZ<ibB|u&Jk?tUGE|?~tl5Wk^jlF-{lPR;A5i_2TUJp0F;38(es)rx!d-0-m4P-! z$~|tV-l!W$kj%u&D~eY>KLN2L0D;ab%{_S1Pl<uJj0^JDir_rTS5CizT^_%RU3Cwc zfrHnUz@7T<9U{4O%SD*qhHiuSo|}zv3Hju=+>m|hx8R?O14*w*f&2&bB050n!R2by zw!@XOQx$SqZ5I<(Qu$V6g>o#A!JVwErWv#(Pjx=KeS0@hxr4?13zj#oWwPS(7Ro|v z>Mp@Kmxo79q|}!5qtX2-O@U&&@6s~!I&)1WQIl?lTnh6UdKT_1R640S4~f=_xoN3- zI+O)$R@RjV$F=>Ti7BlnG1-cFKCC(<Cu6MUX_IBo_X35UX_<48O%CsD25V#~38R@v zrtYIkXvf1CPBiwCyX%=srhV;xX%`i(ICDDA6?ULc$>t|Qjm{SalS~V-t<tWc<BV1; zl_jTzT9WFjJ~QeHdyK~l8BJDMnzlnWR?wVpES%e_)7o6AC7w~n8DW>X#+2ekRhwmN zZr`8{QF6y~Z!D|{=1*2D-JUa<(1Z=;!Ei!KiRNH?o{p5o3crFF=_pX9O-YyJchr$~ zRC`+G+8kx~fD2k*ZIiiIGR<8r&M@3H?%JVOfE>)})7ScOd&?OjgAGT@WVNSCZ8N(p zuQ<bOx+QY5+%zcISi3vNjYInP2!bhID0f~o9vf+-rhLL{z1^Ck(#`qsgx*#5swyXT zW>G~76GE3%(%h1*vUXg$vH{ua0b`sQ4f0*y=u~lgyb^!#CcPJa2mkSEHGLsnO^kb$ zru5_l#nu=Y{rSMWiYx?nO{8I!gH+?wEj~UM?IrG}E|bRIBUM>UlY<`T1EHpRr36vv zBi&dG8oxS|J$!zoaq{+JpJy+O^W(nt*|#g32bd&K^w-t>!Vu9N!k9eA8r!Xc{utY> zg9aZ(D2E0gL#W0MdjwES-7~Wa8iubPrd?8-$C4BP?*wok&O8+ykOx{P=Izx+G~hM8 z*9?BYz!T8~dzcZr#ux8kS7u7r@A#DogBH8km8Ry4slyie^n|GrTbO|cLhpqgMdsjX zJ_LdmM<k-VT(PJj^o5EU9N}k~N8WtTC-IGZ`@y!j$sb^<J0;G@Qw2FyCS!3I_NA$4 z1f*^zNnJ-2I0{osRtQts^V?d(XVo8U_KxuKrC4_(t;qw3T5EIb1gaTiFo3yT&HyFL z)1@c>#I&4LqqsOUIXK8gW;V0B(7^$y#h3h>J0k^WJfAMeYek%Y-Dcb_+0zPJez!GM zAmJ1u;*rK=FNM0Nf}Y!!P9c4)HIkMnq^b;JFd!S3?_Qi2G#LIQ)TF|iHl~WKK6JmK zbv7rPE6VkYr_%_BT}CK8h=?%pk@3cz(UrZ{@h40%XgT<XHMp$hv;h9Ymj#=odkk`i zIQae`_=X#DiyU&N400<DsTZGknpd*i59`VZ(hlvyF={mwt^rqfpTI2&<T<g_&ag;) zGmdv3$7_Umm3*d1pC<tD4&^z@fB#qeFKt~J{6LxaG(+}hgg|*Exu--o{)yT#JuFbk z1;57xTR<^av=7#O$H&Fm{_yrRCu{yYJoJ}c0L{yx#X=<de!)a1JlNVp083z$!k1?| zV)^ps1(-LWXGsXjQ$H~1;~fb%PNBhFP?}VP6a;UunlMt9FKF=Z7zjl{Vm5wAE|rFq z1-k1JTp~NHKqro%r!pzCx_v|wc?QzZb;2~gF~c_e(7kI?gTf@Sl;&2zZGHBdXMu10 ze@7*&Fo`5Se_O_wf2XJaP0LvFpQz-YM_~(7W9xrFv6N(8d1OJ9&>hP*-Oeo`T0eq9 zA8BnWZKzCy5e&&_GEsU4*;_k}(8l_&al5K-V*B<vk;3_5Y5GsaQRXITZ7En5+1cr} zoEOfUwTvx(fB#P)g+XFq$3s&MIR%RGTY*w)u1F^x)_4KQF{~iPr`KL;JvOUA!)Bl1 z9p@=a4SF1Po>FM=O~;MgRkYsOs%9eOY6s6AtE*<7GQAR2ulC3RAJrG_P1iQK5Z~&B z&f8X<>yJV6)oDGIlS$Y*D^Rj(cszTy5c81a5IwBr`BtnC6_e`ArI8CaTX_%rx7;cn zR-0?J_LFg*?(#n~G8cXut(1nV<GD8u>F0Oka$A<Xrs+39Fcy_UX4+wJsyL;Ad#|W_ zoZzvm=HX_}HyBFXw08LR4`!>$1FGcERU<^ggx;p@CZc?3UB41RY+wLS`LWFNSs~YP zuw1@DNN3lTd|jDL7gjBsd9}wIw}4xT2+8dBQzI00m<@?c2L%>}QLfK5%r!a-iII`p zX@`VEUH)uj^$;7jVUYdADQ2k*!1O3WdfgF?OMtUXNpQ1}QINamBTKDuv19^{$`8A1 zeq%q*O0mi@(%sZU>Xdb0Ru96CFqk9-L3pzLVsMQ`Xpa~N6CR{9Rm2)A|CI21L(%GW zh&)Y$BNHa=FD+=mBw3{qTgw)j0b!Eahs!rZnpu)z!!E$*eXE~##yaXz`KE5(nQM`s zD<pRzTt9Ga7_+7V*(vgeNjPLq#T#Hzh4oMyk4m^&mDHa-;LXM`BMlpNPVXZiWB!7- zsrLYk0v?{Pinwui>!$vW9XH)iMxu9R>r$VlLk9oIR%HxpUiW=BK@4U)|1WNQ=mz9a z^!KkO=>GaJ!GBXm{KJj^;kh-MkUlEQ%lza`-G&}C5y1>La1sR6hT=d*NeCnuK%_LV zOXt$}iP6(YJKc<sy3IAHEj2Y-R)3MS?rC66If(_;`nr~Onw70}P1hEBm+!itUy1C` zNpHpki6_MC$7{&PcGt_M^XxtUNv`)v*iXj|1|scVAGjs`iL^4oZ_EXmgi;5b%!&n+ ziIZl66eo#;Grax0|H0Th2FKohS;Mhy+qQFJ+qP}z#I|iaIk9cqPEKq)Z~pVlJTr68 zJXP<9U-g%+tE>9j-Fxq~*ItVUqljQ8?oaysB-EYtFQp9oxZ|5m0^Hq(qV!S+hq#g( z?|i*H2MIr^Kxgz+3vIljQ*Feejy6S4v~jKEPTF~Qhq!(ms5>NGtRgO5vfPPc4Z^AM zTj!`5xEreIN)vaNxa|q6qWdg>+T`Ol0Uz)ckXBXEGvPNEL3R8hB3=C5`@=SYgAju1 z!)UBr{2~=~xa{b8>x2@C7weRAEuatC)3pkRhT#pMPTpSbA|tan%U7NGMvzmF?c!V8 z=pEWxbdXbTAGtWTyI?Fml%lEr-^AE}w#l(<7OIw;ctw}imYax&vR4UYNJZK6P7ZOd zP87XfhnUHxCUHhM@b*NbTi#<F%^Z|eYk;z2_lk&RVh7s<F5xO2t#&+J(_Y0MwZqrY zicy(G9z6&?{09>(-8|wcv%3BGNs#zRCVV(W?1Qj6^PPQa<{yaBwZ`+<`w|;rqUY_C z&AeyKwwf*q#OW-F()lir=T^<^wjK65Lif$puuU5+tk$;e_EJ;Lu+pH>=-8=PDhkBg z8cWt%@$Sc#C6F$Vd+0507;{OOyT7Hs%nKS88q-W!$f~9*WGBpHGgNp}=C*7!RiZ5s zn1L_DbKF@B8kwhDiLKRB@lsXVVLK|ph=w%_`#owlf@s@V(pa`GY$8h%;-#h@<I6M_ zmQBeLGoxDbjVm1w<bC@g^^f(C!^JL|q^~JGpu4s;6B8qK+5^Y5Qq9@Z$yi1AO_ivY zEj2e_U?RYgY($+y4MYyPw@#Znh;BBeSCVn{gx>TsO|Y8V=n@*!Rog7<7Cid%apR|x zOjhHCy<N%=9>fbIt%+*PCveTEcuiDi%Wx;O;+K=W?OF<?k#V0K7HZbJn*d3I+eoH# zh7<l$XDBh2e3X@S)0xR1RL*X3v}anZNmTRLP100Vjg6lZsj8M4WV<xI)QGou52W;l zuhA|fVCliO=-1Jr275Sx<Y+M^NR?|nFbw_$)30P*nj@^6+|ZdgJZ18l!}qu2UJUSH z1R0|V6_~e{(m?E}wM3qUG_Q$t&XB<F<iQ0B?7yghy}e3`PL?Jnn_FH7zs7FvV}ZNC zg-k1nj;~OaAd0bVj&A17jIs@8j;@`1Da87%aS+QW)3w-0g!{V<Z8Jk0K;t-e-m_}H z#$DpTS=ZJ!079fqU)_vsqe{Hxz{t*PFrYaR_X{Idl~pteGXU-!HXH+n-Nm@~!Nn|q zjK^|6Bi^u9r}}mzbV)k27Kxg2T1S^j^-m&I%;>UV%)%~6;gl?<0%)?snDDqIvkHF{ zyI02)+lI9ov42^hL>ZRrh*HhjF9B$A@=H94iaBESBF=eC_KT$8A@uB^6$~o?3W<f9 zYtSca#$B|92$2MdaDc&Gb6a^9xjGKojcdPg>m5t1OIaqF^~><2?4e3c&)@wKn9bD? zoeCs;H>b8DL^F&>Xw-xjZEUFFTv>JD^O#1E#)CMBaG4DX9bD(Wtc8Rzq}9soQ8`jf zeSnHOL}<+WVSKp4kkq&?SbETjq6yr@4%SAqOG=9E(3YeLG9dtV+8vmzq+6PFPk{L; z(&d++iu=^F%b+ea$i2UeTC{R*0Isk;vFK!no<;L+(`y`3&H-~VTdKROkdyowo1iqR zbVW(3`+(PQ2>TKY>N!jGm<sD6AnGF=)0Re*7@0HIc_nG=Iw;>Go7oeoB8O|P_!Ic@ zZ^;3dnuXo;WJ?S+)%P>{Hcg!Jz#2SI(s&dY4QAy_vRlmOh)QHvs_7c&zkJCmJGVvV zX;Mtb>QE+xp`KyciG$Cn*0?AK%-a|=o!+7x&&yzHQOS>8=B*R=niSnta^Pxp1`=md z#;$pS$4WCT?mbiCYU?FcHGZ#)kHVJTTBt^%XE(Q};aaO=Zik0UgLcc0I(tUpt(>|& zcxB_|fxCF7>&~5eJ=Dpn&5Aj{A^cV^^}(7w#p;HG&Q)EaN~~EqrE1qKrMAc&W<U9$ z+^)SZeHD%7BgO}J?hdzGer@n(wj6EYe>XIE;>@<&)5;gD2?={Xf@Mvn@OJKw=8Mgn z!JUFMwD+s==JpjhroT&d{$kQAy%+d`a*XxDEVxy3`NHzmITrE`o!;5ClXNPb4t*8P zzAivdr{j_v!=9!^?T3y?gzmqDWX6mkzhIzJ-3S{T5bcCFMr&RPDryMcdwbBuZbsgN zGrp@^i?rcfN7v0NKGzDPGE#4yszxu=I_`MI%Z|10nFjU-UjQXXA?k8Pk|OE<(?ae) zE%vG#eZAlj*E7_3dx#Zz4kMLj>H^;}33UAankJiDy5ZvEhrjr`!9eMD8COp}U*hP+ zF}KIYx@pkccIgyxFm#LNw~<U{`Via^B{gMVX)!#|jCh_xM6ikcrvnXbWa+dwBKVSS zK-4G%y><Bux_?U*2>G&`;o&5)2`5aogs`1~7cMZQ7zj!%L4E`2yzlQN6REX20&O<9 zKV6fyr)TSc<e)zOh9v0_vMgh>JPPzNTC2gL+0x#=u>(({{D7j)c-%tvqls3#Y?Z1m zV5WUE)zdJ{$p>yX;^P!UcXP?UD~YM;IRa#Rs5~l+*$&nO(;Ers`G=0D!twR(0GF@c zHl9E5DQI}Oz7<JMjFG_&ureTdMV@FLw-eP(lgkhig$ECHD*ei9Xc()#hw||V#@>4n zfKP>&$q0($T4y$6w(p=ERAFh+>n%iaeRA%!T%<^+p<H-<uJNd-sy)}Y@~CN#3u5W* zjI&ROZ_Rx}0(a!_QCcJ9_!&~Uc}E0JQd?M`bT;-C9-Z?5E@UMa&Fe8b7GLT8{XPb5 zyf%i|Q0Xl+SI;QD#Yg>g?M)@ucY<&59$x9M#n+V&>}=nO9wCV{O~lg&v#+jcUj(tQ z`0u1YH)-`U$15a{pBkGyPL0THv1P|4e@pf@3IBZS4dVJPo#H>pWq%Lr0YS-SeWash z8R7=jb28KPMI|_lo#GEO|5B?N_e``H*23{~a!AmUJ+fb4HX-%QI@lSEUxKlGV7z7Q zSKw@-TR>@1RL%w{x}dW#k1NgW+q4yt2Xf1J62Bx*O^WG8OJ|FqI4&@d3_o8Id@*)4 zYrk=>@!wv~mh7YWv*bZhxqSmFh2Xq)o=m;%n$I?GSz49l1$xRpPu_^N(vZ>*>Z<04 z2+rP70oM=NDysd!@fQdM2OcyT?3T^Eb@lIC-UG=Bw{BjQ&P`KCv$AcJ;?`vdZ4){d z&gkoUK{$!$$K`3*O-j<Zi{jTPO?eI9$VNEqKns5`DQpeK-2u;%g&U>yM1~<ZC)5l@ zIkr@87e@uhA6MH$K<@Z>p-7T*qb)Ys>Myt^;<CgA4AYJg%~h2T>#1&a%O@x8A+E>! zY<A2w=yT8AF-EkBw>8=eD`ZG)LVagDLBeHg>=atOG?Kr%h4B%E6m@J^C+U|y)XX@f z8oyJDW|9g=<#f<{JRr{y#~euMnv)`7j=%cHWLc}ngjq~7k**6%4u>Px&W%4D94(r* z+akunK}O0DC2A%Xo9jyF;DobX?!1I(7%}@7F>i%&nk*LMO)bMGg2N+1iqtg+r(70q zF5{Msgsm5GS7DT`kBsjMvOrkx&|EU!{{~gL4d2MWrAT=KBQ-^zQCUq{5PD1orxlIL zq;CvlWx#f1NWvh`hg011I%?T_s!e38l*lWVt|~z-PO4~~1g)SrJ|>*tXh=QfXT)%( z+ex+inPvD&O4Ur;JGz>$sUOnWdpSLcm1X%aQDw4{dB!cnj`^muI$CJ2%p&-kULVCE z>$eMR36kN$wCPR+OFDM3-U(VOrp9k3)lI&YVFqd;Kpz~K)@Fa<T;^H>&FRw}L(SoD z9B4a+hQzZT-BnVltst&=kq6Y(f^S4hIGNKYBgMxGJ^;2yrO}P3;r)(-I-CZ)26Y6? z&rzHI_1GCvGkgy-t1E;r^3Le30|%$ebDRu2+gdLG)r=A~Qz`}~&L@aGJ{}vVs_GE* zVUjFnzHiXfKQbpv&bR&}l2bzIjAooB)=-XNcYmrGmBh(&iu@o!^hn0^#}m2yZZUK8 zufVm7Gq0y`Mj;9b>`c?&PZkU0j4>IL=UL&-Lp3j<Z6B(#r?G=5OTIVqppQV7;$oUZ z_*bfNYVfkU+>&47B5pAW4JceG{!XCA)kT<%2nqCxj<)uy6XR_uws~>_MEKPOpAQ!H zkn>FKh)<9DwwS*|Y(q?$^N!6<o$@lIm`~9<Ur$vhpA3&|(gF`!3*mR7y8%R>(51O0 z^JM~Ax{AI1Oj$fs-S5d4T7Z_i1?{%0SsIuQ&r8#(JA=2iLcTN+?>wOL532%&dMYkT z*T5xepC+V6zxhS@vNbMoi|i)=rpli@R9~P!39tWbSSb904ekv7D#quKbgFEMTb48P zuq(VJ+&L8aWU(_FCD$3^uD!YM%O^K(dvy~Wm2hUuh6bD|#(I39Xt>N1Y{ZqXL`Fg6 zKQ?T2htHN!(Bx;tV2bfTtIj7e)liN-29s1kew>v(D^@)#v;}C4-G=7x#;-dM4yRWm zyY`cS21ulzMK{PoaQ6xChEZ}o_#}X-o}<&0)$1#3we?+QeLt;aVCjeA)hn!}UaKt< zat1fHEx13y-rXNMvpUUmCVzocPmN~-Y4(YJvQ#db)4|%B!rBsgAe+*yor~}FrNH08 z3V!97S}D7d$zbSD{$z;@IYMxM6aHdypIuS*pr_U6;#Y!_?0i<Th%7C5rAWptzT;+8 z^q+L14xti*_qZw!_5cZk%7|55dKPFjM~R1_=~fye=uVS6nDqhi)PszECfuz7L#WMP z!r+BR+k)}Z4nk;GJ*^~}T(1)9OIq4cSY?CPrYrC|{k3|*qwa%HsQZRC>|&yU*@16l z*dcMqDQgfNBf}?quiu4e>H)yTVfsp#f+Du0@=Kc41QockXkCkvu>FBd6Q+@FL!(Yx z2`YuX#eMEiLEDhp+9uFqME_E^faV&~9qjBHJkIp~%$x^bN=N)K@kvSVEMdDuzA0sn z88CBG?`RX1@#hQNd`o^V{37)!w|nA)QfiYBE^m=yQKv-fQF+UCMcuEe1d4BH7$?>b zJl-r9@0^Ie=)guO1vOd=i$_4sz>y3x^R7n4ED!5o<f!284U3o`_8>XL3@5**h<Hcp z@=-jpH@#|<>(xr%Hv)_gILarO46q+MaDOF%ChaymKoI6JU5Pg;7#2n9-18|S1;AK+ zgsn6;k6-%!QD>D?cFy}8F;r@z8H9xN1jsOBw2vQONVqBVEbkiNUqgw~*!^##ht>w0 zUOykwH=$LwX<q*Afk5R&Mctbiz>2j&nLy=@{hr)2O&-wm-NyjW7n~Zs9UlH;P7iP3 zI}S(r0YFVYacnKH(+{*)Tbw)@;6>%=&Th=+Z6NHo_tR|JCI8TJiXv2N7ei7M^Q+RM z?9o`meH$5Yi;@9XaNR#jIK^&{N|DYNNbtdb)XW1Lv2k{E>;?F`#Pq|&_;gm~&~Zc9 zf+6ZE%{x4|{YdtE?a^gKyzr}dA>OxQv+pq|@IXL%WS0CiX!V<r8QLM#*MRM`MfrL^ z$oozeKu(Nq#u9g*IQ%|9o}%#$^xUC(KN(HGggqLu?YF|WP9UQsZcqciD65u58u(o> zm$fCePA%lU{%pTKD7|5NJHeXg=I0jL@$tOF@K*MI$)f?om)D63K*M|r`gb9edD1~Y zc|w7N)Y%do7=0{RC|AziW7#am$)9jciRJ?IWl9PE{G3U+$%FcyKs_0Cgq`=K3@ttV z9g;M!3z~f_?P%y3-ph%vBM<NK?r_G3UHy@sF~h!`(w;SXKRHdJ%HcvAvAAT4Qk5qo zMgavqbI6bm$F8YQRbu-Q60TKKDjBrG#E!{k)-_O9;f|9i`Bf)1frR1W)S8zwM~^w3 zgTV=3ki=7ABU#jjo~7zOCe&Y02bU{vhUnu>eS@p7P&Ea8M@97+%XEj*(1E6vHj==d zjsoviB>j^$_^OI_DEPvFkVo(BGRo%cJeD){6Uckei=~1}>sp299|IRjhXe)%?uP0I zF5+>?0#Ye}T^Y$u_rc4=lPcq4K^D(TZG-w30-YiEM=dcK+4#o*>lJ8&JLi+3UcpZk z!^?95S^C0ja^jwP`|{<+3cBVo<?ZqT?*63G^-kLfa79`_!t|3XN?#4oHPT!4lKDlS z&|`dJ%anWz)cD{Izk)|S%W;K%rEUv+1C{y)t{{-Ax56UAAwT>g$(mRdQmadS+Vh~z zS@|P}=|z3P6uS+&@QsMp0no9Od&27O&14zHXGAOEy<!&qoA;BoYe5Gd6XaoKZz3O> zh~OKpymK5C%;LLb467@KgIiVwYbYd6wFxI{0-~MOGf<ue$0Kq0FKpj=H%&N{0wz|# zv3H@VVff^)dR)T~U^4qB#5GIMnxWTKM_`%1WhbW1RX5wx$kbFST`O+(C#(QYZ1qYr zH@an#>Tq$nBTB!{SrW<fHPfZ<Irz+G=%+OcFrK=FGzu_OrmZYz+$?F}3ABX2OV4Ts zsD|%iq->mL9H<Tr^2xz>s}C&l&l#m?s*{tA?BHS4mVKHAVMqm63H<|c5n0~k)-kbg zXidai&9ZUy0~WFYYKT;oe~rytRk?)r8bptITsWj(@HLI;@=v5|XUnSls7$uaxFRL+ zRVMGuL3w}NbV1`^=Pw*0?>bm8+xfeY(1PikW*PB>>Tq(FR`91N0c2&>lL2sZo5=VD zQY{>7dh_TX98L2)n{2OV=T10~*YzX27i2Q7W<Ntl33%|cp7@_XQ%`!?JFZGy3th+( z9`69=1diKn`u=0X>86M4$?gZIXZaBq#sA*{PH8){|GUi;oM>e?ua7eF4WFuFYZSG| zze?srg|5Ti8Og{<A|-y4P^Vw-HZz`B)6iIj60J8RmoI9z56$O?KklBRh#A8wwXksP zIQ{KLcY4jo=L=|_hbacrV%Qq6!Kf@BT^Q2N;#Sl~J=F}P(ian;6LK=Pia-a<t*`EG zsvh#5MX1PG_H?pP{~kcN#Yl5&$-{v%{fm#~)VR&aYV*QjB+l!bVIL2ZIK*(o)mg>O zeFx<XJQO~(UxB;12D)?%fz2kHQ0)}w{#l!~t+-NWg^{L<(tn&kN>uw9!U+zhyk?@w zjsA6(oKD=Ka;A>Ca)oPORxK+kxH#O@<!C?nm{!PyX#FIJ5Fce^7Glx51z4q!IF-9T z(}u{s9F?k?->zhC!!XS4@=swnuMk>t+JmLmFiE^1aX3f<)D@`%K0FGK^gg1a1j>zi z2KhV>sjU7AX3F$SEqrXSC}fRx64GDoc%!u2Yag68Lw@w9v;xOONf@o)Lc|Uh3<2<E zaTlN?EK~WL>1ctTYu-mFZuHk*+R{GjXHIGq3p)tFtQp%TYqD=j1&y)>@zxoxUJ!G@ zgI0XKmP6MNzw>n<q>RxK$-Gbzs}dyfFzt>#5;f6oR27ql!%+{tr+(`(>%51|k`ML} zY4eE)Lxq|JMas(;JibNQds1bUB&r}ydMQXBY4x(^&fY_&LlQC)3hylc$~8&~|06-D z#T+%66rYbHX%^KuqJED_wuGB+=h`nWA!>1n0)3wZrBG3%`b^Ozv6__dNa@%V14|!D zQ?o$z5u0^8`giv%qE!B<jmp7|q6krbi_G>zZ!3j;BlDlJDk)h@9{nSQeEk!z9RGW) z${RSF3phEM*ce*>Xdp}585vj$|40=&S{S-GTiE?Op*vY&Lvr9}BO$XWy80IF+6@%n z5*2ueT_g@ofP#u5pxb7n*fv^Xtt7&?SRc{*2Ka-*!BuOpf}neHGCiHy$@Ka1^Dint z;DkmIL$-e)rj4o2WQV%Gy;Xg(_Bh#qeOsTM2f@KEe~4kJ8kNLQ+;(!j^b<yk)2Ga* z`Da!i<uGd>gJMcNhvklP5Z6I+9Fq@c&D~8Fb-4rmDT!MB5QC{Dsb;BharP*O;SF4& zc$wj-7Oep7#$WZN!1nznc@Vb<_Dn%ga-O#J(l=OGB`dy=Sy&$(5<R7(thvp%XRU@s zvD!RD|Ap4-iUR15Zc?p`W5PfYcwl?pqP=!?Ly8YQJV-_=LA<5F14$ueWH9bhJ%_)w z7V5=v(sMJKH%(v>-n3zzu%d7E#^8`T@}V+5B;PP8J14#4cCPw-SQTdGa2gWL0*zKM z#DfSXs_iWOMt<QB?xsXtvjwXo?shW#x+{vW<m&Lb0}Ev3ll-+Cv)H2yx72H2vfbao z)&}#dlu-#QrCe#mI_dwQZ8aV{yR-kuQpUpp0FeFvxZ;1G7pk86VlScc8cAV!@aPt- zLbAEaXYm_LG-m+FWTqvpGKKfn>)0*+Y>Lkd=LlyoHjublNLefhKBv@JoC>P7N1_#> zv=mLWe96%EY;!ZGSQDbZ<UL<`wO?;KrMew|zF)HM0NF!*C)o2Xb2C86^s8tmic{Un z9|@ov+&qEN=O7wL#??)8qe;19z|E|XhPC!>Wb#;tzqAGgx~uk+-$+2_8U`!ypbwXl z^2E-FkM1?lY@yt8=J3%QK+xaZ6ok=-y%=KXCD^0r!5vUneW>95PzCkOPO*t}p$;-> ze5j-BLT_;)cZQzR2CEsm@rU7GZfFtdp*a|g4wDr%8?2QkIGasRfDWT-Dvy*U{?IHT z*}wGnzdlSptl#ZF^sf)KT|BJs&kLG91^A6ls{CzFprZ6-Y!V0Xysh%9p%iMd7HLsS zN+^Un$<a&ma`v<=_o?VGrwUGJw=O>tDV)T@i!v?3o0Fsx2qI(AX_$dDkBzQ@fRM%n zRXk6hb9Py#JXUs+7)w@eo;g%QQ95Yq!K_<hHRNS7N*hWtqjDKgW@A%kX02U4KOU=O zvdAz#;kT}}XkJ!QzU;bHk8JAq+{_%?pw^sYL|x|PeydT9?IGU9Y51^30i-q1#6m%s zrcB|gv?mw<4R{=sU0@i6UC;{u0I&8wO=u?6r+b>d=z{0dGS+pToEI6=Bo8+{k$7&Z zo4>PH(`ce8E-Ps&uv`NQ;U$%t;w~|@E3WVOCi~R4oj5wP?%<*1C%}Jq%a^q~T7u>K zML5AKfQDv6>PuT`{SrKHRAF+^&edg6+5R_#H?Lz3iGoWo#PCEd0DS;)2U({{X#zU^ zw_xv{4x7|t!S)>44J;KfA|DC?;uQ($l+5Vp7oeqf7{GBF9356nx|&B~gs+@N^gSdd zvb*>&W)|u#F{Z_b`f#GV<S^Bd2~Pyg*5%eHlTUz}@u5PV*7l^~{G24{Qqrx`@++o~ ztboMW3urCbjTB~&;i*a|(eC0qz31>tQ`pYv3#||N{xj1NgB<#=Odt6{eB%#9RLt5v zIi|0u70`#ai}9fJjKv7dE!9ZrOIX!3{$z_K5FBd-Kp-&e4(J$LD-)NMTp^_pB`RT; zftVVlK2g@+1Ahv2$D){@Y#cL#dUj<nH(n~Edj4!|Skt}P+Crb$TxBzD_v2O|_fXDf zyOhDlptC3q?wEW8BD4DYDy}&OXN4=Gin3Clovb(?fZbl&a4@HKR2*IMYDM~#=eox} zr@@VgW<o#eD*<OYTh-Dp*Y9Q~ulW+qlsH#Z{CRf%{8T(JHO1!sg=2L#MeC>9*&%#6 zd2m9{1NYp>)6=oAvqdCn5#cx{AJ%S8skUgMglu2*IAtd+z1>B&`MuEAS(D(<6X#Lj z?f4CFx$)M&$=7*>9v1ER4b6!SIz-m0e{o0BfkySREchp?WdVPpQCh!q$t>?rL!&Jg zd#heM;&~A}VEm8Dvy&P|J*eAV&w!&Nx6HFV&B8jJFVTmgLaswn!cx$&%JbTsloz!3 zMEz1d`k==`Ueub_JAy_&`!ogbwx27^ZXgFNAbx=g_I~5nO^r)}&myw~+yY*cJl4$I znNJ32M&K=0(2Dj_>@39`3=FX!v3nZHno_@q^!y}%(yw0PqOo=);6Y@&ylVe>nMOZ~ zd>j#QQSBn3oaWd;qy<AH3+eYXyF}9Xqs&`j)Js;7`deezzO@(if{@wtUbaD%FKTU} z5w@ZwEG`RDwK>$&5(5H$Ayi)0haAYO6TH<z?6tyk$+6-5SvI&DI1q3_)(4fdZ5Se9 zBbX2Qt9$@_(SU-uL9$`(89L9b^PH`sRc(S;t{a+9w+wmDT>>FR?rhqHmNOO+(})NB zLI@B@v0)eq!ug`>G<@htRlp3n!EpU|n+G+AvXFrWSUsLMBfL*ZB`CRsIVHNTR&b?K zxBg<HRwQH-Pd9_*;+0`Ns@&d6=NP#dv8(oIe;Q1jESZF+juys7ECR&=R-98_-67uO z3$~aVQBexDk*n5GG0IbgeygBsp6J9Ak`^z@J>sN0BjfB>UVcJ|x%=-zb%OV7lmZc& zxiupadZVF7)6QuhoY;;FK2b*qL0J-Rn-8!X4ZY$-ZSUXV5DFd7`T41c(#lAeLMoeT z4%g655v@7AqT!i@)Edt5JMbN(=Q-6{=L4iG8RA%}w;&pKmtWvI4?G9pVRp|RTw`g0 zD5c12B&A2&P6Ng~8WM2eIW<Jfbv|J{U>=wxd?r7A*N+&!Be7PX3s|7~z=APxm=A?5 z<Ct?-npDR`F>t>xB4WG|*Td@VX{Rs)PV0|yK`oI3^xn(4c_j&vgxk_Y3o(-`_5o`V zRTghg6%l@(qodXN;dB#+OKJEEvhfcnc#BeO2|E(5df-!fKDZ!%9!^BJ_4)9P+9Dq5 zK1=(v?KmIp34r?z{NEWnLB3Px{XYwy-akun4F7xTRr2^zeYW{gcK9)>aJDdU5;w5@ zak=<+-PLH-|04pelTb%ULpuuuJC7DgyT@D|p{!V!0v3KpDnRjANN12q6SUR3mb9<- z>2r~IApQGhst<D{{Vf5G$!J9FyJ-E-*A*EoNl_C#=MT@p=Xle}_jo(=^L>Z!3*?5V z8#)hJ0TdZ<kpQa~V_Tj9(@ze|2#~^ENp?G7Jt@Eflo`qt*qnbcmXouacBm8O7C@Os z{Du2beNd5?Za)HL!ZE%zpwg!kyVuKIF9N=(m23`w*$5>g0M-BK#nGFP>$i=qk82DO z7h;Ft!D5E15OgW)&%lej*?^1~2=*Z5$2VX>V{x8SC+{i10BbtUk9@I#Vi&hX)q<DD zB<)Sw)<~oUy)n6wE>Q!LwySI{Bnv%Sm)yh{^sSVJ8&h_D-BJ_YZe5eCaAWU9b$O2c z$T|{vWVRtOL!xC0DTc(Qbe`ItNtt5hr<)VijD0{U;T#bUEp381_y`%ZIav?kuY<v6 zX67v2C6cbIOG)5k!|qC-WsWgOfACm-q$QibFt!z9&vuM4Ra<@t5?aicE~iV+3AvjG zIg1RVRV7=ipka+$YiU^iebt34R#X_ZP(5j+8#<1x%dt8dn8LWW*;+`tjJbED5GP+( z;WzH$-`*O}!b)z&?E7%4&3EtV-<sTj?CD<=L+<P?A5!5CSPGi)w4A<JPgzq;6rR*O zXNdyY$eTaKl$~z~drmdabQ@3giDtf2>G{iyYdEBPW=*xNSc;Rlt6~F4M`5G+VtOjc z*0qGzCb@gME5udTjJA-9O<&TWd~}ysBd(eVT1-H82-doyH9RST)|+Pb{o*;$j9Tjs zhU!IlsPsj8=(x3bAKJTopW3^6AKROHR^7wZ185wJGVhA~hEc|LP;k7NEz-@4p5o}F z`AD6naG3(n=NF9HTH81=F+Q|JOz$7wm9I<+#BSmB@o_cLt2GkW9|?7mM;r!JZp89l zbo!Hp8=n!XH1{GwaDU+k)pGp`C|cXkCU5%vcH)+v@0eK>%7gWxmuMu9YLlChA|_D@ zi#5zovN_!a-0?~pUV-Rj*1P)KwdU-LguR>YM&*Nen+ln8Q$?WFCJg%DY%K}2!!1FE zDv-A%Cbwo^p(lzac&_TZ-l#9kq`mhLcY3h9ZTUVCM(Ad&=EriQY5{jJv<5K&g|*Lk zgV%ILnf1%8V2B0E&;Sp4sYbYOvvMebLwYwzkRQ#F8GpTQq#uv=J`uaSJ34OW<hi?` zP14gpnLmL&7H`p4L%8;nC1<^Sv9p;w<Wt?|_#g6*20U1pM3nHRfS>ITeSGo6+-8Xw znCk*n{kdDEi)Hi&u^)~cs@iyCkFWB2SWZU|Uc%^43ZIZQ-vWNExCCtDW<BUzywAz* z=(vxHv4d&65KOllAq-#$@U{@M^Tie>jqHs;;tWf$v{}0{p0Rvxkq``)*>+Akq%|Na zA`@~-Vfe|+(AIlqru+7Ceh4nsVmO9p9jc8}HX^W&ViBDXT+uXbT#R#idPn&L>+#b6 zflC-4C5-X;kUnR~L>PSLh*gvL68}RBsu#2l`s_9KjUWRhiqF`j)`y`2`YU(>3bdBj z?>iyjEhe-~$^I5!nn%B6Wh+I`FvLNv<KrLHuL|CB_`07?lkcY;`F}7N|9w?h$j;W( z!pz0d;6Gc;=tP?z1|!0VS^mTNfuvL}h&K?b1^iwS6ciDpxQaBY5Gc}49BtNL@wSAH zN-`fR84|MY8{n7xC}ub4B$LcEGUf*6``pjVtH+rgy&k|kpb4%YlJ~9w&{2Xuzeu1M zq`UMUPdX@*+$axeLs?$}*bD{+cnrR~Y#}m-O=_R~Wti_#iWT_s(=ymH^VTEl)dozx zf?Q;WOl1sf3*~dyaUWrzpj(B{DD^$`<1{1iWu+66OaaANG(C3>auve~eX<+Ipl&04 zT}};W&1a3%W?dJ2=N#0t?e+aK+%t}5q%jSLvp3jZ%?&F}nOOWr>+{GFIa%wO_2`et z=JzoRR~}iKuuR+azPI8;Gf9)z3kyA4EIOSl!sRR$DlW}0>&?Gb<F6G*f~S_Xmd?&< zcTp7FM31gIFJU_kMTKVvYfdLV&qNi%10o_cXO}T8c0JA~S7IvYJW{2Mc@QfYQPg<B zM^$g#zoTZ3_m+CrHFauLo}15?s=HP)?JB{H_0#&&1l#xC0PUJEil&(!l3&4$4Oabl z(t*+m#j)cGDV4roRR~6gh^^UTE<ORsZfX<yztwQw%!x#Q2cf-H-Ei*R_oEBu0*M}r zaR*fDOfo;PPv!PbT7OdxPFjDUG&0BrIox?s;EQMl0XBMs85AAF!I(xegz)j_*&}F& zVxM4&whWS?c~-!t@$rAV{-U7+LEraxy0yVT-$%W;DztTaLwSHk;hmJtJCfzd$8ZUU zM49WF9Y$VtKqVmyl%^86>gPojmj<IO9KHY)gcrh=qY_}jG!}())PpS;BXBA!e*hSR zilZbQ&4Xd81)(e#05gdbS)_Rc7=w(fM<O8%<WUPqvy2OZsgKBL!XxkiWU2;{7$;C6 z9R+3;R|H$*pUT7|00m@1wlw|z2Pi1^Q3j9tQwHGtFdr%Y_fp{BLtn(*#K`48rPtM- zeUXnbzjJ6`4-eFtz^q{qhyCKLVL%|Li&oS2mxY?F!w9Q6rOe*>mnln;cTqCt=ADbE zZ8GAnoM+S1(5$i8^O4t`ue;vO4i}z0wz-QEIVe5_u03;}-!G1NyY8;h^}y;tzY}i5 zqQr#Ur3Fy8sSa$Q0ys+f`!`+>9WbvU_I`Sj;$4{S>O3?#inLHCrtLy~!s#WXV=oVP zeE93*Nc`PBi4q@%Ao$x4lw9vLHM!6mn3-b_cebF|n-2vt-zYVF_&sDE--J-P;2WHo z+@n2areE0o$LjvjlV2X7ZU@j+`{*8zq`JR3gKF#EW|#+{nMyo-a>nFFTg&vhyT=b} zDa8+v0(Dgx0yRL@ZXOYIlV<CBxH&myw-{Mgw|3Z>SZ0|MFizy0VPW8;AfA5|pe!#j zX}Py^8fl5SyS4g1WSKKtnyP+_PoOwMMw<ZOz9;D51vwfT?(7P{J9uC+df4xbrr2zV zB?xsPrQn{*Mv;KOgSS&5@+LuXkea1)Zq>u`(i@Z)diJp~U54*-miOchy7Z35eL>^M z4p<-aIxH4VUZgS783@H%M7P9hX>t{|<kmOhY+3PLOlAj^>RU7$n4T(brCG#h9e9p! z+o`i;EGGq3&pF;~5V~eBD}lC)>if$w%Vf}AFxGqO88|ApfHf&Bvu+xdG)@vuF}Yvk z)o;~k-%+0K0g+L`Wal<EEr#pT9&cB!85zTR)doGHYQA;Y4iG{j=~O<Gh^(9@<v8&o z)RDtDdQW7hx3ZX>a!$=ZV|z$e%>f0%XoLib%)!R^RoS+{!#X?h-<kGFCJU+*P>6uu zF&&KxORdZU&EwQFITIRLo(7TA3W}y6X{?Y%y2j0It!ekU#<)$qghZtpcS>L3uh`Uj z7GY;6f$9qKynP#oS3$$a{p^{D+0oJQ71`1?OAn_m8)UGZmj3l*ZI)`V-a>MKGGFG< z&^jg#Ok%(hhm>hSrZ5;Qga4u(?^i>GiW_j9%_7M>j(^|Om$#{k+^*UL<NK}BVp#gd zBD%KcbS)b{|CXjYx_P3_eGPFMMz1_Nk540?Us_HPBMy;cERLy0SxfOpX5xzV&cl#) zt&&6pjk~F}sDOyA9s+XeyTf%9%I=S3nCcDp2Af;QpT#R>nEgzW_1gCICtAD^WpC`A z{9&DXkG#01Xo)U$OC(L5Y$DQ|Q4C6CjUKk1UkPj$nXH##J{c8e#K|&{mA*;b$r0E4 zUNo0jthwA(c&N1l=PEe8Rw_8cEl|-eya9z&H3#n`B$t#+aJ03RFMzrV@gowbe8v(c zIFM60^0&lCFO10NU4w@|61xiZ4CVXeaKjd;d?sv<gv6;u6elr3xr~p;CLrK1=+ZBw z$Y<W|q#kt(G{*D&Qkw-trQSG}>52XM*lS8XiVjgWpRB;&U_C0g+`6B5V&w|O6B*_q zsATxL!M}+$He)1eOWECce#eS@2n^xhlB4<_Nn?yCVEQWDs(r`|@2GqLe<#(|&P0U? z$7V5IgpWf09uIf_RazRwC?qEqRaHyL?iiS05UiGesJy%^>-C{{ypTBI&B0-iUYhk> zIk<5xpsuV@g|z(AZD+C-;A!fTG=df1=<%nxy(a(IS+U{ME4ZbDEBtcD_3V=icT6*_ z)>|J<Y(kIYsG`z6gyX&}qOiN*?9OL086m5X{hq6Ig4#W@iNENBRsw2^%kU7#L2|pp zfdZ~o%7N|AM$3+2Q82nCBw2dK>?>&6%nvHhZERBtjK+s4xnut*@>G<VN!iYC0JC0F z7C4lQZ~9^GNSctWumcZ#1X037wz24bZkA{*jo3r`%UXDS^K_+8hV8{gw8t#vVSJ;% z9c?Ez0!rvl#uwpap|4<*d9UuKSjWn24OFIi&rE}!=sVnu59lQ~@4HIl&h;AmLia?2 zUL-?{m1bz0QDnL&$JD)qE4*iTL#%6dSw8A41Gt4doXWr-`$rleCkVKi0#mfRM!)mY zIRoubkmw30NIQhtCJYLo7$LOA<y9EL8N*i`%g_PIUD8SHni$er{_LTTN*Wqfx<buS zb$(_9;wX-c*`xTSvy6C@PDNyy2YoKV;`dWQKiLwa26g^kXh`=t6PlPNT~Q9So~U*X z3nx6eVXazyi3=nJQw6@jVna;b;RTqKnF0f_=;<}6<0^@)YUgQHPBXXoP$FjaOB^=t zP%99lNT=pyPA&53Wj%hQT>AmA5m*OTp$!^CHTr}vM4n(X1Q*;{e-Rd2BCF-u@1ZGm z!S8hJ6L=Gl4T_SDa7Xx|-{4mxveJg=ctf`BJ*fy!yF6Dz&?w(Q_6B}WQVtNI!BVBC zKfX<>7vd6C96}XAQmF-Jd?1Q4eTfRB3q7hCh0f!(JkdWT5<{iAE#dKy*Jxq&3a1@~ z8C||Dn2mFNyrUV|<-)C^_y7@8c2Fz+2jrae9deBDu;U}tJ{^xAdxCD248(k;dCJ%o z`y3sADe>U%s<JWQeHbE;y_*tD>uxwwv~8A1+R$VB=Q?%U?4joI$um;aH+eCrBqpn- z%79D_7rb;R-;-9RTrwi9dPlg8&@tfWhhZ(Vx<DUpdj8XcHW`q{6yqiIzcmGmhsxE( z@-zez1_>&1PQ+6(huX`;M9x~LrW~~#3{j0Bh2kDU$}@!fFQej4VGkJv?M4rU^x!RU zEwhu$!CA_iDjFjrJa`aocySDX16?~;+wgav;}Zut6Mg%C4><PTsD!{kIiIRy9=1Aw zY;M8Eqj=FH#bx<r<6@cBrQ0UMw6e8^v|*8L6vZKlp_PoKF*td|-B1-bh(bXl;KGoy ze|2jEKeeu5MGO@KpjsEha>}8FL?8)Kgwc(Qlj{@#2Pt0?G`$h7<q&K~4OS{ORv@L7 z>P#M+qoXtlV@d}%c&OzO+QYKK`kyXaK{U(O^2DyIXCZlNQjt0^8~8JzNGrIxhj}}M z&~QZlbx%t;MJ(Vux;2tgNKGlAqphLq%pd}JG9uoVHUo?|hN{pLQ6Em%r*+7t^<);X zm~6=qChlNAVXNN*Sow->*4;}T;l;D1I-5T{Bif@4_}=>l`tK;qqDdt5zvisCKhMAH z#r}`)7VW?LZqfdmXQ%zo5bJ00{Xb9^YKrk0Nf|oIW*K@(=`o2Vndz}ZDyk{!u}PVx zzd--+_WC*U{~DH3{?GI64IB+@On&@9X>EUAo&L+G{L^dozaI4C3G#2wr~hseW@K&g zKWs{uHu-9Je!3;4pE>eBltKUXb^*hG8I&413)$J&{D4N%7PcloU6bn%jPxJyQL?g* z9g+YFFEDiE`8rW^laCNzQmi7CTnPfwyg3VDHRAl>h=In6jeaVOP@!-CP60j3+#vpL zEYmh_oP0{-gTe7Or`L6x)6w?77QVi~<VDx`fw;L`VTJ3A_($%0y%60sa5-^3a6v=d zmmak>jD8lWN@3RHcm80iV%M1A!+Y6iHM)05iC64tb$X2lV_%Txk@0l^hZqi^%Z?#- zE;LE0uFx)R08_S-#(wC=dS&}vj6P4>5ZWjhthP=*Hht&TdLtKDR;rXEX4*z0h74FA zMCINqrh3Vq;s%3MC1YL`{WjIAPk<IG3Wg4CtfDQ*vr2eaPwC^O8I68!!&nV4u+r_F z?Axith|qN&KZ99L>VL#3rj^9Pj9Ss7>7duy!9H0vYF%>1jh)EPqvlr6h%R%CxDsk| z!BACz7E%j?bm=pH6Eaw{+suniuY7C9Ut~1cWfOX9KW9=H><&kQlinPV3h9R>3nJvK z4L9(DRM=x;R&d#a@oFY7mB|m8h4692U5eYfcw|QKwqRsshN(q^v$4$)HgPpAJDJ`I zkqj<O$?%~S@(l(Nhxp_sGA`$A%xJ7kG%+@Mg|5Rt6m|$*ZUs_O&tJj;*GHA8ccUky ztdHVq5%mDvVz0+qo6-yO?&N>q(8Cd!K!+wCd=d@w%~e$=gdUgD&wj$LQ1r>-E=O@c ze+Z$x{>6(JA-fNVr)X;*)40Eym1TtUZI1Pwwx1hUi+G1Jlk~vCYeXMNYtr)1?qwyg zsX_e*$h?380O0<gk^3K^_@6N5|2rtFRkhT9jP1T;nZ|k<{vtt&=cCpw1{DoTNi|6* zNR~&3M3w}sVsT4{Fd&%jPlD{py<BN_zia9hI9Ip7U3}jPq_+H)r9(in7-!PpJihq- zw0+F|?s|8=Bj*cv!`7|95cO>0ou?0R@7-Fc59o$UvyVs4cUbujHUA>sH!}L54>`e` zHUx#Q+Hn&Og#YVOuo*niy*GU3rH;%f``nk#NN5-xrZ34NeH$l`4@t);4(+0|Z#I>Y z)~Kzs#exIAaf--65L0U<!Yaeav3)qH(`Lb>HT_SvV8O2WYeD>Mq^Y6L!Xu8%vnp<f zF-ML?EA-QLj_KdFHPDT9@{&CgOfV=>ofG@w!}R7M28?i1*T&zp3X4^OMCY6(Dg<-! zXmcGQrRgHXGYre7GfTJ)rhl|rs%abKT_Nt24_Q``XH{88NVPW+`x4ZdrMuO0iZ0g` z%p}y};~T5gbb9SeL8BSc`SO#ixC$@QhXxZ=B}L`tP}&k?1oSPS=4%{UOHe0<_XWln zwbl5cn(j-qK`)vGHY5B5C|QZd5)W7c@{bNVXqJ!<MH|d3P$j9&VqkPfJu|wcu=-=7 zLO~2}`PnP-On3_NOs4{hJ&4M7x*M^%g^_s4zus|ykus<=xkIi<3#(~DK73KtIuaza zq3Ok{j4$g8v7e>!n$^ufc?N9C-BF2QK1(kv++h!>$QbAjq)_b$$PcJdV+F7hz0Hu@ zqj+}m0qn{t^tD3DfBb~0B36|Q`bs*xs|$i^G4uNUEBl4g;op-;Wl~iThgga?+dL7s zUP(8lMO?g{GcYpDS{NM!UA8Hco?#}eNEioRBHy4`mq!Pd-9@-97|k$hpEX>xoX+dY zDr$wfm^P&}Wu{!%?)U_(%Mn79$(ywvu*kJ9r4u|MyYLI_67U7%6Gd_vb##Nerf@>& z8W11z$$~xEZt$dPG}+*IZky+os5Ju2eRi;1=rUEeIn>t-AzC_IGM-<V%6qUamagiO zojXi&7L8*IWlJ}@8}`JxyIBN$u|oRzm@w!)u;`JJ^U^Jm{ZDgyeTstN3t7NGnWGqQ z+EIa8gC_h)Zo3JN!q<7m%o03s)htD&NG?lXEop9Gx?#VaLBB3SgcFmh3F=U430hIe z6>IXWK3^6QNU+2pe=MBn4I*R@A%-iLD<B3fBYdI`-^xCP#WQb@1Qb6Wc1|7$rrI$0 zS^}=1l87G|X!N)P#&2-ZEFApzIK5kn5Gfb61a_<ma9;3$9f;mz8FokB;4w(Y@!^ya zu;TjxPaQyO7n{FcIlqEr<3P?P4i)3rOGc5I4};xC;qQa&0^@jzv_HUBAQxZ^AEFm5 zBTlS@?>COHTE-O^wo$sL_h{dcPl=^muAQb`_BRm};=cy{qSkui;`WSsj9%c^+bIDQ z0`_?KX0<-=o!t{u(Ln)v><gH1Bl$-3LHEdi^mWSpxtdQaHDvah(~iGD|9egFyL#F~ z?1$GP^^-sUm)!YZ{+<LJ&0K7L{O*24r%mkt6Kzb6pOOS-L<yFAV=ioJ*0iBM>%VGL z0pC=GB7*AQ?N7N{ut*a%MH-tdtNmNC+Yf$|KS)BW(g<Pdkm%)j?It(+K5~xj$IK!{ zp+6C1gYJS33v`?Xo$~~77W&2eyGU19<m6FlNMw~43kikpZyKWVZ@N_*wAeJPS%sqH z40>QJ*z$d{+{j?(e&hgTy^2|AR9vx1Xre2fagGv0YXWqtNkg*v%40v?BJBt|f9wX5 z{QTlCM}b-0{mV?IG>TW_BdviUKhtosrBqdfq&Frdz>cF~yK{P@(w{Vr7z2qKFwLhc zQuogKO@~YwyS9%+d-zD7mJG~@?EFJLSn!a&mhE5$_4xBl&6QHMzL?CdzEnC~C3$X@ zvY!{_GR06ep5;<#cKCSJ%srxX=+pn?ywDwtJ2{TV;0DKBO2t++B(tIO4)Wh`rD13P z4fE$#%zkd=UzOB<aY_AGMe|>7<sX$yw1SM(554I3!t(H9Z3X!Z5et+k4_h&Thynx! ziTh5;c|GaYsH@W32G0xdhr&KvFp@Nsj=Jm7RQGd8dhSlG9l$e(+pLSdq<w-kZ7W-l z4}DfW=)F=&(6?c$=|K`W>4gi=-*CuID&Z3zI^-`4<B}t|X$V`AU_-K%EPFL_9twl_ zImK{&j6sLpH7WP2;B>U^S?dHxK8fP*;fE|a(KYMgMUo`T<LMf=xb>HIS1f!*6dOI2 zFjC3<eBkTsS?s~jirYF@Nrja2AilB1Grgok(Q{J!-yMdCbS>O=-AL`<v=HnY;qaP= z%r%pyN;W|G{!13k5HgL{&1MZLLmT$ii@iPy4pvmSTAaNGh_&&=IOx}7|G?LkOre&5 ze$)ZX{wLMvAK$Ose+l($RV_DU6^yOKl5CcA-LOWBeC<Ob3qkCr#=PoHO}Zi7*=4i{ zhy`P5@RD?fbbG{Ww-4~nmy*sK0y=*cjfuaB@ZGQIADDEhtuw}4>6=9pp;`CYPTdVX z8(*?V&%QoipuH0>WKlL8<OkjufLT@|_$itWfOgQQ1sL1my9jh5H&Aqu0?B`H1h6nM zGosuE%;~qrp)5qiJt5TkvuY!P5&J7R#=`qhgmgE%$-tyU1wv>A*zTKckD!paN@~hh zmXzm~qZhMGV<pYA7aBO1=rl8ewvtPYii`7)>dQGd=AG8&20HW0RGV8X{$9LldFZYm zE?}`Q3i?xJRz43S?VFMmqRyvWaS#(~Lempg9nTM$EFDP(Gzx#$r)W&lpFKqc<y(GB zQ)iBAS`N=DS_sKSXI55P(lHkwH)@Q89oTUc{-vd@Ue^>AoJh-AxEw$-bjW>`_+gEi z2w`99#UbFZGiQjS8kj~@PGqpsPX`T{YO<m!OQBo2H4HfqK(14BF?XJ?$*LTUG|=XE z%mXZ(W0fCU1)eodty#XEHD<Wgj8lxa(h;^9fW-UTt2UUeP&QC*>j`CaEqTFag;$jY z8_{Wzz>HXx&G*Dx<5skhpETxIdhKH?DtY@b9l8$l?UkM#J-Snmts7bd7xayKTFJ(u zyAT&@6cAYcs{PBfpqZa%sxhJ5nSZBPji?Zlf&}#L?t)vC4X5VLp%~fz2Sx<*oN<7` z?ge=k<=X7r<~F7Tvp9#HB{!mA!QWBOf%EiSJ6KIF8QZNjg&x~-%e*tflL(ji_S^sO ztmib1rp09uon}RcsFi#k)oLs@$?vs(i>5k3YN%$T(5Or(TZ5JW9mA6mIMD08=749$ z!d+l*iu{Il7^Yu}H;lgw=En1sJpCKPSqTCHy4(f&NPelr31^*l%KHq^QE>z>Ks_bH zjbD?({~8Din7IvZeJ>8Ey=e;I?thpzD=zE5UHeO|neioJwG;IyLk?xOz(yO&0DTU~ z^#)xcs|s>Flgmp;SmYJ4g(|HMu3v7#<SE)AQQ4Qptn#EiMmi^p+Ei-7DkP+HtEnTf z=ihEK*Qz1Ztg&vmf~iSIJvy;UM*1)`@_lXyy`^7!LwVESw8;X~PDDY`x##uAQg`Ks z=exD#_B;<%p^vKWmo{iCKEmMLveyeSGEVy6BOKJM`)TA4((F&!tIH99l|b!Je=-ox zTnV0#B=>;c*Aa8iF#UZo7CvDq4>8#qLJ|YdZ!AsH%^_7N1IQjCro<P>K7UpUK$>l@ zw`1S}(D?mUXu_C{wupRS-jiX~w=Uqqhf|Vb3Cm9L=<ElBA0am3@5~_j4>T+w91Cu^ z*&Ty%sN?x*h~mJc4g~k{xD4ZmF%FXZNC;oVDwLZ_WvrnzY|{v8hc1nmx4^<U$9gKZ zXD;2On@pVz(zumYHw-cDICc(jj*0Lh@n}B_NB#b3S3Y`joU*Hg4D^tTlnR`X)wKiE z<1(Wo3^4=KAZ|hOq%hItOAv2Yuv6FZW5>}Z;yriXsAf+Lp+OFLbR!&Ox?x<j>ABwl zu8w&|5pCxmu#$?Cv2_-Vghl2LZ6m7}VLEfR5o2Ou$x02uA-%QB2$c(c1rH3R9hesc zfpn#oqpbKuVsdfV#cv@5pV4^f_!WS+F>SV6N0JQ9E!T90EX((_{bSSFv9ld%I0&}9 zH&Jd4MEX1e0iqDtq~h?DBrxQX1iI0lIs<|kB$Yrh&cpeK0-^K%=FBsCBT46@h<vzQ zR*F0_2h6Z11Y9Zzju}{_b;!1TT<IfdPup&BfzJrm?bo16`)8Tm1upGj;JDjh>#<Oy z8t{(&!50W|bIsS-1avP5+*F7gID<ta6$a=EvLN_Gn#i|E*V;GvV|e9HOQ9zf{;k4c z6E1|)o(`{GoSr=usmv~(8ivQq9h)k@J&LDI;_p0b!?ZsmFktoKIgcIE+yq!s*%$%) z#ay6sWeCZZ>yi!AyDq1V(#V}^;{{V<B^hU0(%wtueKZDT`@J-_3mSLDBmaq~d|XD< z7XM`Dzdz4EMpggFBJu`~KkDTFp@A11CGaoLc9C!MrY0!YzwKdGfx7t=5D@|7VVCk+ z^B@Uld)n>*@T4WJ&U-NTq43w=|K>z8%pr_nC>%C(Wa_l78Ufib$r8Od)IIN=u>417 z`Hl{9A$mI5A(;+-Q&$F&h-@;NR>Z<2U;Y21>>Z;s@0V@SbkMQQj%_;~+qTuQ?c|AV zcWm3XZQHhP&R%QWarS%mJ!9R^&!_)*s(v+VR@I#QrAT}`17Y+l<`b-nvmDNW`De%y zrwTZ9EJrj1AFA>B`1jYDow}~*dfPs}IZMO3=a{Fy#IOILc8F0;JS4x(k-NSpbN@qM z`@aE_e}5{!$v3+qVs7u?sOV(y@1Os*Fgu`fCW9=G@F_#VQ%xf$hj0~wnnP0$hFI+@ zkQj~v#V>xn)u??YutKsX><O0LtJ*CTAYt%cbdwrkH)-u>pxKCl^p!C-o?+9;!Nug^ z{rP!|+KsP5%uF;ZCa5F;O^9TGac=<e#17!Ngq|n{jbexb$Js+_Pf5VvAs8ZxqWBs8 zB0&S13$r1<F)VBk$e5stmC}hMzOSHBA0t(kR!@rbH|QU6*IH0=h|_OeT6wpDmdv23 z34f|!XhwEd(Kkc%FUYK@H{gz+bBn7dd5Fl+jfYh}4;N_qJ7#NlT2O+)R4Pxe>M|=V z_H(PfkV1rz4jl?gJ(ArXMyWT4y(86d3`$iI4^l9`vLdZkzpznSd5Ikfrs8qcSy&>z zTIZgWZGXw0n9ibQxYWE@gI0(3#KA-dAdPcsL_|hg2@~C!VZDM}5;v_Nykfq!*@*Zf zE_wVgx82GMDryKO{U{D>vSzSc%B~|cjDQrt5BN=Ugpsf8H8f1lR4SGo#hCuXPL;QQ z#~<Fi-k$qL|7RVJ1Mm1Ag<$gB0(}Grl3S(KpYI)5f?u3vC7#V;7XHhGB(-?~L6q0H z3n(8fq$!!dl{5-8`7Oj@C;&S?r8OD`wLCHxqX2ux!ard>b?C4MoepT3X`qdW2dNn& z<Us;@%o6IHN`m_LE5@ycr7sZ5iN)2(z?4TB28{O!&JjxPf+H!xrjo)qWAjZifsc&} zRUE<g)K;7gXjcN#UR5gfzb2nN2z_KhXjz-ToEUcM{^o{zAJg2QnR<7bsn(YUNIKF@ z%f>o8)K}%Lpu>0tQei+{<z4bzXqy$)s2?v=pRVAvTjJpopW)-K>>*VGErz|qjbK#9 zvtd8rcHplw%YyQCKR{kyo6fgg!)6tHUYT(L>B7er5)41iG`j$qe*kSh$fY!PehLcD zWeKZHn<492B34*JUQh=CY1R~jT9Jt=k=jCU2=SL&&y5QI2uAG2?L8qd2U(^AW#{(x zThSy=C#>k+QMo^7caQcpU?Qn}j-`s?1vXuzG#j8(A+RUAY})F@=r&F(8nI&HspAy4 z4>(M>hI9c7?DCW8rw6|23?qQMSq?*Vx?v30U%luBo)B-k2mkL)Ljk5xUha3pK>EEj z@(;tH|M@xkuN?gsz;*bygizwYR!6=(Xgcg^>WlGtRYCozY<<HB2ZgQ)h*70uL|pZ2 z>rFX2E>kaZo)O<^J7a`MX8Pf`gBd4vrtD|qKn&B)C&wp0O-x*@-|m*0egT=-t@%dD zgP2D+#WPptnc;_ugD6%zN}Z+X4=c61XNLb7L1gWd8;NHrBXwJ7s0ce#lWnnFUMTR& z1_R9Fin4!d17d4jpKcfh?MKRxxQk$@)*hradH2$3)nyXep<KqaI3?gC#eK%i>5Z;B z?yX+-Bd=TqO2!11?MDtG0n(*T^!CIiF@ZQymqq1wPM_X$Iu9-P=^}v7npvvPBu!d$ z7K?@CsA8H38+zjA@{;{kG)#AHME>Ix<711_iQ@WWMObXyVO)a&^qE1GqpP47Q|_AG zP`(AD&r!V^MXQ^e+*n5~Lp9!B+#y3#f8J^5!iC@3Y@P`;FoUH{G*pj*q7MVV)29+j z>BC`a|1@U_v%%o9VH_HsSnM`jZ-&CDvbiqDg)tQEnV>b%Ptm)T|1?TrpIl)Y$LnG_ zzKi5j2Fx^K^PG1=*?GhK;$(UCF-tM~^=Z*+Wp{FSuy7iHt9#4n(sUuH<I4zjuT^*g zTOj)T0YzMUS}8lWAh=#M-<;W0su6sG+MC}XSqZj-`H_&HD?2Z|qE8QdIM(X#NffsX zuwQ<4gm=;OFM%U76&;OT!|+5x?B#F>K??@v+6*|10Csdnyg9hAsC5_OrSL;jVkLlf zHXIPukLqbhs~-*oa^gqgvtpgTk_7Gy<tuXYyQ=x)UZpuI%V@LXq4|I6hehHuRD*Qk z+@3~62V_$Rc&ax*Fe`-_r5e~NV8Dh7^Qkc47_lIobqdmfrCP}{2YGKf1@Bh!U!R!l zVh{a+_vh}x%?yACZT@@6M}$iwzkin{Cukrb?*D6_{O__PU~FPz|81A`Ki4JuH6?`4 zsEYmO+F8y*acunUpGD%Hp$-=5%jS}CI-%I;>pwH><53riYYL*M=Q@F-yEPLqQ&1Sc zZB%w}T~RO|#jFjMWcKMZccxm-SL)s_ig?OC?y_~gLFj{n8D$J_Kw%{r0oB8?@dWzn zB528d-wUBQzrrS<hDznyNCgL-qOb|p_nS%fSb5_Ze4V1&Bgk3Vp>SL<Uo&kY+&uyM z)HW-LQUfYYW$q$nSg9;!DvA>q?fR!K%59Zv9J4yCQhhDGwhptpA5O5U?Hjqt>8nOD zi{)0CI|&Gu%zunGI*XFZh(ix)q${jT8wnnzbBMPYVJc4HX*9d^mz|21$=R$J$(y7V zo0dxdbX3N#=F$zjstTf*t8vL)2*{XH!+<2IJ1VVFa67|{?LP<VPiuN~9#0c!YR12a zo;Lj{EKN(4nyG=Ui#tn@dJ(*;md5Ze)kd6mTF$dEZFv=nYr2Lr(==N%ad&FBajgz2 z6_^{6U)eu_=!Ix#bP)AA_tuIc?3=_Mq548F*;t3>&P41h$2i2;?N~RA30LV`BsUcj zfO9#Pg1$t}7zpv#&)8`mis3~o+P(DxOMgz-V*(?wWaxi?R=NhtW}<#^Z?(BhSwyar zG|A#Q7wh4OfK<|DAcl9THc-W4*>J4nTevsD%dkj`U~wSUCh15?_N@uMdF^Kw+{agk zJ`im^wDqj`Ev)W3k3stasP`88-M0ZBs7;B6{-tSm3>I@_e-QfT?7|n0D~0RRqDb^G zyHb=is;IwuQ&ITzL4KsP@Z`b$d%B0W<OzS}HhQ;U8}SDa=0^@=1zh?Ebwi(h3u<mG zLDfZ57#t!A)c-~Y!O{*PdxzDz>uhioo1CWttW8yhsER1ZUZzA{F*K=wmi-sb#Ju+j z-l@In^IKnb{bQG}Ps>+Vu_W#grNKNGto+yjA)<V{IjYd_d3*YH={C)J_}OyeI6i_H zu|j9WcfZFU?Sgyac7umALud#l=-0F(fRl)O28qU<%HpKBh#im&8;uWW953cFj~Iy& zlr`ZkkzG;HtUSI4CRZRj#8C;N*{hU?ezHzcM|gV*@iklj`$SU@402&viLPBGiWFL2 zQ6uyKx<>?>0?~X`4I3T@5G1)RqGUZuP^NJCq&^HykuYtMDD8qq+l8RcZNJsvN(10{ zQ1$XcGt}QH-U^WU!-wRR1d--{B$%vY{JLWIV%P<Wi12|;({s?oRC80?)@wj6UxV~n zk2qyqDc_<v8oEujhH&m^Jq#Lrp)Z$Oj#rxlpUicHLpt@6dZF=U?wW~T_o?IN;ie!I zV-bY1+kei0nc9KpW>4-KQuxxDeJaF#{eu&&r!3Qu{w}0f--8^H|KwE>)ORrcR+2Qf zb})DRcH>k0zWK8@{RX}NYvTF;E~phK{+F;MkIP$)T$93Ba2R2TvKc>`D??#mv9wg$ zd~|-`Qx5LwwsZ2hb*Rt4S9dsF%Cny5<1fscy~)d;0m2r$f=83<->c~!GNyb!U)PA; zq^!`@@)UaG)Ew(9V?5ZBq#c%dCWZrplmuM`o~TyHjAIMh0*#1{B>K4po-dx$Tk-Cq z=WZDkP5x2W&Os`N8KiYHRH#UY*n|nvd<ovggige3u`1qGi1+Y8X!3s{W#*m=tX&CV zNWQ(*z*>(U>yO=MFI-2BEp?x@=N<~CbLJBf6P)}vLS?xJXYJ2^<3KJUdrwKnJnTp{ zjIi|R=L7rn9b*D#Xxr4*R<3T5AuOS+#U8hNlfo&^9JO{VbH!v9^JbK=TCGR-5EWR@ zN8T-_I|&@<P6ysJp1u%bVccl?q?sU4Onn?IFII0`6;jp*_+1Vcjf$mX{%JA^!$Gkf z>A}(hKeL4_*eb!1G8p~&_Im8|wc>Cdir+gg90n1dw?QaXcx6Op_W1r=axR<Rt0$d- z&gdORS`9;Z%6j=d$PU%VL0xT-jF-dHo&#w}>w>4;rM*UOpT#Eb9xU1IiWo@h?|5uP zka>-XW0Ikp@dIe;MN8B01a7+5V@h3WN{J=HJ*pe0uwQ3S&MyWFni47X32Q7SyCTNQ z+sR!_9IZa5!>f&V$`q!%H8ci!a|RMx5}5MA_kr+bhtQy{-^)(hCVa@I!^TV4RBi zAFa!Nsi3y37I5EK;0cqu|9MR<Vh>j<^r&h1lF}u0KpKQD^5Y+LvFEwM<n%Y4Ns0&r z#Pgp7tfaM#i}k;d-@gi@qNBc}@xL(OgxbkB%Zc*U!8(yY_d_z4QrJ%DIL^_}pG(C; zxV&Dt0*#6mW+VnKpUKH&)*t(_EhJ1#-d4~Kom-)N+kGAW3vl$z=E{EB!4#iw1#JGZ zpZv7B?(+0N;`4s@&;+D$6BOaTPLlV-MY35`gn~5zS!mCgh|W$2sr@*jRa}74{|6)> zLU@@v4_Na#Axy6tn3P%sD^5P#<7F;sd$f4a7L<t5V42bo`*JV+&3HWm9OI@30?%Oh z5o+B(*v(C-H_!6}Lzhp-kE~j|H(u&BA@KX~k?60QV5NR)N2OJYIOG(f(FG`kmvdU7 zwM#zp&<w6$6785wBe4}t?5yT4MP5N47S8;*P_q6hn|Wj2S~%IPE(O9P2?RAKY>BMk zGU^RZHBcxSA%kCx*eH&wgA?Qwazm8>9SCSz_!;MqY-QX<1@p$*T8lc?@`ikEqJ>#w zcG``^CoFMAhdEXT9qt47g0IZkaU)4R7wkGs^Ax}usqJ5HfDYAV$!=6?>J6+Ha1I<5 z|6=9<InPN?WUE(MBZF{EDJ@lH!7KCH_7xC@JvfZL@&nc!7K2v>soU4>E))tW$<#>F ziZ$6>K<f#VmS*<VCLk5Snrr-`d{Bp+A{=r<v#~0tw_zC-WYWg-s*<dPsHVYZm|7R# z>Jf0bPfbx_)7-}tMINlc=}|H+$uX)mhC6-Hz+XZxsKd^b?RFB6et}O#+>Wmw9Ec9) z{q}XFWp{3@qmyK*Jvzpyqv57LIR;hPXKsrh{G?&dRjF%Zt5<eso5q2Qq7|ChevXoo zUUuocw%BMFdbc16MLS>&m20Ll?Oy<ul%w2Zua&zkQjQpssgWs#RKAL}6i`eHUzx7p zSoNx{P@%ayUjixVqBLi(th$z4mR4dC*OaQENb9y_y<R>fUYC3WRn{cgQ?^V~UAv+5 z&_m#&nIwffgX1*Z2#5^Kl4DbE#NrD&Hi4|7SPqZ}(>_+JMz=s|k77aEL}<=0Zfb)a z%F(*L3zCA<=xO)2U3B|pcTqDbBoFp>QyAEU(jMu8(jLA61-H!ucI804+B!$E^cQQa z)_ERrW3g!B9iLb3nn3dlkvD7KsY?sRvls3QC0qPi>o<)GHx%4Xb$5a3GBTJ(k@`e@ z$RUa^%S15^1oLEmA=sayrP5;9qtf!Z1*?e$ORVPsXpL{j<cf(aoOxSwSjBR1mea0e z^c3Q=wn8)%*koW31D%}j9dO0gR7Y@b_00J@vMv~iauOuPVkQGKG^LpkGueXj*z<(H zv6PsTl$Bd^g^4yr-LK|PFHGhgs2bdrT$8)TR7nc(mnkPw6vrbaA_vA0JdMNVsoX7X zzI)+QrS1A&rLp4hm_yCc#tP)G{!hBfUV@*{)uh;EQVyvIbs@16JE!9Psq3UaXMGQ^ zJ^Y2zHl3Zl1idw?=a56!^Y{sB%B-k(>L<6E)0sj&swP3}NPmR%FM?O>SQgN5XfHE< zo(4#Cv11(%Nnw_{_Ro}r6=gKd{k?NebJ~<~Kv0r(r0qe4n3LFx$5%x(BKvrz$m?LG zjLIc;hbj0FMdb9aH9Lpsof#yG$(0sG2%RL;d(n>;#jb!R_+dad+K;Ccw!|RY?uS(a zj~?=&M!4C(5LnlH6k%aYvz@7?xRa^2gml%vn&eKl$R_lJ+e|xsNfXzr#xuh(>`}9g zLHSyiFwK^-p!;p$yt7$F|3*IfO3Mlu9e>Dpx8O`37?fA`cj`C0B-m9uRhJjs^mRp# zWB;Aj6|G^1V6`jg7#7V9UFvnB4((nIwG?k%c7h`?0tS8J3Bn0t#pb#SA}N-|45$-j z$R>%7cc2ebAClXc(&0UtHX<>pd)akR3Kx_cK+n<}FhzmTx!8e9^u2e4%x{>T6pQ`6 zO182bh$-W5A3^wos0SV_TgPmF4WUP-+D25KjbC{y_6W_9I2_vNKwU(^qSdn&>^=*t z&uvp*@c8#2*paD!ZMCi3;K<prH4a&u_&GPH7Wp*~+CzWZpzTbL&)4k=HC|E1=uT#O zPuYcj5Jbb}c9+j=4foN!N-fSV`(c<30`iukl213!u4qI^cD3Ytg!~P3N1S(`-3^yF zlFzoPUGJT0f@f{`ZDabbR@mNt3*M;G<P@?W{8}}Lg`Hvy7&Jmu=04fd1erYTPEwb= z15%g^dl!<DS<}y*RUs*g6?{>{Na;I4Q35zw$YrW5U@Kk~)&rw;G?d7Q&c9|x<<Maw zgBLn8*CoGPVj_^rpPn~ENvB}-`?vqbUaC%e+l73U+D^-Cb&Pj13W#6g!+x^$_tX)* z@cs9Wk+{Dzx8NJw-G7(M|EOb>Hg|CNMsxovmfth*|E*GHezPTWa^Hd^F4!B3sF;)? z(NaPyAhocu1jUe(!5Cy|dh|W2=!@fNmuNOzxi^tE_jAtzNJ0JR-avc_H|ve#KO}#S z#a(8secu|^Tx553d4r@3#6^MHbH)vmiBpn0X^29xEv!Vuh1n(Sr5I0V&`jA2;WS|Y zbf0e}X|)wA-Pf5gBZ>r4YX3Mav1kKY(ulAJ0Q*jB)YhviHK)w!TJsi3^dMa$L@^{` z_De`fF4;M87vM3Ph9SzCoCi$#Fsd38u!^0#*sPful^p5oI(xGU?yeYjn;Hq1!wzFk zG&2w}W3`AX4bxoVm03y>ts{KaDf!}b&7$(P4KAMP=vK5?1In^-YYNtx1f#}+2QK@h zeSeAI@E6<gTYtZud5<DeMJ>Z8a?)>sZ`fbq9_snl6LCu6g>o)rO;ijp3|$vig+4t} zylEo7$SEW<_U+qgVcaVhk+4k+C9THI5V10qV*dOV<w;)q>6pPtAI$)QN{!JRBKh-D zk2^{j@bZ}yqW?<#VVuI_27*cI-V~sJiqQv&m07+10XF+#ZnIJdr8t`9s_EE;T2V;B z4UnQUH9EdX%zwh-5&wflY#ve!IWt0UE-My3?L#^Bh%kcgP1q{<CvcqqQ2utk7s%sV zoGl}#Zl)W@RNSAAf;w-DBO+*e0HO2%x-G=Z;*Pl$zHy^xW)%na$gbyTIw>&26eXLn zTkjJ*w+(|_>Pq0v8{%nX$QZbf)tbJaLY$03;MO=Ic-uqYUmUCuXD>J>o6BCRF=xa% z3R4SK9#t1!K4I_d>tZgE>&+kZ?Q}1qo4&h%U$GfY058s%*=!kac{0Z+4Hwm!)pFLR zJ+5*OpgWUrm0FPI2ib4NPJ+Sk07j(`diti^i#kh&f}i>P4~|d?RFb#!JN)~<!~Z`* zivNy#7kzu<{{tyjD6d)1{g;-B-EK2+0;|?2Nj`=2hUDsRiVj-}RAJN{d@x~38|)#_ zx&F#UxFFdbXxE(|#84p;-%dtBDbgEpl>D@)beox}bw?4VCf^y*`2{4`-@%SFTry2h z>9VBc9#JxEs1+0i2^LR@B1J`B9Ac=#FW=(?2;5;#U$0E0UNag_!jY$&2diQk_n)bT zl5Me_SUvqUjwCqmVcyb`igygB_4YUB*m$h5oeKv3uIF0s<pxU7XrV;DR{UhyjHRs5 zb+8Qf7A65FfQ?d1ZT2w}F_l*Eb)?ah<8c%Yy;Eal4{xBsX^nN@Pe5CxcymxUwL?eh zv9_Z0XXBqZl6EhcKDo~Ou&%?PpG{{$wPe(7oy?yZ1mnWmr0b~pN$igR!(Rx*QN$iy z=-Re}qI2g(ku?t~HgBj3V=|H$hiN2{j!P%zCB+1x34pnjx#?&{ENcU`o_2tynp}0U zKI9mTgI{WS`?XY!3FH!0Q>k}~es!{D>4r%PC*F~FN3owq5e0|Y<Du-bB4EU)q{6=q z#<0gBE8S|!ZrmQeH3JgM^AxLU0k8cAwCY-9?0w8gxwWKqzGP>eUTSG#Vq%&Gk7uwW z0lDo#_wvflqHeRm*}l?}o;EILszBt|EW*zNPmq#?4A<L0#2f-Fpgzo6i9m?Cv{^Fe z9>+&i0xx^?9obLyY4xx=Y9&^G;xYXYPxG)DOpPg!i_Ccl#3L}6xAAZzNhPK1XaC_~ z!A|mlo?Be*8Nn=a+FhgpOj@G7yYs(Qk(8&|h@_>w8Y^r<TLc~8#)=w@0;xlrL@mM3 zg*K(X)@-O)lt;P?5e(;WTL%O;a;rQNAE5;DqERSyAXc1biP%NUWXy?=-B^)wQ=+I4 zU%qA-ghSXXn27E3w8NMG!5XHJY>&5nCqe0V60rRz?b5%J;GYeBqSAjo|K692GxD4` zRZyM2FdI+-jK2}WAZTZ()w_)V{n5tEb@>+JYluDozCb$fA4H)$bzg(Ux{*hXurjO^ zwAxc+UXu=&JV*E59}h3kzQPG4M)X8E*<G*nR0cBsK+3(q5`<{N)Z$_eT#;miD(s%h z{fdYdgo~K&tWs<DY?yIi#?k!bT;M<ZDoV|<xhf7jcRFXDXl`LtGFz=LPAW$(hAEz} zq@oGhJoeM0w4KvLTg%>}#_&}w*KEg<F5P|-B$Y<3$zfM|>tX)cU{vm9b$atHa;s>| z+L6&cn8xUL*OSjx4YGjf6{Eq+Q3{!ZyhrL&^6Vz@jGbI%cAM9GkmFlamTbcQGvOlL zmJ?(FI)c86=JEs|*;?h~o)88>12nXlpMR4@yh%qdwFNpct;vMlc=;{FSo*apJ;p}! zAX~t;3tb~VuP|ZW;<Q&V!o{~5>z$=IHf->F@Ml)&-&Bnb{iQyE#;GZ@C$PzE<QuI) zv_|vm%I_n1Dpq6lr--l%%tq7K!<v~?55k`WhA?y(q<f`<c6%@dUw9u~aA?j^`pueW zW?_3n{u)d4@AQyf;UHiIRxp16RoWg&F+uwIJYB{!Spu!Z6TFEXau<!8UfawC4vbZv zJTpZLC-RhzHO9xSd6HqYzkfjT+8e>f6~q}4D>9jic@mTO5x76ulDz@+XAcm35!VSu zT*Gs>;f0b2TNpjU_BjHZ&S6Sqk6V1370+!eppV2H+FY!q*n=GHQ!9Rn6MjY!Jc77A zG7Y!lFp8?TIHN!LXO?gCnsYM-gQxsm=Ek**V<u>mZu7vnuufD7K~GIxfxbsQ@qv2T zPa`tvHB$fFCyZl>3oYg?_wW)C>^_iDOc^B7klnTOoytQH18WkOk)L2BSD0r%xgRSW zQS9elF^?O=_@|58zKLK;(f77l-Zzu}4{fXed2saq!5k#UZAoDBqYQS{sn@j@Vtp|$ zG%gnZ$U|9@u#w1@11Sjl8ze^Co=)7yS(}=;68a3~g;NDe_X^}yJj;~s8xq9ahQ5_r zxAlTMnep*)w1e(TG%tWsjo3RR;yVGPEO4V{Zp?=a_0R#=V^ioQu4YL=BO4r0$$XTX zZfnw#_$V}sDAIDrezGQ+h?q24St0QNug_?{s-pI(^jg`#JRxM1YBV;a@@JQvH8*>> zIJvku74E0NlXkYe_624>znU0J@L<-c=G#F3k4A_)*;ky!C(^uZfj%WB3-*{*B$?9+ zDm$WFp=0(xnt6`vDQV3Jl5f&R(Mp};;q8d3I%Kn>Kx=^;uSVCw0L=gw53%Bp==8Sw zxtx=cs!^-_+i{2OK`Q;913+AXc_&Z5$@z3<)So0CU3;JAv=H?@Zpi~riQ{z-zLtVL z!oF<}@IgJp)Iyz1zVJ42!SPHSkjYNS4%ulVVIXdRuiZ@5Mx8LJS}J#qD^Zi_xQ<pa zK0_C<`%bp5M~CVCk7hV^j*M;Wzcj7kCsCfgg5CJ~2`y3|66=yp|GC7FJNP7A_Wc+( zejiW#M^nRp{rUgmIRC{MB`ST%d>@>DKDr-_e#>5h3dtje*NcwH_h;i{Sx7}dkdpuW z(yUCjckQsagv*QGMSi9u1`Z|V^}Wjf7B@q%j2DQXyd0nOyqg%m{CK_lAoKlJ7#8M} z%IvR?Vh$6aDWK2W!=i?*<77q&B8O&3?zP(Cs@kapc)&p7En?J;t-TX9abGT#H?TW? ztO5(lPKRuC7fs}zwcUKbRh=7E8wzTsa#Z{a`WR}?UZ%!HohN}d&xJ=JQhpO1PI#>X zHkb>pW04pU%Bj_mf~U}1F1=wxdBZu1790>3Dm44bQ#F=T4V3&HlOLsGH)+AK$cHk6 zia$=$kog?)07HCL*PI6}DRhpM^*%I*kHM<#1Se+AQ!!xyhcy6j7`iDX7Z-2i73_n# zas*?7LkxS<P{ZTB#tR|&N^U;Moy2#JwwW4RFPddYtD_bw0R1|Eo=5;j>-XSqv;YBa zW_n*32D(HTYQ0$feV_Fru1ZxW0g&iwqixPX3=9t4o)o|kOo79V$?$uh?#8<F`E$a_ zutly1{7L1J@Y@6Vp*~KB!yXMF2QHqby@+ZG8+ND)X+s9is!(NOe)h&%h+bxjPFhwq z$60~SJQ<aykcGl3;BUCZ>Q8e>4e)V6;_(x&ViUVxma+i25qea;d-oK7ouuDsB^ab{ zu1qjQ%`n56VtxBE#0qAzb7lph`Eb-}TYpXB!H-}3Ykqyp`otprp<BVE0iW(NxIg}T zHQz%!Kt^1I=QqGS(r32Y=&DuF_0#yaLgW`I>7{VEuW*^IR2n$Fb99*nAtqT&oOFIf z@w*6>YvOGw@Ja?Pp1=whZqydzx@9X4n^2!n83C5{C?G@|E?&$?p*g68)kNvUTJ)I6 z1Q|(#UuP6p<iHaJ>j78GUxq11m-GSszc+)X{C2eo-?8ud9sB=3(D47v?`JAa{V(IF zPZQ_0AY*9M97>Jf<<kmA6!?J&2x7=_q{>o%#O_%Wq}8>YM=q0|tGY+hlXcpE=Z4Od z`NT7Hu2hnvRoqOw@g1f=bv`+nba{GwA$Ak0INlqI1k<9!x_!sL()h?hEWoWrdU3w` zZ%%)VR+Bc@_v!C#koM1p-3v_^L6)_Ktj4HE>aUh%2XZE@JFMOn)J~c`_7VWNb9c-N z2b|SZMR4Z@E7j&q&9(6H3yjEu6HV7{2!1t0lgizD;mZ9$r(r7W5G$ky@w(T_dFnOD z*p#+z$@pKE+>o@%eT(2-p_C}wbQ5s(%Sn_{$HDN@MB+Ev?t@3dPy`%TZ!z}AThZSu zN<1i$s<Qod+!u+1TpqzHMAR;(P|C33h|NdU1+@toT{?QhAJAzzUDj;ch>iJhXFdjV zP*y|V<`V8t=h#XTRUR~5`c`Z9^-`*BZf?WAehGdg)E2Je)hqFa!k{V(u+(hTf^Yq& zoruUh2(^3pe)2{bvt4&4Y9CY3js<F>)PUHtd4rVG57}uFJL)D(JfSIo^{P=7liFXG zq5yqgof0V8paQcP!gy+;^pp-DA5pj=gbMN0eW=-eY+N8~y+G>t+x}oa!5r>tW$xhI zPQSv=pi;~653Gvf6~*JcQ%t1xOrH2l3Zy@8AoJ+wz@daW@m7?%LXkr!bw9GY@ns3e zSfuWF_gkWnesv?s3I`@}NgE2xwgs&rj?k<VL?gG5MC{Nmj1vZX?3e8O$&f`#KcfCT zD|dGfAH<9vQYUE_U}e#K2epdwK03De5{_327SI@sw~J+|<wi@;rZX!9Y2MH7_L7?E z^an@e4GxaY9F(p#Ot=#L(YG%x=Gq!vNbxtM=IXzPyPmYSGU#`>H-FEy82=O8`+szN ziHch`vvS`zNfap14!&#i9H@wF7}yIPm=UB%(o(}F{wsZ(wA0nJ2aD^@B41>>o-_U6 zUqD~vdo48S8~FTb^+%#zcbQiiYoDKYcj&$#^;Smmb+Ljp(L=1Kt_J!;0s%1|JK}Wi z;={~oL!foo5n8=}rs6MmUW~R&;SIJO3TL4Ky?kh+b2rT9B1Jl4<n8E`j>>#Uh-Bec z`Hsp<==#UEW6pGPhNk8H!!DUQR~#F9jEMI6T*OWfN^Ze&X(4nV$wa8QUJ>oTkruH# zm~O<`J7Wxseo@FqaZMl#Y(mrFW9AHM9Kb|XBMqaZ2a)DvJgYipkDD_VUF_PKd~dT7 z#02}bBfPn9a!X!O#83=lbJSK#E}K&yx-HI#T6ua)6o0{|={*HFusCkHzs|Fn&|C3H zBck1cmfcWVUN&i>X$YU^Sn6k2H;r3zuXbJFz)r5~3$d$tUj(l1?o={MM){kjgqXRO zc5R*#{;V7AQh|G|)jLM@wGAK&rm2~@{Pewv#06pHbKn#wL0P6F1!^qw9g&cW3Z=9} zj)POhOlwsh@eF=>z?#sIs*C-Nl(yU!#DaiaxhEs#iJqQ8w%(?+6lU02MYSeDkr!B- zPjMv+on6OLXgGnAtl(ao>|X2Y8*Hb}GRW5}-IzXnoo-d0!m4Vy$GS!XOLy>3_+UGs z2D|YcQx@M#M|}TDOetGi{9lGo9m-=0-^+nKE^*?$^uHkxZh}I{#UTQd;X!L+W@jm( zDg@N4+lUqI92o_rNk{3P>1gxAL=&O;x)ZT=q1mk0kLlE$WeWuY<p^lk9k<q+rcOGG zY)G$sy1c;kpqg0vV-}_XHLMzubt5&Y+X3Q{3Wa&iSOY9S8qUS1LUsYa--u3<U|kYH zfL}q@Sl3A;lg32U^*mSX!dr5wpp#<9G)=5WC=&Cv)mW|a!muj?rXU0JHBrQ<`Qqt} zCgYqLnoe5^we#~H-+!p+9n;rMDHiXMi5YFyOWW{wi{Tnu%1mqAov`>_$0`0jY-Kkt zP*|m3AF}Ubd=`<>(Xg0har*_@x2YH}bn0Wk*OZz3*e5;Zc;2<o%?~}~ck-Gk+}B1o z7H(amz-SpgFI})ialV|e<4!f0)HG}_n?GAIDeiC%vdRTJZ<WeGYT+p)vyz_FBWtO$ zz2K9gb9XN8(>uBdnl8?&XjupbkOeNZsNh6pvsq_ydmJI+*z**{<vBKXP!vVq<!p_e z&dE_6Lim}RGRF|DDL<W_^>I{0K)-;p1~k8cpJXL$^t!-`E}=*4G^-E8>H!LjTPxSx zcF<xkt%17w#Nu8CTO5_pkM9Mxb}Wf0;14cP1{yv5#7uIFNY2eGhq{_*hEBSbjQA$U znAZkjT-yd=I_R8ZJ?H07yh5z>+cS`ommfKMhNSbas^<U&=a>@YbTpH1*RFrBuATUR zt{oFWSk^$xU&kbFQ;MCX22RAN5F6eq9UfR$ut`Jw--p2YX)A*J69m^!oYfj2y7<PW z+v>NYcH6&r+0~_sH^c^nzeN1AU4Ga7=FlR{S|Mm~MpzY0$Z+p2W(a={b-pR9EO1Rs zB%KY|@wLcAA@)KXi!d2_Bxr<pdgvLNQgZmCJz&c*%>khDn`DT1=Dec}V!okd{$+wK z4E{n8R*xKyci1(CnNdhf$Dp2(Jpof0-0%-38X=Dd9PQgT+w%Lshx9+loPS~MOm%ZT zt%2B2iL_KU_ita<m0BJ>%N>xjB!<T714UjSOi9+HnppU8HTO6Xys3~>#71_3=3c}o zgeW~^U_ZTJQ2!PqXulQd=3b=XOQhwATK$y(9$#1jOQ4}4?~l#&nek)H(04f(Sr=s| zWv7Lu1=%WGk4FSw^;;!8&YPM)pQ<cfOEqZOX3qg|bi6Y+6raTouS~FCMLV)D&#=5p zIeXB!ZqEvR`rjuFN1ix6GoI%|3=23*fhels&m^Kl7$Xb)d4}z-AG?ZOEDO)5!dX$; zxo;%86BwnKIVK{n#wbp)z+DlG`Eo;!p1qmI5u}Dr3ERkBC^gA;rI=Ohq{XCvK{N9A zv&z##C1k^|5<I!-;+MCJN#mX7#cD{4PE)89nNv$gm~ronTcb2Mq~#~4^M!^C-1#M( z=1UT>DCY9DhU`hMty1@sq1=Tj7bFsOOBZOFlpR`W>-J$-(kezWJj;`?x-v>ev{*8V z8p|KXJPV$HyQr1A(9LVrM47<GkZ=yfPF+Dq%S|w=MijcJ6`g68YLr!qXVkqpG~JGH zv`>u-XpcrIyO`yWvx1pVYc&?154aneRpLqg<bQ>x)EMvRaa#|9?Wwqs2+W8n5~79G z(}iCiLk;?enn}ew`HzhG+tu+Ru@T+K5juvZN)wY;x6HjvqD!&!)$$;1VAh~7fg0K| zEha#aN=Yv|3^~YFH}cc38ovVb%L|g@9W6fo(JtT6$fa?zf@Ct88e}m?i)b*Jgc{fl zExfdvw-BYDmH6>(4QMt#p0;FUIQqkhD}aH?a7)_%JtA~soqj{ppP_82yi9kaxuK>~ ze_)Zt>1?q=ZH*kF{1iq9sr*tVuy=u>Zev}!gEZx@O6-fjyu9X00gpIl-fS_pzjpqJ z1yqBmf9NF!jaF<+YxgH6oXBdK)sH(>VZ)1siyA$P<#KDt;8NT*l_0{xit~5j1P)FN zI8hhYK<Ctos5a2iSB6E;K{q?&ab{FphO?zxIqqf}%h}LQupBY+8nGBng;9rl>hQ)i z37^aP<qpGiOwA$)u%NPBnx5-dJ?eaKuJ|k=(U7<z&!E=Ex{~rH$w*MsBugwy8}mlc z@Cp`bIFkJ)nD=}RT7wXAD(8ljk?9q}6wNph<*7S`FnAlTK859*a=phG5gBZhql8^1 zqKXJBJSm<4{Im$>13B~u65?sg+_@2Kr^iWHN=U;EDSZ@2W2!5ALhGNWXnFBY%7W?1 z=HI9JzQ-pLKZDYTv<0-lt|6c-RwhxZ)mU2Os{bsX_i^@*fKUj8*aDO5pks=qn3Dv6 zwggpKLuyRCTVPwmw1r}B#AS}?X7b837UlXwp~E2|PJw2SGVueL7){Y&z!jL!XN=0i zU^Eig`S2`{+<wl==2w=5|F9xM-02N5`F*r(R=yBH;xL${JnA$s&UywB#{_0RS^+hX z*x9l+ftTA)(qqfU_K+4UB=w=A|0J9m!!ePjqBV0F7$&R=n`+yR@2tk2NwHhL{|&cH z$fXb3^<M+ME~B<-d*&_qT19T_sRLwwvu;hmu)Vt+WHH@$?DQ6m2&aLvtcbK<MgTBu zp(SQPX~F@0CgG?+)(jWG3Oc!+oEMIunjIDK0w|m6;WzWCMpr#dq3I<GeN7c@71Fqq zf&ADs4Jz}_R;T_`?S9~(wB(9e>gU$68aRdWx?BZ{sU_f=8sn~>s~M?GU~`fH5kCc; z8ICp+INM3(3{#k32RZdv6b9MQYdZXNuk<tNnM*Zj3!<Rmy`SQx3idGTVVW@OzSywq z<sw@>7ed8;G?S2nT+NZBG=Tar^KFl2SvhW$bGW#kdWL-I)s_IqVnCDDM9fm8g;P;8 z7t4yZn3^*NQfx7SwmkzP$=fwdC}bafQSEF@pd&P8@H#`swGy_rz;Z?Ty5mkS%>m#% zp_!m9e<()sfKiY(nF<1zBz&&`ZlJf6QLvLhl`_``%RW&{+O>Xhp;lwSsyRqGf=RWd zpftiR`={2(siiPAS|p}@q=NhVc0ELprt%=fMXO3B)4ryC2LT(o=sLM7hJC!}T1@)E zA3^J$3&1*M6Xq>03FX`R&w*NkrZE?FwU+Muut;>qNhj@bX17ZJxnOlPSZ=Zeiz~T_ zOu#yc3t6ONHB;?|r4w+pI)~KGN;HOGC)txxiUN8#m<I|Y6ES5NY<qwYy-|}EoiBmM zzK&og-IJMpwjbL8IAA?{APd++5KnIBFcmKwZzO~h`v>exj+W(cz%9a4sx|IRG=}ia zuEBuba3AHsV2feqw-3MvuL`I+2|`Ud4~7ZkN=JZ;L20|Oxna5vx1qbIh#k2O4$RQF zo`tL()zxaqibg^GbB+BS5#U{@K;WWQj~GcB1zb}zJkPwH|5hZ9iH2308!>_;%msji zJHSL~s)YHBR=Koa1mLEOHos*`gp=<n)R&-+66xg`pqUXP#0Zm{sf^MKJnR>s8KA-C zu0aE+W!#iJ*0xqKm3A`fUGy#O+X+5W36myS>Uh2!R*s$aCU^`K&KKLCCDkejX2p=5 z%o7-fl03x`gaSNyr?3_JLv?2RLS3F*8ub>Jd@^Cc17)v8vYEK4aqo?OS@W9mt%ITJ z9=S2%R8M){CugT@k~~0x`}Vl!svYqX=E)c_oU6o}#Hb^%G1l3BudxA{F*tbjG<UG_ z<Vo0tRnGPd)K5|7jd0}T-wQdZZG$TREj=4*<4bR;^};Y7orbT=`ITOz*@g<Y=x;1T zesE`pc}OxPz?01Be<R|c+WT7B<35I5;~ham;Y6hY?F{PCSdwf@9qXBZn%JBWfW6@H zxqGR{o+TW^4T&@lZ$GVT-8s0{R3MmXsMGGDxgNTZ8|S`+<Qxcf#Ei_^nsS}Yt*PB* z5#h+HDAU+!GmbOJ#d(J8|6Rt?+U5gR1>;W_>=xV73pKY53v%>I)@D36I_@&p$h|Aw zonQS`07z_F#@T-%@-Tb|)7;;anoD_WH>9ewFy(ZcEOM$#Y)8>qi7rCnsH9GO-_7zF zu*C87{Df1P4TEOsnzZ@H%&lvV(3V@;Q!%+OYRp`g05PjY^gL$^$-t<lOaPfbWQ29U ziQLZAEqJX}(BXW*YUz0#v0~iKqbJ35-`aw1m+YA~k)TQVyq!wOKDE%}gHJ%Woa=@J z_G3Z9VfhbWs>0Y>H*CDDs?FZly*oZ&dxvsxaUWF!{em4{A>n@vpXg$dwvt@_rgmHF z-MER`ABa8R-t_H*kv>}CzOpz;!>p^^9ztHMsHL|SRnS<-y5Z*r(_}c4=fXF`l<Xkw zEy6TsVDdDoV_TNG(<WvkK8TcvFZ^8LA2wsz%Sl;DbLS}my*l(?>^-i}>e7v!qs_jv zqvWhX^F=2sDNWA9c@P0?lUlr6ecrTKM%pNQ^?*Lq?p-0~?_j50xV%^(+H>sMul#Tw zeciF*1=?a7cI(}352%>LO96pD+?9!fNyl^9v3^v&Y4L)mNGK0FN43&Xf8jUlxW1Bw zyiu2;qW-aGNhs=zbuoxnxiwZ3{PFZM#Kw)9H@(hgX23h(`Wm~m4&TvoZoYp{plb^> z_#?vXcxd>r7K+1HKJvhed>gtK`TAbJUazUWQY6T~t2af%#<+Veyr%7-#<!`u7=6z< z@9(d@bkwuJK`;AXMIasQ=4>*A#@&*;@g58{i|E%6yC_InGXCOd{L0;$)z#?n7M`re zh!kO{6=>7I?*}czyF7_frt#)s1CFJ_XE&VrDA?Dp3XbvF{qsEJgb&OLSNz_5g?HpK z9)8rsr4JN!Af3G9!#Qn(6zaUDqLN(g2g8*M)Djap?WMK9NKlkC)E2|-g|#-rp%!Gz zAHd%`iq|81efi93m3yTBw3g0j#;Yb2X{mhRAI?&KDmbGqou(2xiRNb^sV}%%Wu0?< z?($L>(#BO*)^)<q1<y(%o-D>rSgyNRni$i`R4v;GhlCZ8$@e^ROX(p=2_v6Y!%^As zu022)fHdv_-~Yu_H6WVPLpHQx!W%^6j)cBhS`O3QBW#x(eX54d&I22op(N59b*&$v zFiSRY6r<ogkg`lB4Kzk}79-1@4WexUMjw+78PVxKkpJ}l$#uYSKRni~wAs(LFQ`S~ z)6H^=SsBl_=gtAZcCb%LqI~={V0L=Y2XD)_)<%!{eY=Ch6CuBtAu<8uk=VNIh~<** z4}OE-7;4DiEfmH5r(r_l!C{NrR4GiA;)KB7^Z5GJe5Gi4jN{kozWVvza9J%ysu#}1 zV05Y-@;(u_e>Oc^(dgSV1<S;M;fP~XvG?H2UWU4_zajZjOwrCC*tclCC;-DiooNK0 zKT=dwZdL_ATeNsapPF;r>>a7-5C;(5S5MvKcM2Jm-LD9TGqDpP097%52V+0>Xqq!! zq4e3vj53SE6i8J`XcQB|MZPP8j;PAOnpGnllH6#Ku~vS42xP*Nz@~y%db7Xi8s09P z1)e%8ys6&M8D=Dt6&t`iKG_4X=!kgRQoh%Z`dc&mlOUqXk-k`jKv9@(a^2-Upw>?< zt5*^DV~6Zedbec4NVl(<J^HBK@2E4@wD3MtdV(xxnq%Vbc}WuHo}5QftrZiThWddC z`SqHbMkXtVXKo79381*cF)4R%emsAj0XSNL+PDEL#FejmgEu5hh1&sJBWank<?5|| zFa70^J_fIcXV;YE%H5TE-LegY#;Q{2+X5ec9PUkBc$GBoPD$f3kMxcc{GCuyqMnT@ zoFpa`18N8o#cD|?wHX2`2uRGD5<2)(v9&spIF?JOuSy<x-3h*1uo<tmXh9c!`+f(x zJ!3;pKi`8)fAw>$2T{&b)zA@b#dUyd>`2JC0=xa_fIm8<d`*pjaOx{pV5Pe7q{O1W zAT5c_%T(D{?pE<afMwAc3^p*w8J|rwP_Jm84rW1XYj_z!4LWkm8d^QnYM3TAJ00M} zVy+@IpL$^~2r+Bvv<<LIU<+tp*%PbBjNTe&Z{H48g3%crRya-=a)qRylf)#ctNvw? zlFff;8hFwWk7>{5u<t#%K`^*tgQ@!!adiD7YiKBgV_c5|kR2YSiy*0+0NhSocT-E< zLsbh?CBfxQ!F7|i4p=%MX(y=Km3GT?bty_*CZh1*xF8v|8xjqfoni!cQE1EFLyy>m zr-!ApXZhC8@=vC2WyxO|!@0Km)h8ep*`^he92$@YwP>VcdoS5OC^s38e#7RPsg4j+ zbVGG}WRSET&ZfrcR(x~k8n1rTP%CnfUNKUonD$P?FtNFF#cn!wEIab-;jU=B1dHK@ z(;(yAQJ`O$sMn>h;pf^8{JISW%d+@v6@CnXh9n5TXGC}?FI9i-D0OMaIg&mAg=0Kn zNJ7oz5*ReJukD55fU<geid?Ih<u$lD<ZaBChRKMH%^g0f>sMuaP+H4tDN&V9zfqF@ zr=#ecUk9wu{0;!+gl;3Bw=Vn^)z$ahVhhw)io!na&9}LmWurLb0zubxK=UEnU*{5P z+SP}&*(iBKSO4{alBHaY^)5Q=mZ+2OwIooJ7*Q5XJ+2|q`9#f?6myq!&oz?klihLq z4C)$XP!BNS0G_Z1&TM>?Jk{S~{F3n83ioli=IO6f%wkvCl(RFFw~j0tb{Gv<R+ryT zRX%I@y>XTx>*sB0McY0s&SNvj4+^h`9nJ_wM>F!Uc>X}9PifQekn0sKI2SAJP!a4h z5cyGTuCj3ZBM^&{dRelIlT^9zcfaAuL5Y~bl!ppSf`wZbK$z#6U~rdclk``e+!qhe z6Qspo*%<)eu6?C;Bp<^VuW6JI|Ncvyn+LlSl;Mp22Bl7ARQ0Xc24%29(<s4_N=u?? z$t{gru-VXd6Kxbxv5Bt?Z$yc1w+D)_3NsUN5!&<3)ieB&Nu@~@oJ<S*(@c3&Pewi0 z%9!QRq|vABdor|x%wu0~=1Z5hOOpQi`0!iyJhFLc128BQmq$$|x}WJU?iN}7Kl;!3 z&fH)Q*P121qPNW?v_ooy8Sm4CFA(z37NUr<`Xy>ZrdsIPw&-=yHQ7_Vle|5h>AST0 zUGX2Zk34vp?U~IHT|;$U86T+UUHl_NE4m|}>E~6q``7hccCaT^#y+?wD##Q%HwPd8 zV3x4L4|qqu`B<QDcIkg$mi~*?H~RNY_Wx4D`}Y)A)!YT!6xCZ~ZTPl7{FKBwUP;<e zGwA>$4(LXqDJngNy-{&@aFBvVsywt@X^}iH7P%>bR?ciC$I^U-4Foa<B~H&?t+Qu` zYwLM1`)@`@%8{2TvE*VigXq!t`qQLW_S5DOEz|2z2j3@V_m6j4f9Dj+Tf}F*MS{?9 z%5n(*$y|Ke#xMg=rzn#aJmhAj==f%8S}Mvc)f)j&X?h~FezLP0LfVpNB_5sLG5z-3 z-35pyFHV0nPmuu&M8{s3y}I4c7J41@C$_+Wqk&1f`MvOF;v`)pAYGN4MEXuFe)LY2 z=&C(w{X1B@m%`@ul3h(Gtch>`YKI^qDyGK7k%E%c_P=yzAi`YnxGA%DeNd++j3*h^ z=rn>oBd0|~lZ<6YvmkKY*ZJlJ;Im0tqgWu&E92eqt;+NYdxx`eS(4Hw_Jb5|yVvBg z*tbdY^!AN;luEyN4VRhS@-_DC{({ziH{&Z}iGElSV~qvT>L<HMHzj7J-Kpa!p!c29 zr8L!oM5$VXc*@gwRrKuo*h${B7BEkfln@c=6oRf(*M;GdM=)m;H&XnLvs%-2b*+hr z7OLt;wv$AG{1d5A_3iuPkRtq^Z3(lga~pLocX)JXz8xtPuFH$tb1E#7^Np@GfB#l+ z&L?_OtoPwP5<R)d%5@nIE}~}{W{Q)eZmHbhg7xXREu-ZQD=)K37HlG2J>-8G%+yEL zX#MFOhj{InyKG=mvW-<1B@c-}x$vA(nU?>S>0*eN#!SLzQ)Ex7fvQ)S4D<8|I#N$3 zT5Ei`Z?cxBODHX8(Xp73v`IsAYC@9b;t}z0wxVuQSY1J^GRwDPN@qbM-ZF48T$GZ< z8WU+;Pqo?{ghI-KZ-i*ydXu`Ep0Xw^McH_KE9J0S7G;x8Fe`DVG?j3Pv=0YzJ}yZR z%2=oqHiUjvuk0~Ca>Kol4CFi0_xQT~;_F?=u+!kIDl-9g`#ZNZ9HCy17Ga1v^Jv9# z{T4Kb1-AzUxq*MutfOWWZgD*HnFfyYg0&e9f(5tZ>krPF6{VikNeHoc{linPPt#Si z&*g>(c54V8rT_AX!J&bNm-!umPvOR}vDai#`CX___J#=zeB*{4<&2WpaDncZsOkp* zsg<%@@rbrMkR_ux9?LsQxzoBa1s%$BBn6<mos5IdgH=dhp$~J!{0^n6tTjg;U5Diz zxn*0gDh2*1rZJV&>&fW9znj@{DqJLV4eBUx+J-d4%cpr)wD1@Vx?X)?B*tGi&A= zu5vL>vk#{&&zUwcfzeCBJUwFYSF$08qDsB;gWQN*g!p8pxjofWbqNSZOEKOaTx@+* zwdt5*Q47@EOZ~EZL9s?1o?A%9TJT=Ob_13yyugvPg*e&ZU(r6^k4=2+D-@n=Hv5vu zSXG|hM(>h9^zn=eQ=$6`JO&70&2|%V5Lsx>)(%#;pcOfu>*nk_3HB_BNaH$`jM<^S zcSftDU<V-inKL>1?nL;jy)+sfonQN}(}gUW?d_ikr*3=^{G)=tjBtEPe>TO|0ddVB zTklrSHiW+!#26frPXQQ(YN8DG$PZo?(<o9TOVxrss2Tawf48g5GCBiDSDE3#UTp|k z0pKVwU|jx{+EQY()yCur&pg1mIZ@qM8XqZ|+FwhjuUu?KF@3ju{(}}|suk~@HhJ@t zQ#Y9~FZ`d#%R!jufJh!!RQMg1{%cNgteq&qcX<80AJGOV1RA*AhOQap$U?Y%h(E_| z8KTI5C~yZDX7BVzv96*H*$a>po(QUCCf_OJC`pw*uey00%gmH!`WJkrKXj2!<hVw7 zGRWHF)G~HffGg-$(O!{Pk3Jwp$~FflQ(t@$&I&M44Aeq=eh)c85(&68I*-`m6uoK% zdl?LKwDh&L2fOllfoB@M_mp%O$0z3aZY$k-iHXr}^=#M>#6?`T25mTu9OJp2L8z3! z=arrL$ZqxuE{%yV)14Kd>k}j7pxZ6#$Dz8$@WV5p8kTqN<-7W)Q7Gt2{KoOPK_tZ| zf2WG~O5@{qPI+W<4f_;reuFVdO^5`ADC1!JQE|N`s3cq@(0WB!n0uh@*c{=LAd;~} zyGK@hbF-Oo+!nN)@i*O(`@FA#u?o=~e{`4O#5}z&=UkU*50fOrzi11D^&FOqe>wii z?*k+2|EcUs;Gx{!@KBT~>PAwLrIDT7Th=Utu?~?np@t^gFs?zgX=D${RwOY^WGh-+ z+#4$066I<rp}NtcF5!lhZvD^1^l^-7{>Sh8eYW#FXWp~S`<*%O^ZuItL1Tyqt8#tZ zY120E;^VG`!lZn&3sPd$RkdHpU#|w+bYV)pJC|SH9g%|5IkxVTQcBA4CL0}$&}ef@ zW^Vtj%M;;_1xxP9x#ex17&4N*{ksO*_4O}xYu(p*JkL#yr}@7b)t5X?%CY<+s5_MJ zuiqt+N_;A(_)%lumoyRFixWa-M7qK_9s6<1X?JDa9fP!+_6u~~M$5L=ipB=7(j#f< zZ<k8E`Rwou;l+s_T|L^56YmSu4%^`QFTOq==C$v}z48r)V)i|8HET~j$njp}R%Tx6 z6z1$DcJ|+-rK;8O+M4D2OI&h9Z=KBOt2kjcN}lMJQdr(sa_4xJa;t^b;+K^1J9dTU z7E3oumY>34J%=bs549%~_mA(|={uZNs_0?o7;-LBP(ZRnkd{-^|2|=4vUTmtByHL8 zEph`(LSEzQj68a+`d$V<45J7cyv^#|^|%fD#si1Nx!4NW*`l*{->HEWNh6-|g>-=r zXmQ|-i}Ku$ndUeHQ^&ieT!Lf}vf6GaqW9$DJ2NWrqwPY%%4nip$@vK$nRp*_C-v<| zuKz~ZyN&<%!NS26&x?jhy+@awJipMQ-8(X4#Ae5??U<1QMt1l9R=w9fAnEF}NYu$2 z>6<Remz*?TALWUbsLm^V8JpOI8Qrv^I{Uf`UiQ}=bwlT?m7XzzYl?d75*MLY>}Vkc zIb*A?G*z8^IvibmBKn_u^5&T_1oey0gZS2~obf(#xk=erZGTEdQnt3DMGM+0oPwss zj5zXD;(oWhB_T@~Ig#9@v)AKtXu3>Inmgf@A|-lD-1U>cNyl3h?ADD9)GG4}zUGPk zZzaXe!~Kf?<~@$G?Uql3t8jy9{2!doq4=J}j9ktTxss{p6!9Ud<L0{JvuM8zT}59} zy?q<PQ`(o7I;|2vYuA@mlwTC2VWpG0`?f+&<cL+^MokC9tZR;`s@;M`H_iw&h5ar5 zCVhp4@v?m@Lrzz?2Z>jyDERlA*xZ!=Q)KDs5O)phz>Vq3BNGoM(H|=1*Q4$^2fTZw z(%nq1P|5Rt81}SYJpEEzMPl5VJsV5&4e)ZWKDyo<HF@#^TWD2$%#hJqrPwhn_I`~h zrl9TX+98FE%rR>Z>1EwpkHx-AQVQc8%JMz;{H~p{=FXV>jIxvm4X*qv52e?Y-f%DJ zxEA165GikEASQ^fH6K#d!Tpu2HP{sFs%E=e$gYd$aj$+xue6N+Wc(rAz~wUsk2`(b z8Kvmyz%bKQxpP}~baG-rwYcYCv<u@FIev&XxIE!%Ztbxbt-Y%ng;He-ByR+Lk{8!A z+z_?%ukrIgzuh|^o09LLIM$E(jD2g{U~B*3O~s^?p1O(7xg6;i3B@l87#4eO$$oiS znHnuA9vGDnw~@NpSw1YYZjoo+HWTzC%$-J_E}3wK-(9hvP0Ct}242U82Wv+8>kHOi zlkR<=>ZBTU*8RF_d#Bl@zZsRIhx<%~Z@Z<pYsoy05ZE@HkOT*OyM_H}bO0QHT>=ik z>adw3!DK(8R|q$vy{FTxw%#xliD~6qXmY^7_9kthVPTF~Xy1CfBqbU~?1QmxmU=+k z(ggxvEuA;0e&+ci-zQR{-f7aO{O(Pz_OsEjLh_K>MbvoZ4nxtk5u{g@nPv)cgW_R} z9}EA4K4@z0?7ue}Z(o~R(X&FjejUI2g~08PH1E4w>9o{)S(?1>Z0XMvTb|;&EuyOE z<Uf?|k(eh=|5A0k%+DyPOynyZIUEys#os=5y;pE3-eqjU+vfn|kx^`PvS0aFgK5k7 z_Ws8b<tF{v{r8Xc%Ra(S^vGriKjcqYF_IFKBCZ&>GvWNpYX)Nv<8|a^;<iw`qCF!m zJCer*R!Lka?@SE&ch5f_idG-0J0#H0t`-WqI6fU-<8)0T>1>bh#&znEcl-r!T#pn= z4$?Yudha6F%4b>*8@=BdtXXY4N+`U4Dmx$}>HeVJk-QdTG@t!tVT#0(LeV0gvqyyw z2sEp^9eY0N`u10Tm4n8No&A=)IeEC|gnmEXoNSzu!1<4R<%-9kY_8~5Ej?zReg<ow z_S`;&)5*<v9D*b@Pp375jJ+bUt(~O-SF0ZSb>Mn78wuMs#;i&eUA0Zk_RXQ3b&TT} z;SCI<r<Tr9OC%fOR>=7-FUB@*&;8|n>(_g^HGf3@QODE3LpmX~ELnymQm{Sx9xrKS zK29p~?v@R$0=v6Dr5aW>-!{+h@?Q58|Kz8{{W`%J+lDAdb&M5VHrX_m<yIVSusgG) z`wE|gu8*tGFN+9LZP$c*%%oTIyT7Y#Uu$+WtVYvbCQ+Do^ID=OntZUS+AP2A;pDLp zQ|q{WRobah5mlF}4!P`?SD*ypcWDw;D;8QgDGN?GxP&*+636N@_L%TF<fCxBbhCW% zM%&xYBGf(}%YX|$hc1=>DY;1-JLnf)ezmPau$)1;=`-FU=-r-83tX=C`S#}GZufju zQ>sXNT0Ny=k@nc%cFnvA_i4SC)?_ORXHq8B4D%el1uPX`c~uG#S1M7C+*MMqLw78E zhY2dI8@+N^qrM<pEkuRyo-$3h*azHIX|CuJeg6U1uTYr#f1L)jm7pty!Nt_EpVqGj zXVf`s*T3XSv0ipIVh~5QdLCYp1wi6^@nTR33ob<X9tfmOm*uFzZ-27JIbc;RY)sW` zt!7sXsMOap1pmxDHwCgCeJ^w>I1+;TUd<Y7V_}N3cTlx9g%Oxl!1(@Fz@fhB!X!Qg zzGte&a;gDDb7s`2pRO@2w9ly`vje6Ilj<$_c7{OhbUJ}K%$c%`!Wu9kSA#`Xja47q z=~M~uTQ&ejWO4`vDE9|h6MO?f*d<f1pCzFOx6YSGxl|H(I2h0=%!FyILZ_|=bX<Z@ zIXy76kKLDpG=TR2T<cl5;7+F|gWhKYZ346vNMyq9`PzDJfsBPN=&eq$MKd*arcmkB zATW410h$o^5JE{IECTR`<Q+_;Q1BqjI5Ciw%m8kGTdmE2GB82|h<4gEtNm!8e#?mt z;D<+GLqmLk>a(vGqGSRyU{Fnm`aqrr7bz4<dKN=G5TxM1z~0~yR6MN!z}y8FQ<9oh z=+qK`&H;`)dq`^^SX>2c5xsOO-~oZpkzorD1g}Y<6rk&3>PsSGy}W?MtqFky@A(X# zIuNZK0cK?^=;PUAu>j0#HtjbHCV*6?jzA&OoE$*Jlga*}LF`SF?WLhv1O|zqC<>*> zYB;#lsYKx0&kH@BFpW8n*yDcc6?;_zaJs<-jPSkCsSX-!aV=P5kUgF@Nu<{a%#K*F z134Q{9|YX7X(v$62_cY3^G%t~rD>Q0z@)1|zs)vjJ6Jq9;7#Ki`w+eS**En?7;n&7 zu==V3T&eFboN3ZiMx3D8qYc;VjFUk_H-WWCau(VFXSQf~viH0L$<p$oTrv;PAp(HR zxGYwoQ{`RRg4u%PEm#VL7~lyq<6`h+F9OK~tPR1wo}q|fJ3M>gwD$<h61N|-us1_A zl+PEzjP+uR3A<ynvirg<hO=WL=vh8&(Wl2haO~h*nJ_e*eGq{@?#~H&b}m8~6;8Z^ zpbmt5cVjrE;ei2}0ANsjD@<R3Rp?ZF=p4ZLJsD20%)C2Oh4~(z31DYTGiF#ITp7;7 z!>UfFHqNcgN`x}M+YQ6RnN<+@t>JUp#)9YOkqst-Ga?{FsDpEeX0(5v{0J~SEbWiL zXC2}M4?UH@u&|;%0y`eb33ldo4~z-x8zY!oVmV=c+f$m?RfDC35mdQ2E>Pze7KWP- z>!Bh<&57I+O_^s}9Tg^k)h7{xx@0a0IA~GAOt2yy!X%Q$1rt~LbTB6@Du!_0%HV>N zlf)QI1&gvERKwso23mJ!Ou6ZS#zCS5W`gxE5T>C#E|{i<1D35C222I33?Njaz`On7 zi<+VWFP6D{e-{yiN#M|Jgk<44u1TiMI78S5W`Sdb5f+{zu34s{CfWN7a3Cf^@L%!& zN$?|!!9j2c)j$~+R6n#891w-z8(!oBpL2K=+%a$r2|~8-(vQj5_XT`<0Ksf;oP+tz z9CObS!0m)Tgg`K#xBM8B(|Z)Wb&DYL{WTYv`;A=q6~Nnx2+!lTIXtj8J7dZE!P_{z z#f8w6F}^!?^KE#+ZDv+xd5O&3EmomZzsv?>E-~ygGum45fk!SBN&|eo1rKw^?aZJ4 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/dataconnect/gradle/wrapper/gradle-wrapper.properties b/dataconnect/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..a048a37a2 --- /dev/null +++ b/dataconnect/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed May 08 19:29:05 BST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/dataconnect/gradlew b/dataconnect/gradlew new file mode 100755 index 000000000..4f906e0c8 --- /dev/null +++ b/dataconnect/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/dataconnect/gradlew.bat b/dataconnect/gradlew.bat new file mode 100644 index 000000000..107acd32c --- /dev/null +++ b/dataconnect/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/dataconnect/settings.gradle.kts b/dataconnect/settings.gradle.kts new file mode 100644 index 000000000..67a300440 --- /dev/null +++ b/dataconnect/settings.gradle.kts @@ -0,0 +1,31 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } + dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } + } +} + +rootProject.name = "Firebase Data Connect" +include(":app") + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..e558da6f4 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,31 @@ +[versions] +agp = "8.4.0" +kotlin = "1.9.23" +coreKtx = "1.13.1" +junit = "4.13.2" +junitVersion = "1.1.5" +espressoCore = "3.5.1" +lifecycleRuntimeKtx = "2.7.0" +activityCompose = "1.9.0" +composeBom = "2023.08.00" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } + diff --git a/mock-google-services.json b/mock-google-services.json index 50f138650..a596ae07b 100644 --- a/mock-google-services.json +++ b/mock-google-services.json @@ -957,6 +957,25 @@ "test_interstitial_ad_unit_id": "ca-app-pub-3940256099942544/1033173712" } } + }, + { + "client_info": { + "mobilesdk_app_id": "1:474448463284:android:c76572afd2f0ba8d97e8e1", + "android_client_info": { + "package_name": "com.google.firebase.example.dataconnect" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyCPndbsEs_QWumL5_B0BpNLuMkvVSecvL0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } } ], "client_info": [], diff --git a/settings.gradle.kts b/settings.gradle.kts index b67a2413e..5d0d73ef2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,7 @@ include(":admob:app", ":config:app", ":crash:app", ":database:app", + ":dataconnect:app", ":dynamiclinks:app", ":firestore:app", ":functions:app", From 2e242ffa0f8a4620791de4a10edfd439b3875b7c Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 10 May 2024 19:27:15 +0100 Subject: [PATCH 02/63] chore: add Firebase dependencies --- dataconnect/app/build.gradle.kts | 6 ++++++ dataconnect/build.gradle.kts | 1 + gradle/libs.versions.toml | 7 ++++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/dataconnect/app/build.gradle.kts b/dataconnect/app/build.gradle.kts index 2463e0da5..65c4bcc79 100644 --- a/dataconnect/app/build.gradle.kts +++ b/dataconnect/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.google.services) } android { @@ -59,6 +60,11 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + + // Firebase dependencies + implementation(libs.firebase.auth) + implementation(libs.firebase.dataconnect) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/dataconnect/build.gradle.kts b/dataconnect/build.gradle.kts index f74b04bf2..7252638e5 100644 --- a/dataconnect/build.gradle.kts +++ b/dataconnect/build.gradle.kts @@ -2,4 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false + alias(libs.plugins.google.services) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e558da6f4..3df2050c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,7 @@ [versions] agp = "8.4.0" +firebaseAuth = "23.0.0" +firebaseDataConnect = "16.0.0-alpha01" kotlin = "1.9.23" coreKtx = "1.13.1" junit = "4.13.2" @@ -8,9 +10,12 @@ espressoCore = "3.5.1" lifecycleRuntimeKtx = "2.7.0" activityCompose = "1.9.0" composeBom = "2023.08.00" +googleServices = "4.4.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +firebase-auth = { module = "com.google.firebase:firebase-auth", version.ref = "firebaseAuth" } +firebase-dataconnect = { module = "com.google.firebase:firebase-dataconnect", version.ref = "firebaseDataConnect" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -28,4 +33,4 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } - +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } From bc89f0b4f9d439b55095f35d953e2e8078a7d2bb Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 13 May 2024 21:59:35 +0100 Subject: [PATCH 03/63] add first schema --- dataconnect/app/build.gradle.kts | 3 + dataconnect/dataconnect.yaml | 7 ++ .../dataconnect/connectors/connector.yaml | 6 ++ dataconnect/schema/movie_schema.gql | 70 +++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 dataconnect/dataconnect.yaml create mode 100644 dataconnect/dataconnect/connectors/connector.yaml create mode 100644 dataconnect/schema/movie_schema.gql diff --git a/dataconnect/app/build.gradle.kts b/dataconnect/app/build.gradle.kts index 65c4bcc79..c55e39a3e 100644 --- a/dataconnect/app/build.gradle.kts +++ b/dataconnect/app/build.gradle.kts @@ -48,6 +48,9 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + sourceSets.getByName("main") { + java.srcDirs("build/generated/sources") + } } dependencies { diff --git a/dataconnect/dataconnect.yaml b/dataconnect/dataconnect.yaml new file mode 100644 index 000000000..ccd5417c5 --- /dev/null +++ b/dataconnect/dataconnect.yaml @@ -0,0 +1,7 @@ +specVersion: v1alpha +serviceId: local +schema: + source: /schema +connectorDirs: [ + "./dataconnect/connectors", +] diff --git a/dataconnect/dataconnect/connectors/connector.yaml b/dataconnect/dataconnect/connectors/connector.yaml new file mode 100644 index 000000000..5f73ab105 --- /dev/null +++ b/dataconnect/dataconnect/connectors/connector.yaml @@ -0,0 +1,6 @@ +connectorId: movies +authMode: PUBLIC +generate: + kotlinSdk: + outputDir: ../../app/build/generated/sources/com/google/firebase/dataconnect/movies + package: com.google.firebase.dataconnect.movies diff --git a/dataconnect/schema/movie_schema.gql b/dataconnect/schema/movie_schema.gql new file mode 100644 index 000000000..b77bcc079 --- /dev/null +++ b/dataconnect/schema/movie_schema.gql @@ -0,0 +1,70 @@ +# Movies +type Movie + @table(name: "Movies", singular: "movie", plural: "movies", key: ["id"]) { + id: UUID! @col(name: "movie_id") @default(expr: "uuidV4()") + title: String! + releaseYear: Int @col(name: "release_year") + genre: String + rating: Int @col(name: "rating") +} +# Movie Metadata +# Movie - MovieMetadata is a one-to-one relationship +type MovieMetadata + @table( + name: "MovieMetadata" + ) { + # @ref creates a field in the current table (MovieMetadata) that holds the primary key of the referenced type + # In this case, @ref(fields: "id") is implied + movie: Movie! @ref + # movieId: UUID <- this is created by the above @ref + director: String @col(name: "director") +} +# Actors +# Suppose an actor can participate in multiple movies and movies can have multiple actors +# Movie - Actors (or vice versa) is a many to many relationship +type Actor @table(name: "Actors", singular: "actor", plural: "actors") { + id: UUID! @col(name: "actor_id") @default(expr: "uuidV4()") + name: String! @col(name: "name", dataType: "varchar(30)") +} +# Join table for many-to-many relationship for movies and actors +# The 'key' param signifies the primary key(s) of this table +# In this case, the keys are [movieId, actorId], the generated fields of the reference types [movie, actor] +type MovieActor @table(key: ["movie", "actor"]) { + # @ref creates a field in the current table (MovieActor) that holds the primary key of the referenced type + # In this case, @ref(fields: "id") is implied + movie: Movie! @ref + # movieId: UUID! <- this is created by the above @ref, see: implicit.gql + actor: Actor! @ref + # actorId: UUID! <- this is created by the above @ref, see: implicit.gql + role: String! @col(name: "role") # "main" or "supporting" + # TODO: optional other fields +} +# Users +# Suppose a user can leave reviews for movies +# user:reviews is a one to many relationship, movie:reviews is a one to many relationship, movie:user is a many to many relationship +type User + @table(name: "Users", singular: "user", plural: "users", key: ["id"]) { + id: UUID! @col(name: "user_id") @default(expr: "uuidV4()") + auth: String @col(name: "user_auth") @default(expr: "auth.uid") + username: String! @col(name: "username", dataType: "varchar(30)") + # The following are generated from the @ref in the Review table + # reviews_on_user + # movies_via_Review +} +# Reviews +type Review @table(name: "Reviews", key: ["movie", "user"]) { + id: UUID! @col(name: "review_id") @default(expr: "uuidV4()") + user: User! + movie: Movie! + rating: Int + reviewText: String + reviewDate: Date! @default(expr: "request.time") +} +# Self Joins +extend type Movie { + sequelTo: Movie +} + +type Hello @table { + id: UUID! +} \ No newline at end of file From 441033fd7c4dfb48f7f662d434a3f1a84bb3d5ac Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 13 May 2024 23:33:46 +0100 Subject: [PATCH 04/63] update with new schema --- .../example/dataconnect/MoviesViewModel.kt | 47 ++ dataconnect/dataconnect.yaml | 7 - .../dataconnect/connectors/connector.yaml | 11 +- .../dataconnect/connectors/mutations.gql | 113 ++++ .../dataconnect/connectors/queries.gql | 565 ++++++++++++++++++ dataconnect/dataconnect/dataconnect.yaml | 10 + .../schema/schema.gql} | 53 +- 7 files changed, 787 insertions(+), 19 deletions(-) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MoviesViewModel.kt delete mode 100644 dataconnect/dataconnect.yaml create mode 100644 dataconnect/dataconnect/connectors/mutations.gql create mode 100644 dataconnect/dataconnect/connectors/queries.gql create mode 100644 dataconnect/dataconnect/dataconnect.yaml rename dataconnect/{schema/movie_schema.gql => dataconnect/schema/schema.gql} (67%) diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MoviesViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MoviesViewModel.kt new file mode 100644 index 000000000..762b323d7 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MoviesViewModel.kt @@ -0,0 +1,47 @@ +package com.google.firebase.example.dataconnect + +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.flow +import com.google.firebase.dataconnect.movies.instance +import kotlinx.coroutines.launch + +class MoviesViewModel : ViewModel() { + + fun fetchMovies() { + viewModelScope.launch { + val connector = MoviesConnector.instance + + connector.listMovies.execute() + connector.createMovie.execute( + title = "Empire Strikes Back", + releaseYear = 1980, + genre = "Sci-Fi", + rating = 5 + ) + + connector.listMoviesByGenre.execute(genre = "Sci-Fi") + +// connector.addMovie.execute(title = "", genre = "") +// +// val result = connector.listMovies.execute() +// result.data.movies.firstOrNull() + +// connector.listMoviesGenre.flow(genre = "") +// .collect { data -> +// val movies = data.movies +// } + + // Connect to the emulator on "10.0.2.2:9510" + connector.dataConnect.useEmulator() + +// (alternatively) if you're running your emulator on non-default port: + connector.dataConnect.useEmulator( + port = 9999 + ) + + } + } +} \ No newline at end of file diff --git a/dataconnect/dataconnect.yaml b/dataconnect/dataconnect.yaml deleted file mode 100644 index ccd5417c5..000000000 --- a/dataconnect/dataconnect.yaml +++ /dev/null @@ -1,7 +0,0 @@ -specVersion: v1alpha -serviceId: local -schema: - source: /schema -connectorDirs: [ - "./dataconnect/connectors", -] diff --git a/dataconnect/dataconnect/connectors/connector.yaml b/dataconnect/dataconnect/connectors/connector.yaml index 5f73ab105..5e80be132 100644 --- a/dataconnect/dataconnect/connectors/connector.yaml +++ b/dataconnect/dataconnect/connectors/connector.yaml @@ -1,6 +1,13 @@ -connectorId: movies +connectorId: movie-connector +# Required. Accepted values are either "PUBLIC" or "ADMIN" (only "PUBLIC" for gated private +# preview). If "ADMIN", the connector in this directory is an AdminConnector and its operations +# are gated by IAM. authMode: PUBLIC generate: + # (Web SDK generation omitted, but can be found in https://github.com/firebase/quickstart-js) kotlinSdk: - outputDir: ../../app/build/generated/sources/com/google/firebase/dataconnect/movies + # Create a custom package name for your generated SDK package: com.google.firebase.dataconnect.movies + # Specify where to store the generated SDK + # We're using the build/ directory so that generated code doesn't get checked into git + outputDir: ../../app/build/generated/sources/com/google/firebase/dataconnect/movies diff --git a/dataconnect/dataconnect/connectors/mutations.gql b/dataconnect/dataconnect/connectors/mutations.gql new file mode 100644 index 000000000..324c223d8 --- /dev/null +++ b/dataconnect/dataconnect/connectors/mutations.gql @@ -0,0 +1,113 @@ +# Create a movie based on user input +mutation createMovie( + $title: String! + $releaseYear: Int! + $genre: String! + $rating: Float + $description: String + $imageUrl: String! + $tags: [String!] = [] +) { + movie_insert( + data: { + title: $title + releaseYear: $releaseYear + genre: $genre + rating: $rating + description: $description + imageUrl: $imageUrl + tags: $tags + } + ) +} + +# Update movie information based on the provided ID +mutation updateMovie( + $id: UUID! + $title: String + $releaseYear: Int + $genre: String + $rating: Float + $description: String + $imageUrl: String + $tags: [String!] = [] +) { + movie_update( + id: $id + data: { + title: $title + releaseYear: $releaseYear + genre: $genre + rating: $rating + description: $description + imageUrl: $imageUrl + tags: $tags + } + ) +} + +# Delete a movie by its ID +mutation deleteMovie($id: UUID!) { + movie_delete(id: $id) +} + +# Delete movies with a rating lower than the specified minimum rating +mutation deleteUnpopularMovies($minRating: Float!) { + movie_deleteMany(where: { rating: { le: $minRating } }) +} + +# Add a movie to the user's watched list +mutation addWatchedMovie($movieId: UUID!) @auth(level: USER) { + watchedMovie_upsert(data: { userId_expr: "auth.uid", movieId: $movieId }) +} + +# Remove a movie from the user's watched list +mutation deleteWatchedMovie($userId: String!, $movieId: UUID!) @auth(level: USER) { + watchedMovie_delete(key: { userId: $userId, movieId: $movieId }) +} + +# Add a movie to the user's favorites list +mutation addFavoritedMovie($movieId: UUID!) @auth(level: USER) { + favoriteMovie_upsert(data: { userId_expr: "auth.uid", movieId: $movieId }) +} + +# Remove a movie from the user's favorites list +mutation deleteFavoritedMovie($userId: String!, $movieId: UUID!) @auth(level: USER) { + favoriteMovie_delete(key: { userId: $userId, movieId: $movieId }) +} + +# Add an actor to the user's favorites list +mutation addFavoritedActor($actorId: UUID!) @auth(level: USER) { + favoriteActor_upsert(data: { userId_expr: "auth.uid", actorId: $actorId }) +} + +# Remove an actor from the user's favorites list +mutation deleteFavoriteActor($userId: String!, $actorId: UUID!) @auth(level: USER) { + favoriteActor_delete(key: { userId: $userId, actorId: $actorId }) +} + +# Add a review for a movie +mutation addReview($movieId: UUID!, $rating: Int!, $reviewText: String!) @auth(level: USER) { + review_upsert( + data: { + userId_expr: "auth.uid" + movieId: $movieId + rating: $rating + reviewText: $reviewText + reviewDate_date: { today: true } + } + ) +} + +# Delete a user's review for a movie +mutation deleteReview($movieId: UUID!, $userId: String!) @auth(level: USER) { + review_delete(key: { userId: $userId, movieId: $movieId }) +} + +# Upsert (update or insert) a user based on their username +mutation upsertUser($username: String!) @auth(level: USER) { + user_upsert(data: { + id_expr: "auth.uid", + username: $username + }) +} \ No newline at end of file diff --git a/dataconnect/dataconnect/connectors/queries.gql b/dataconnect/dataconnect/connectors/queries.gql new file mode 100644 index 000000000..8f8653101 --- /dev/null +++ b/dataconnect/dataconnect/connectors/queries.gql @@ -0,0 +1,565 @@ +# List subset of fields for movies +query ListMovies @auth(level: PUBLIC) { + movies { + id + title + imageUrl + releaseYear + genre + rating + tags + } +} + +# List subset of fields for users +query ListUsers @auth(level: PUBLIC) { + users { + id + username + favoriteActors_on_user { + actor { + id + name + imageUrl + } + } + favoriteMovies_on_user { + movie { + id + title + genre + imageUrl + tags + } + } + reviews_on_user { + id + rating + reviewText + reviewDate + movie { + id + title + } + } + watchedMovies_on_user { + movie { + id + title + genre + imageUrl + } + } + } +} + +# List movies of a certain genre +query ListMoviesByGenre($genre: String!) @auth(level: PUBLIC) { + mostPopular: movies( + where: { genre: { eq: $genre } } + orderBy: { rating: DESC } + ) { + id + title + imageUrl + rating + tags + } + mostRecent: movies( + where: { genre: { eq: $genre } } + orderBy: { releaseYear: DESC } + ) { + id + title + imageUrl + rating + tags + } +} + +# List movies by the order of release +query ListMoviesByReleaseYear @auth(level: PUBLIC) { + movies(orderBy: [{ releaseYear: DESC }]) { + id + title + imageUrl + } +} + +# Get movie by id +query GetMovieById($id: UUID!) @auth(level: PUBLIC) { + movie(id: $id) { + id + title + imageUrl + releaseYear + genre + rating + description + tags + metadata: movieMetadatas_on_movie { + director + } + mainActors: actors_via_MovieActor(where: { role: { eq: "main" } }) { + id + name + imageUrl + } + supportingActors: actors_via_MovieActor( + where: { role: { eq: "supporting" } } + ) { + id + name + imageUrl + } + sequelTo { + id + title + imageUrl + } + reviews: reviews_on_movie { + id + reviewText + reviewDate + rating + user { + id + username + } + } + } +} + +# Get actor by id +query GetActorById($id: UUID!) @auth(level: PUBLIC) { + actor(id: $id) { + id + name + imageUrl + biography + mainActors: movies_via_MovieActor(where: { role: { eq: "main" } }) { + id + title + genre + tags + imageUrl + } + supportingActors: movies_via_MovieActor( + where: { role: { eq: "supporting" } } + ) { + id + title + genre + tags + imageUrl + } + } +} + +# User movie preferences +query UserMoviePreferences($username: String!) @auth(level: USER) { + users(where: { username: { eq: $username } }) { + likedMovies: movies_via_Review(where: { rating: { ge: 4 } }) { + title + imageUrl + genre + description + } + dislikedMovies: movies_via_Review(where: { rating: { le: 2 } }) { + title + imageUrl + genre + description + } + } +} + +# Get movie metadata +query GetMovieMetadata($id: UUID!) @auth(level: PUBLIC) { + movie(id: $id) { + movieMetadatas_on_movie { + director + } + } +} + +# Get movie cast and actor roles +query GetMovieCast($movieId: UUID!, $actorId: UUID!) @auth(level: PUBLIC) { + movie(id: $movieId) { + mainActors: actors_via_MovieActor(where: { role: { eq: "main" } }) { + id + name + imageUrl + } + supportingActors: actors_via_MovieActor( + where: { role: { eq: "supporting" } } + ) { + id + name + imageUrl + } + } + actor(id: $actorId) { + mainRoles: movies_via_MovieActor(where: { role: { eq: "main" } }) { + id + title + imageUrl + } + supportingRoles: movies_via_MovieActor( + where: { role: { eq: "supporting" } } + ) { + id + title + imageUrl + } + } +} + +# List movies by partial title match +query ListMoviesByPartialTitle($input: String!) @auth(level: PUBLIC) { + movies(where: { title: { contains: $input } }) { + id + title + genre + rating + imageUrl + } +} + +# Fetch a single movie using key scalars (same as get movie by id) +query MovieByKey($key: Movie_Key!) @auth(level: PUBLIC) { + movie(key: $key) { + title + imageUrl + } +} + +# Fetch movies by title +query MovieByTitle($title: String!) @auth(level: PUBLIC) { + movies(where: { title: { eq: $title } }) { + id + title + imageUrl + genre + rating + } +} + +# Fetch top-rated movies by genre +query MovieByTopRating($genre: String) @auth(level: PUBLIC) { + mostPopular: movies( + where: { genre: { eq: $genre } } + orderBy: { rating: DESC } + ) { + id + title + imageUrl + rating + tags + } +} + +# List movies by tag +query ListMoviesByTag($tag: String!) @auth(level: PUBLIC) { + movies(where: { tags: { includes: $tag } }) { + id + title + imageUrl + genre + rating + } +} + +# List top 10 movies +query MoviesTop10 @auth(level: PUBLIC) { + movies(orderBy: [{ rating: DESC }], limit: 10) { + id + title + imageUrl + rating + genre + tags + metadata: movieMetadatas_on_movie { + director + } + mainActors: actors_via_MovieActor(where: { role: { eq: "main" } }) { + id + name + imageUrl + } + supportingActors: actors_via_MovieActor(where: { role: { eq: "supporting" } }) { + id + name + imageUrl + } + } +} + +# List movies by release year range +query MoviesByReleaseYear($min: Int, $max: Int) @auth(level: PUBLIC) { + movies( + where: { releaseYear: { le: $max, ge: $min } } + orderBy: [{ releaseYear: ASC }] + ) { + id + rating + title + imageUrl + } +} + +# List recently released movies +query MoviesRecentlyReleased @auth(level: PUBLIC) { + movies(where: { releaseYear: { ge: 2010 } }) { + id + title + rating + imageUrl + genre + tags + } +} + +# List movies with filtering on fields +query ListMoviesFilter($genre: String, $limit: Int) @auth(level: PUBLIC) { + movies(where: { genre: { eq: $genre } }, limit: $limit) { + title + imageUrl + } +} + +# List movies by partial title string match +query ListMoviesByTitleString( + $prefix: String + $suffix: String + $contained: String +) @auth(level: PUBLIC) { + prefixed: movies(where: { description: { startsWith: $prefix } }) { + title + } + suffixed: movies(where: { description: { endsWith: $suffix } }) { + title + } + contained: movies(where: { description: { contains: $contained } }) { + title + } +} + +# List movies by rating and genre with OR/AND filters +query ListMoviesByRatingAndGenre($minRating: Float!, $genre: String) +@auth(level: PUBLIC) { + movies( + where: { _or: [{ rating: { ge: $minRating } }, { genre: { eq: $genre } }] } + ) { + title + imageUrl + } +} + +# Get favorite movies by user ID +query GetFavoriteMoviesById($id: String!) @auth(level: USER) { + user(id: $id) { + favoriteMovies_on_user { + movie { + id + title + genre + imageUrl + releaseYear + rating + description + } + } + } +} + +# Get favorite actors by user ID +query GetFavoriteActorsById($id: String!) @auth(level: USER) { + user(id: $id) { + favoriteActors_on_user { + actor { + id + name + imageUrl + } + } + } +} + +# Get watched movies by user ID +query GetWatchedMoviesByAuthId($id: String!) @auth(level: USER) { + user(id: $id) { + watchedMovies_on_user { + movie { + id + title + genre + imageUrl + releaseYear + rating + description + } + } + } +} + +# Get user by ID +query GetUserById($id: String!) @auth(level: USER) { + user(id: $id) { + id + username + reviews: reviews_on_user { + id + rating + reviewDate + reviewText + movie { + id + title + } + } + watched: watchedMovies_on_user { + movie { + id + title + genre + imageUrl + releaseYear + rating + description + tags + metadata: movieMetadatas_on_movie { + director + } + } + } + favoriteMovies: favoriteMovies_on_user { + movie { + id + title + genre + imageUrl + releaseYear + rating + description + tags + metadata: movieMetadatas_on_movie { + director + } + } + } + favoriteActors: favoriteActors_on_user { + actor { + id + name + imageUrl + } + } + } +} + +# Check if a movie is watched by user +query GetIfWatched($id: String!, $movieId: UUID!) @auth(level: USER) { + watchedMovie(key: { userId: $id, movieId: $movieId }) { + movieId + } +} + +# Check if a movie is favorited by user +query GetIfFavoritedMovie($id: String!, $movieId: UUID!) @auth(level: USER) { + favoriteMovie(key: { userId: $id, movieId: $movieId }) { + movieId + } +} + +# Check if an actor is favorited by user +query GetIfFavoritedActor($id: String!, $actorId: UUID!) @auth(level: USER) { + favoriteActor(key: { userId: $id, actorId: $actorId }) { + actorId + } +} + +# Fuzzy search for movies, actors, and reviews +query fuzzySearch( + $input: String + $minYear: Int! + $maxYear: Int! + $minRating: Float! + $maxRating: Float! + $genre: String! +) @auth(level: PUBLIC) { + moviesMatchingTitle: movies( + where: { + _and: [ + { releaseYear: { ge: $minYear } } + { releaseYear: { le: $maxYear } } + { rating: { ge: $minRating } } + { rating: { le: $maxRating } } + { genre: { contains: $genre } } + { title: { contains: $input } } + ] + } + ) { + id + title + genre + rating + imageUrl + } + moviesMatchingDescription: movies( + where: { + _and: [ + { releaseYear: { ge: $minYear } } + { releaseYear: { le: $maxYear } } + { rating: { ge: $minRating } } + { rating: { le: $maxRating } } + { genre: { contains: $genre } } + { description: { contains: $input } } + ] + } + ) { + id + title + genre + rating + imageUrl + } + actorsMatchingName: actors(where: { name: { contains: $input } }) { + id + name + imageUrl + } + reviewsMatchingText: reviews(where: { reviewText: { contains: $input } }) { + id + rating + reviewText + reviewDate + movie { + id + title + } + user { + id + username + } + } +} + +# Search movie descriptions using L2 similarity with Vertex AI +query searchMovieDescriptionUsingL2Similarity($query: String!) +@auth(level: PUBLIC) { + movies_descriptionEmbedding_similarity( + compare_embed: { model: "textembedding-gecko@001", text: $query } + method: L2 + within: 2 + where: { description: { ne: "" } } + limit: 5 + ) { + id + title + description + tags + rating + imageUrl + } +} \ No newline at end of file diff --git a/dataconnect/dataconnect/dataconnect.yaml b/dataconnect/dataconnect/dataconnect.yaml new file mode 100644 index 000000000..5d09e5a65 --- /dev/null +++ b/dataconnect/dataconnect/dataconnect.yaml @@ -0,0 +1,10 @@ +specVersion: "v1alpha" +serviceId: "dataconnect" +schema: + source: "./schema" + datasource: + postgresql: + database: "postgres" + cloudSql: + instanceId: "cloud-sql-instance" +connectorDirs: ["./connectors"] diff --git a/dataconnect/schema/movie_schema.gql b/dataconnect/dataconnect/schema/schema.gql similarity index 67% rename from dataconnect/schema/movie_schema.gql rename to dataconnect/dataconnect/schema/schema.gql index b77bcc079..1c7f04c76 100644 --- a/dataconnect/schema/movie_schema.gql +++ b/dataconnect/dataconnect/schema/schema.gql @@ -3,10 +3,16 @@ type Movie @table(name: "Movies", singular: "movie", plural: "movies", key: ["id"]) { id: UUID! @col(name: "movie_id") @default(expr: "uuidV4()") title: String! + imageUrl: String! @col(name: "image_url") releaseYear: Int @col(name: "release_year") genre: String - rating: Int @col(name: "rating") + rating: Float @col(name: "rating") + description: String @col(name: "description") + tags: [String] @col(name: "tags") + # Vectors + descriptionEmbedding: Vector @col(size:768) # vector } + # Movie Metadata # Movie - MovieMetadata is a one-to-one relationship type MovieMetadata @@ -18,14 +24,19 @@ type MovieMetadata movie: Movie! @ref # movieId: UUID <- this is created by the above @ref director: String @col(name: "director") + # TODO: optional other fields } + # Actors # Suppose an actor can participate in multiple movies and movies can have multiple actors # Movie - Actors (or vice versa) is a many to many relationship type Actor @table(name: "Actors", singular: "actor", plural: "actors") { id: UUID! @col(name: "actor_id") @default(expr: "uuidV4()") + imageUrl: String! @col(name: "image_url") name: String! @col(name: "name", dataType: "varchar(30)") + biography: String @col(name: "biography") } + # Join table for many-to-many relationship for movies and actors # The 'key' param signifies the primary key(s) of this table # In this case, the keys are [movieId, actorId], the generated fields of the reference types [movie, actor] @@ -34,37 +45,59 @@ type MovieActor @table(key: ["movie", "actor"]) { # In this case, @ref(fields: "id") is implied movie: Movie! @ref # movieId: UUID! <- this is created by the above @ref, see: implicit.gql + actor: Actor! @ref # actorId: UUID! <- this is created by the above @ref, see: implicit.gql + role: String! @col(name: "role") # "main" or "supporting" # TODO: optional other fields } + # Users # Suppose a user can leave reviews for movies # user:reviews is a one to many relationship, movie:reviews is a one to many relationship, movie:user is a many to many relationship type User @table(name: "Users", singular: "user", plural: "users", key: ["id"]) { - id: UUID! @col(name: "user_id") @default(expr: "uuidV4()") - auth: String @col(name: "user_auth") @default(expr: "auth.uid") - username: String! @col(name: "username", dataType: "varchar(30)") + # id: UUID! @col(name: "user_id") @default(expr: "uuidV4()") + id: String! @col(name: "user_auth") + username: String! @col(name: "username", dataType: "varchar(50)") # The following are generated from the @ref in the Review table # reviews_on_user # movies_via_Review } + +# Join table for many-to-many relationship for users and favorite movies +type FavoriteMovie + @table(name: "FavoriteMovies", key: ["user", "movie"]) { + user: User! @ref + movie: Movie! @ref +} + +# Join table for many-to-many relationship for users and favorite actors +type FavoriteActor + @table(name: "FavoriteActors", key: ["user", "actor"]) { + user: User! @ref + actor: Actor! @ref +} + +# Join table for many-to-many relationship for users and watched movies +type WatchedMovie + @table(name: "WatchedMovies", key: ["user", "movie"]) { + user: User! @ref + movie: Movie! @ref +} + # Reviews type Review @table(name: "Reviews", key: ["movie", "user"]) { id: UUID! @col(name: "review_id") @default(expr: "uuidV4()") - user: User! - movie: Movie! + user: User! @ref + movie: Movie! @ref rating: Int reviewText: String reviewDate: Date! @default(expr: "request.time") } + # Self Joins extend type Movie { sequelTo: Movie } - -type Hello @table { - id: UUID! -} \ No newline at end of file From 3c999166d847b3ffe9ab8b67d582d899cc2b83d0 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 31 May 2024 20:27:05 +0100 Subject: [PATCH 05/63] add kotlin serialization and androidx.lifecycle --- dataconnect/app/build.gradle.kts | 2 ++ gradle/libs.versions.toml | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/dataconnect/app/build.gradle.kts b/dataconnect/app/build.gradle.kts index c55e39a3e..534392c48 100644 --- a/dataconnect/app/build.gradle.kts +++ b/dataconnect/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.google.services) } @@ -57,6 +58,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.android) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3df2050c2..bed607ba5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,25 +1,26 @@ [versions] agp = "8.4.0" firebaseAuth = "23.0.0" -firebaseDataConnect = "16.0.0-alpha01" +firebaseDataConnect = "16.0.0-alpha03" kotlin = "1.9.23" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.1.5" espressoCore = "3.5.1" -lifecycleRuntimeKtx = "2.7.0" +lifecycle = "2.8.1" activityCompose = "1.9.0" composeBom = "2023.08.00" googleServices = "4.4.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-viewmodel-android = { module = "androidx.lifecycle:lifecycle-viewmodel-android", version.ref = "lifecycle" } firebase-auth = { module = "com.google.firebase:firebase-auth", version.ref = "firebaseAuth" } firebase-dataconnect = { module = "com.google.firebase:firebase-dataconnect", version.ref = "firebaseDataConnect" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } @@ -33,4 +34,5 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } From 44a274d505d84e09332c0be01271d8d74b8e5912 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 31 May 2024 20:27:31 +0100 Subject: [PATCH 06/63] firebase init --- dataconnect/.gitignore | 2 ++ dataconnect/dataconnect/connectors/connector.yaml | 2 +- dataconnect/firebase.json | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 dataconnect/firebase.json diff --git a/dataconnect/.gitignore b/dataconnect/.gitignore index aa724b770..5b4ab9b06 100644 --- a/dataconnect/.gitignore +++ b/dataconnect/.gitignore @@ -13,3 +13,5 @@ .externalNativeBuild .cxx local.properties +.dataconnect/ +.firebaserc diff --git a/dataconnect/dataconnect/connectors/connector.yaml b/dataconnect/dataconnect/connectors/connector.yaml index 5e80be132..2ba51749d 100644 --- a/dataconnect/dataconnect/connectors/connector.yaml +++ b/dataconnect/dataconnect/connectors/connector.yaml @@ -1,4 +1,4 @@ -connectorId: movie-connector +connectorId: movies # Required. Accepted values are either "PUBLIC" or "ADMIN" (only "PUBLIC" for gated private # preview). If "ADMIN", the connector in this directory is an AdminConnector and its operations # are gated by IAM. diff --git a/dataconnect/firebase.json b/dataconnect/firebase.json new file mode 100644 index 000000000..fe0eb6bfc --- /dev/null +++ b/dataconnect/firebase.json @@ -0,0 +1,6 @@ +{ + "dataconnect": { + "source": "dataconnect", + "location": "us-central1" + } +} From 08a034c5cdcdb09f88530af0412e8ec3f427ceee Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 3 Jun 2024 03:50:17 +0100 Subject: [PATCH 07/63] setup bottom navigation --- dataconnect/app/build.gradle.kts | 1 + .../example/dataconnect/MainActivity.kt | 136 +++++++++++++++--- .../example/dataconnect/MoviesViewModel.kt | 47 ------ .../dataconnect/feature/genres/Navigation.kt | 21 +++ .../dataconnect/feature/movies/Navigation.kt | 21 +++ .../dataconnect/feature/profile/Navigation.kt | 21 +++ .../dataconnect/feature/search/Navigation.kt | 21 +++ .../app/src/main/res/values/strings.xml | 6 + gradle/libs.versions.toml | 2 + 9 files changed, 206 insertions(+), 70 deletions(-) delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MoviesViewModel.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/search/Navigation.kt diff --git a/dataconnect/app/build.gradle.kts b/dataconnect/app/build.gradle.kts index 534392c48..db4b72919 100644 --- a/dataconnect/app/build.gradle.kts +++ b/dataconnect/app/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.compose.navigation) // Firebase dependencies implementation(libs.firebase.auth) 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 index 03e2481f7..4996cbe10 100644 --- 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 @@ -1,17 +1,47 @@ package com.google.firebase.example.dataconnect import android.os.Bundle +import android.util.Log 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.material.icons.filled.Search +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.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance +import com.google.firebase.example.dataconnect.feature.genres.GENRES_ROUTE +import com.google.firebase.example.dataconnect.feature.genres.genresScreen +import com.google.firebase.example.dataconnect.feature.movies.MOVIES_ROUTE +import com.google.firebase.example.dataconnect.feature.movies.moviesScreen +import com.google.firebase.example.dataconnect.feature.movies.navigateToMovies +import com.google.firebase.example.dataconnect.feature.profile.PROFILE_ROUTE +import com.google.firebase.example.dataconnect.feature.profile.profileScreen +import com.google.firebase.example.dataconnect.feature.search.SEARCH_ROUTE +import com.google.firebase.example.dataconnect.feature.search.searchScreen import com.google.firebase.example.dataconnect.ui.theme.FirebaseDataConnectTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -19,29 +49,89 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { FirebaseDataConnectTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) + val navController = rememberNavController() + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + NavigationBar { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + NavigationBarItem( + icon = { Icon(Icons.Filled.Home, contentDescription = null) }, + label = { Text(stringResource(R.string.label_movies)) }, + selected = isRouteSelected(currentDestination, MOVIES_ROUTE), + onClick = { + navController.navigateToMovies { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + // on the back stack as users select items + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination when + // reselecting the same item + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + } + ) + NavigationBarItem( + icon = { Icon(Icons.Filled.Menu, contentDescription = null) }, + label = { Text(stringResource(R.string.label_genres)) }, + selected = isRouteSelected(currentDestination, GENRES_ROUTE), + onClick = { + } + ) + NavigationBarItem( + icon = { Icon(Icons.Filled.Search, contentDescription = null) }, + label = { Text(stringResource(R.string.label_search)) }, + selected = isRouteSelected(currentDestination, SEARCH_ROUTE), + onClick = { + } + ) + NavigationBarItem( + icon = { Icon(Icons.Filled.Person, contentDescription = null) }, + label = { Text(stringResource(R.string.label_profile)) }, + selected = isRouteSelected(currentDestination, PROFILE_ROUTE), + onClick = { + } + ) + } + } + ) { innerPadding -> + NavHost( + navController, + startDestination = MOVIES_ROUTE, + Modifier.consumeWindowInsets(innerPadding), + ) { + moviesScreen() + genresScreen() + searchScreen() + profileScreen() + } } } } - } -} -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} + lifecycleScope.launch(Dispatchers.IO) { + val connector = MoviesConnector.instance -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - FirebaseDataConnectTheme { - Greeting("Android") + val response = connector.listMovies.execute() +// val response = connector.createMovie.execute( +// title = "Empire Strikes Back", +// releaseYear = 1980, +// genre = "Sci-Fi", +// imageUrl = "" +// ) { +// description = "Hello World" +// tags = emptyList() +// rating = 0.0 +// } + Log.e("Main", "$response") + } } -} \ No newline at end of file +} + +private fun isRouteSelected(currentDestination: NavDestination?, route: String) = + currentDestination?.hierarchy?.any { it.route == route } == true diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MoviesViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MoviesViewModel.kt deleted file mode 100644 index 762b323d7..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/MoviesViewModel.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.google.firebase.example.dataconnect - -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.flow -import com.google.firebase.dataconnect.movies.instance -import kotlinx.coroutines.launch - -class MoviesViewModel : ViewModel() { - - fun fetchMovies() { - viewModelScope.launch { - val connector = MoviesConnector.instance - - connector.listMovies.execute() - connector.createMovie.execute( - title = "Empire Strikes Back", - releaseYear = 1980, - genre = "Sci-Fi", - rating = 5 - ) - - connector.listMoviesByGenre.execute(genre = "Sci-Fi") - -// connector.addMovie.execute(title = "", genre = "") -// -// val result = connector.listMovies.execute() -// result.data.movies.firstOrNull() - -// connector.listMoviesGenre.flow(genre = "") -// .collect { data -> -// val movies = data.movies -// } - - // Connect to the emulator on "10.0.2.2:9510" - connector.dataConnect.useEmulator() - -// (alternatively) if you're running your emulator on non-default port: - connector.dataConnect.useEmulator( - port = 9999 - ) - - } - } -} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt new file mode 100644 index 000000000..686c9efbb --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt @@ -0,0 +1,21 @@ +package com.google.firebase.example.dataconnect.feature.genres + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable + +const val GENRES_ROUTE = "genres_route" + +fun NavController.navigateToGenres(navOptions: NavOptionsBuilder.() -> Unit) = + navigate(GENRES_ROUTE, navOptions) + +fun NavGraphBuilder.genresScreen( + +) { + composable(route = GENRES_ROUTE) { + // TODO: Call composable + } +} + + diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt new file mode 100644 index 000000000..9cc3717c3 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt @@ -0,0 +1,21 @@ +package com.google.firebase.example.dataconnect.feature.movies + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable + +const val MOVIES_ROUTE = "movies_route" + +fun NavController.navigateToMovies(navOptions: NavOptionsBuilder.() -> Unit) = + navigate(MOVIES_ROUTE, navOptions) + +fun NavGraphBuilder.moviesScreen( + +) { + composable(route = MOVIES_ROUTE) { + // TODO: Add Movies composable + } +} + + diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt new file mode 100644 index 000000000..156ba105b --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt @@ -0,0 +1,21 @@ +package com.google.firebase.example.dataconnect.feature.profile + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable + +const val PROFILE_ROUTE = "profile_route" + +fun NavController.navigateToProfile(navOptions: NavOptionsBuilder.() -> Unit) = + navigate(PROFILE_ROUTE, navOptions) + +fun NavGraphBuilder.profileScreen( + +) { + composable(route = PROFILE_ROUTE) { + // TODO: Call composable + } +} + + diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/search/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/search/Navigation.kt new file mode 100644 index 000000000..fee3bdf59 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/search/Navigation.kt @@ -0,0 +1,21 @@ +package com.google.firebase.example.dataconnect.feature.search + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable + +const val SEARCH_ROUTE = "search_route" + +fun NavController.navigateToSearch(navOptions: NavOptionsBuilder.() -> Unit) = + navigate(SEARCH_ROUTE, navOptions) + +fun NavGraphBuilder.searchScreen( + +) { + composable(route = SEARCH_ROUTE) { + // TODO: Call composable + } +} + + diff --git a/dataconnect/app/src/main/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml index 8c9ac0b83..a2cda77fb 100644 --- a/dataconnect/app/src/main/res/values/strings.xml +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -1,3 +1,9 @@ <resources> <string name="app_name">Firebase Data Connect</string> + + <!-- NavBar labels --> + <string name="label_movies">Movies</string> + <string name="label_genres">Genres</string> + <string name="label_search">Search</string> + <string name="label_profile">Profile</string> </resources> \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bed607ba5..696b8a681 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ lifecycle = "2.8.1" activityCompose = "1.9.0" composeBom = "2023.08.00" googleServices = "4.4.1" +composeNavigation = "2.7.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,6 +31,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation"} [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 12731493031636ae5e5c56772dda9abc864366d3 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 3 Jun 2024 04:57:36 +0100 Subject: [PATCH 08/63] feat: create movies screen --- dataconnect/app/build.gradle.kts | 2 + dataconnect/app/src/main/AndroidManifest.xml | 3 + .../example/dataconnect/MainActivity.kt | 24 -------- .../dataconnect/data/DataConnectMappers.kt | 15 +++++ .../dataconnect/data/DataRepository.kt | 14 +++++ .../example/dataconnect/data/Movie.kt | 14 +++++ .../feature/movies/MoviesScreen.kt | 59 +++++++++++++++++++ .../feature/movies/MoviesViewModel.kt | 29 +++++++++ .../dataconnect/feature/movies/Navigation.kt | 2 +- gradle/libs.versions.toml | 3 + 10 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataRepository.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.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/MoviesViewModel.kt diff --git a/dataconnect/app/build.gradle.kts b/dataconnect/app/build.gradle.kts index db4b72919..ff39640d0 100644 --- a/dataconnect/app/build.gradle.kts +++ b/dataconnect/app/build.gradle.kts @@ -66,6 +66,8 @@ dependencies { 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) diff --git a/dataconnect/app/src/main/AndroidManifest.xml b/dataconnect/app/src/main/AndroidManifest.xml index b5ed84e4f..230081caf 100644 --- a/dataconnect/app/src/main/AndroidManifest.xml +++ b/dataconnect/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> + <application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" 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 index 4996cbe10..cccc1901f 100644 --- 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 @@ -1,7 +1,6 @@ package com.google.firebase.example.dataconnect import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -20,16 +19,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.lifecycle.lifecycleScope import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import com.google.firebase.dataconnect.movies.MoviesConnector -import com.google.firebase.dataconnect.movies.execute -import com.google.firebase.dataconnect.movies.instance import com.google.firebase.example.dataconnect.feature.genres.GENRES_ROUTE import com.google.firebase.example.dataconnect.feature.genres.genresScreen import com.google.firebase.example.dataconnect.feature.movies.MOVIES_ROUTE @@ -40,8 +35,6 @@ import com.google.firebase.example.dataconnect.feature.profile.profileScreen import com.google.firebase.example.dataconnect.feature.search.SEARCH_ROUTE import com.google.firebase.example.dataconnect.feature.search.searchScreen import com.google.firebase.example.dataconnect.ui.theme.FirebaseDataConnectTheme -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -113,23 +106,6 @@ class MainActivity : ComponentActivity() { } } } - - lifecycleScope.launch(Dispatchers.IO) { - val connector = MoviesConnector.instance - - val response = connector.listMovies.execute() -// val response = connector.createMovie.execute( -// title = "Empire Strikes Back", -// releaseYear = 1980, -// genre = "Sci-Fi", -// imageUrl = "" -// ) { -// description = "Hello World" -// tags = emptyList() -// rating = 0.0 -// } - Log.e("Main", "$response") - } } } diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt new file mode 100644 index 000000000..6d80d4da9 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt @@ -0,0 +1,15 @@ +package com.google.firebase.example.dataconnect.data + +import com.google.firebase.dataconnect.movies.ListMoviesQuery + +fun ListMoviesQuery.Data.MoviesItem.toMovie(): Movie { + return Movie( + id = this.id, + title = this.title, + genre = this.genre, + imageUrl = this.imageUrl, + releaseYear = this.releaseYear, + rating = this.rating, + tags = this.tags + ) +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataRepository.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataRepository.kt new file mode 100644 index 000000000..1b23f1ec2 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataRepository.kt @@ -0,0 +1,14 @@ +package com.google.firebase.example.dataconnect.data + +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance + +class DataRepository( + private val moviesConnector: MoviesConnector = MoviesConnector.instance +) { + + suspend fun listMovies(): List<Movie> { + return moviesConnector.listMovies.execute().data.movies.map { it.toMovie() } + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt new file mode 100644 index 000000000..c4466547c --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt @@ -0,0 +1,14 @@ +package com.google.firebase.example.dataconnect.data + +import java.util.UUID + +data class Movie( + val id: UUID, + val title: String, + val imageUrl: String, + val releaseYear: Int? = 1970, + val genre: String? = "", + val rating: Double? = 0.0, + val description: String? = "", + val tags: List<String?>? = emptyList() +) diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesScreen.kt new file mode 100644 index 000000000..ef63d19ed --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesScreen.kt @@ -0,0 +1,59 @@ +package com.google.firebase.example.dataconnect.feature.movies + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Card +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.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.google.firebase.example.dataconnect.data.Movie + +@Composable +fun MoviesScreen( + moviesViewModel: MoviesViewModel = viewModel() +) { + val movies by moviesViewModel.movies.collectAsState() + MoviesScreen(movies) +} + +@Composable +fun MoviesScreen( + movies: List<Movie> +) { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.padding(top = 48.dp) + ) { + items(movies) { movie -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) { + AsyncImage( + model = movie.imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = movie.title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(8.dp) + ) + Text( + text = "Rating: ${movie.rating}", + modifier = Modifier.padding(bottom = 8.dp, start = 8.dp) + ) + } + } + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesViewModel.kt new file mode 100644 index 000000000..175286962 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesViewModel.kt @@ -0,0 +1,29 @@ +package com.google.firebase.example.dataconnect.feature.movies + +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.example.dataconnect.data.DataRepository +import com.google.firebase.example.dataconnect.data.Movie +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class MoviesViewModel( + private val dataRepository: DataRepository = DataRepository() +) : ViewModel() { + + private val _movies = MutableStateFlow<List<Movie>>(emptyList()) + val movies: StateFlow<List<Movie>> + get() = _movies + + init { + viewModelScope.launch { + try { + _movies.value = dataRepository.listMovies() + } catch (e: Exception) { + e.printStackTrace() + } + } + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt index 9cc3717c3..5861da685 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt @@ -14,7 +14,7 @@ fun NavGraphBuilder.moviesScreen( ) { composable(route = MOVIES_ROUTE) { - // TODO: Add Movies composable + MoviesScreen() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 696b8a681..5e230d1ca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "8.4.0" +coilCompose = "2.6.0" firebaseAuth = "23.0.0" firebaseDataConnect = "16.0.0-alpha03" kotlin = "1.9.23" @@ -15,7 +16,9 @@ composeNavigation = "2.7.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-compose-android = { module = "androidx.lifecycle:lifecycle-runtime-compose-android", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-android = { module = "androidx.lifecycle:lifecycle-viewmodel-android", version.ref = "lifecycle" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } firebase-auth = { module = "com.google.firebase:firebase-auth", version.ref = "firebaseAuth" } firebase-dataconnect = { module = "com.google.firebase:firebase-dataconnect", version.ref = "firebaseDataConnect" } junit = { group = "junit", name = "junit", version.ref = "junit" } From 30d629817bd42cd076833339b38f5fa6a0e81937 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 3 Jun 2024 05:16:59 +0100 Subject: [PATCH 09/63] add firebase data connect logo --- .../drawable-v24/ic_launcher_foreground.xml | 30 ------------------ .../res/drawable/firebase_data_connect.xml | 18 +++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 4 +-- .../mipmap-anydpi-v26/ic_launcher_round.xml | 4 +-- .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5439 bytes .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 1404 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 2898 -> 0 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2765 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 982 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 1772 -> 0 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 7233 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 1900 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 3918 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 12140 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 2884 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 5914 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 18036 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 3844 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 7778 -> 0 bytes 19 files changed, 22 insertions(+), 34 deletions(-) delete mode 100644 dataconnect/app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 dataconnect/app/src/main/res/drawable/firebase_data_connect.xml create mode 100644 dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher.png delete mode 100644 dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher.webp delete mode 100644 dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.png delete mode 100644 dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.webp delete mode 100644 dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.png delete mode 100644 dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.webp delete mode 100644 dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.png delete mode 100644 dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100644 dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp delete mode 100644 dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/dataconnect/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/dataconnect/app/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d114..000000000 --- a/dataconnect/app/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:aapt="http://schemas.android.com/aapt" - android:width="108dp" - android:height="108dp" - android:viewportWidth="108" - android:viewportHeight="108"> - <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> - <aapt:attr name="android:fillColor"> - <gradient - android:endX="85.84757" - android:endY="92.4963" - android:startX="42.9492" - android:startY="49.59793" - android:type="linear"> - <item - android:color="#44000000" - android:offset="0.0" /> - <item - android:color="#00000000" - android:offset="1.0" /> - </gradient> - </aapt:attr> - </path> - <path - android:fillColor="#FFFFFF" - android:fillType="nonZero" - android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" - android:strokeWidth="1" - android:strokeColor="#00000000" /> -</vector> \ No newline at end of file diff --git a/dataconnect/app/src/main/res/drawable/firebase_data_connect.xml b/dataconnect/app/src/main/res/drawable/firebase_data_connect.xml new file mode 100644 index 000000000..e2106454e --- /dev/null +++ b/dataconnect/app/src/main/res/drawable/firebase_data_connect.xml @@ -0,0 +1,18 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="192dp" + android:height="192dp" + android:viewportWidth="192" + android:viewportHeight="192"> + <group> + <clip-path + android:pathData="M96,96m-96,0a96,96 0,1 1,192 0a96,96 0,1 1,-192 0"/> + </group> + <path + android:pathData="M97.07,126.87l-22.22,-22.22c3.8,-6.69 2.85,-15.35 -2.86,-21.06c-6.85,-6.85 -17.95,-6.85 -24.8,0c-6.85,6.85 -6.85,17.95 0,24.8c5.71,5.71 14.36,6.66 21.06,2.86l22.22,22.22c6.65,6.66 15.37,6.71 21.03,2.06c5.69,-4.67 7.18,-13.16 1.67,-20.75l-27,-42.48l-0.1,-0.13c-2.81,-3.81 -1.65,-6.82 0.17,-8.32c1.87,-1.54 5.26,-2.16 8.68,1.27l15.97,15.97c1.82,1.82 4.78,1.82 6.6,0c1.82,-1.82 1.82,-4.78 0,-6.6l-15.97,-15.97c-6.73,-6.73 -15.55,-6.55 -21.22,-1.87c-5.69,4.69 -7.41,13.25 -1.84,20.92l27,42.48l0.1,0.13c2.88,3.9 1.65,6.79 0.02,8.13c-1.69,1.39 -5.01,2.06 -8.51,-1.44zM53.8,101.8c3.2,3.2 8.4,3.2 11.6,0c3.2,-3.2 3.2,-8.4 0,-11.6c-3.2,-3.2 -8.4,-3.2 -11.6,0c-3.2,3.2 -3.2,8.4 0,11.6zM120.27,83.87c-6.7,6.7 -6.7,17.57 0,24.27c6.7,6.7 17.57,6.7 24.27,0c6.7,-6.7 6.7,-17.57 0,-24.27c-6.7,-6.7 -17.57,-6.7 -24.27,0z" + android:strokeLineJoin="miter" + android:strokeWidth="1" + android:fillColor="#ffffff" + android:fillType="evenOdd" + android:strokeColor="#00000000" + android:strokeLineCap="butt"/> +</vector> 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 index 6f3b755bf..8eeb203f9 100644 --- a/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/dataconnect/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@drawable/ic_launcher_background" /> - <foreground android:drawable="@drawable/ic_launcher_foreground" /> - <monochrome android:drawable="@drawable/ic_launcher_foreground" /> + <foreground android:drawable="@drawable/firebase_data_connect" /> + <monochrome android:drawable="@drawable/firebase_data_connect" /> </adaptive-icon> \ 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 index 6f3b755bf..8eeb203f9 100644 --- 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 @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@drawable/ic_launcher_background" /> - <foreground android:drawable="@drawable/ic_launcher_foreground" /> - <monochrome android:drawable="@drawable/ic_launcher_foreground" /> + <foreground android:drawable="@drawable/firebase_data_connect" /> + <monochrome android:drawable="@drawable/firebase_data_connect" /> </adaptive-icon> \ 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-hdpi/ic_launcher.webp b/dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78ecd372343283f4157dcfd918ec5165bb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cm<R)d19?=)E<{+g@mp0C)CAX%7ksNnpX=jPlJEkqD9%o*fC(U7iySOYHHS zCLH@bXPyI|^Z)Mc^PG7Oc+NfBJO`d7p5;U`M53Wbd!w|M(MUoNRc2m{@^!wFKzL?& zx_RAc-^9Azxo%DmXW^9GV0~;n_G9&dym)|QrMEx!y_F=oDunn;1y)cvAc6z{0MHiz zodGIH07w8nF%*bGq9Gv`YHnm80|d1T$uW=u4vGV%tX#>e5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!<r*4*+?g*sysFgiN}}d!-T(>jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#<RA5Hg_mG&BZS>9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG<W<SKpuS2eaDGkL-J- z!+&UV_N0e<DV*|igwqdC*l{lj0T&&`uq=ycU@f9x0i8jzLa(y*X(YV?PCtUw;ks}p z4zFn7N(-OG^(9ot1ZhYISOWe9?+l%f6v41n+j<OM$_uJgNP?ZJy}hEMMH=;WiG4I` zs(bIWwSD<LJnAN(E(Xjr4k(^UYF37F_3f{;E%%FEa&I3I0GbdH@{pD5$m+1PN5CW) zyZ&*9o#8wstdx@^rci;0B4BP2+H4Y<KbJI5L(bXG(k@`Kp=d>8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqro<oln8GL@_6LPC)kg1!M)Y!|NCn7b*0sG zEN=&c2xMM<>a($ne7EUK;#3V<N-jQ7j($tREa0F&-HzYCQtR#fTCZMRN*ZSm;T7a+ zuxa$}zDL8R9wGYkHb$+gJoiM@z+u{u7a_VUBwtd)bPzHxH}C`W=^2PsBr`s7taBMG zm#Ss=-o`)W8%%x%>YkXaew%Kh^3OrMht<?zOY6P}#rBhn_hrWY$_P`{#CBR9w?+E6 zt7r#NN-tjxFY{9q75P<|L<ZJBHwn4FhG(&i>jYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)<UE%?IC0Du41FrE~F_qc8nOq>P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9<vs%8>j@06@(!<!eaZcF<_le1MVaYMg=gRy*f2#IaBH-mJIpy+L z=Gsbhd6=3>{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG diff --git a/dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/dataconnect/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2898 zcmV-Y3$650Nk&FW3jhFDMM6+kP&il$0000G0000-002h-06|PpNWB9900E$G+qN-D z+81ABX7q?;bwx%xBg?kcwr$(C-Tex-ZCkHUw(Y9#+`E5-zuONG5fgw~E2WDng@Bc@ z24xy+R1n%~6xI#u9vJ8zREI)sb<&Il(016}Z~V1n^PU3-_H17A*Bf^o)&{_uBv}Py zulRfeE8g(g6<I*pq!<m%e0eKLCnC;y@4a&(;z@3Oc_yGuA}p}r2a+O=6+01<KP&)j z?X*V!2PDO_%3er*&=0L^6alyFqZiK_dhy(U3lP;LLerOI%$mpKS51g&5Mj)6#*PVe zF_(`;R5goZ_A_QeW9~l|wn`DsB!!6;@*G4}iEuP2Ot6s0BdUVMnEezcTDX4#Y(*N4 z%PCB_a77DrWnVI8;$wcJDzUhkF$0WwCu~^;eS7IbaNIi-ro8tUGsu`9t8y&n(KArb zV_-{Zd`}5Q__NX_45pDj6i?2BDQ58^g~1BnfGwhN=w`Zb9Jh2q7g$_Q$ABGgfGsfU zQ%Xp}ueAZ7(7KK;B*zUoD8S$_dIs%z0xV#07bPs=!^#3izY*Sh+CVAuM|l6Flv1c) zL>HFhk_?o_;0@tz?1I+l+Y#Q*;RVC?(ud`_cU-~n|AX-b`JHrOIqn(-t&rOg-o`#C zh0LPxmbOAEb;zHTu!R3LDh1Q<R(Kya7{I0;4Dacb1&lp`!Jlm{pmgtAx{w^#57P>O zZTf-|lJNUxi-PpcbRjw3n~n-pG;$+dIF6eqM5+L();B2O2tQ~|p{PlpNcvDbd1l%c zLtXn%lu(3!<myn;X3ipg7@oW+6O}@J$7hVgi1~F#J<2qhIXme>aNK!V#+HNn_D3lp z2%l+hK-nsj|Bi9;V*WIcQRTt5j9<byX)%{hZi!H7I(x!SO0tBzPRXWGd1Ke*j*=vy zyQaHQRY5iP-Q*Z2C#JituSKDnx+Q<*4#r7|x$~NQt44KoTmQ*R>0A<=<I>am+cc`J zTYIN|PsYAhJ|=&h*4wI4ebv-C=Be#u>}%m;a{IGmJDU`0snWS&$9zdrT(z8#{OZ_Y zxwJx!ZClUi%YJjD6Xz@OP8{ieyJB=tn?>zaI-4JN;rr`JQbb%y5h2O-?_V@7pG_+y z(lqAsqYr!NyVb0C^|uclHaeecG)Sz;WV?rtoqOdAAN{j%?Uo%owya(F&qps@Id|Of zo@~Y-(YmfB+chv^%*3g4k3R0WqvuYUIA+8^SGJ{2Bl$X&X&v02>+0$4?di(34{pt* zG=f#yMs@Y|b&=HyH3k4yP&goF2LJ#tBLJNNDo6lG06r}ghC-pC4Q*=x3;|+W04zte zAl>l4kzUBQFYF(E`KJy?ZXd1tnfbH+Z~SMmA21KokJNs#eqcXWKUIC>{TuoKe^vhF z);H)o`t9j~`$h1D`#bxe@E`oE`cM9w(@)5Bp8BNukIwM>wZHfd0S;5bcXA*5KT3bj zc&_~`&{z7u{Et!Z_k78H75gXf4g8<_ul!H$eVspPeU3j&&Au=2R*Z<QVlXG0%J7Qu z`uQlm{Q{cWVD7XACdR6KeMUk-Q7>p#M9$9s;fqwgzfiX=E_?BwVcfx3tG9Q-+<5fw z%Hs64<N1NYeh_oukcz%rOcU>z)@Q*%s3_Xd5>S4d<X%6~`O&m@p+WTqnB(reB<gqb zpaA~={ur+R)J6BZ_}KqfN1AF`u0i5>g$s>@rN^ixeVj*tqu3ZV)biDcFf&l?lGwsa zWj3rvK}?43c{IruV2L`hUU0t^MemAn3U~x3$4mFDxj=Byowu^Q+#wKRPrWywLjIAp z9*n}<YIhnms>eQ9-gZmnd9Y0WHtwi2sn6n~?i#n9VN1B*074_VbZZ=WrpkMYr{RsI ztM_8X1)J*DZejxkjOTRJ&a*lrvMKBQURNP#K)a5wIitfu(CFYV4FT?LUB$jVwJSZz zNBFTWg->Y<QdHGXO6(B7DL40#@QH~&1bt_RGfAlw%_YsP19wAkHXw%~G9G(zw;=yC z_Wta^hs{<khF)Et{~KQ(Y!<^`L|pYl%vB@$I(;3RmQHq?VZ^(}{nUdkKh|wO|NXu) ze|eLtM-LNkZU|pzO^)wX4?x7Y#55_{=sp>k0j&h3e*a5><wP*B;A~Y_-J8$UU=+E3 zs|^$XdARfHEBrp-b3qaNg~XRwL;d6S=>B=-xM7dE`IuOQna!u$OoxLlE;WdrNlN)1 z7**de7-hZ!(%_ZllHBLg`Ir#|t>2$*xVOZ-ADZKTN?{(NUeLU9GbuG-+Axf*AZ-P1 z0ZZ*f<D6L!WI}YtFrx~d;ZCS=O$ReN3~!sEoYV$RgCJx3D(Cp-Mie$*C4cS*q~E}& z0BT11xQ>x+ck4{XtFsbcc%GRStht@q!m*ImssGwuK+P@%gEK!f5dHymg<9nSCXsB6 zQ*{<`%^bxB($Z@5286^-A(tR;r+p7B%^%$N5h%lb*Vlz-?DL9x;!j<5>~kmXP$E}m zQV|7uv4SwFs0jUervsxVUm>&9Y3DBIzc1XW|CUZrUdb<&{@D5yuLe%Xniw^x&{A2s z0q1<L&7;HiAPZm8Z=iQR8>+owDSfc3Gs?ht;3jw49c#mmrViUfX-yvc_B*wY|Lo7; zGh!t2R#BHx{1wFXReX*~`NS-<fA!XHlF+kxYYK8u1|b%w@Tz%ELs#ab^++6I>LpSX z#TV*miO^~B9PF%O0huw!1Zv>^d0G3$^8dsC6VI!$oK<B%_ozoN7z7_(zzYjWYY9bu zd)NEdFua83uR-Vf-s4v#aHcT*T0qDHMRnnTV@TqU{LFRZ2dsH&3pJ!02lVAX&;IMb z^MANDir>DKiXdJt{mGkyA`+Gwd4D-^1qtNTUK)`N*=NTG-6}=5k6suNfdLt*dt8D| z%H#$k)z#ZRcf|zDWB|pn<3+7Nz>?WW9WdkO5(a^m+D4WRJ9{wc>Y}IN)2Kbgn;_O? zGqdr&9~|$Y0tP=N(k7^Eu;iO*w+f%W`20BNo)=Xa@M_)+o$4LXJyiw{F?a633SC{B zl~9FH%?^Rm*LVz`lkULs)%idDX^O)SxQol(3jDRyBVR!7d`;ar+D7do)jQ}m`g$<s z-6tu{nP5&-otsZNY)-$k`{Pj80gwuW=4gjb+bXY>TevUD5@?*P8)vo<u;hmO(wx=4 zu#Ty4#N8dV+4db_oTh<$^Q+`f9^xq{WR#>a?kEe@_hl{_h8j&5eB-5FrYW&*FHVt$ z$kRF9Nstj<DlnDleF4(_XZ^q<)s2!0YS`L=!d-ZCs(bT}fT({j8NU<*U4dqQq?|<5 zrM4G6K$2co@=m3s4&j>%KRzpjdd_9wO=4zO8ritN*NPk_9avYrsF(!4))tm{Ga#OY z(r{0buexOzu7+<C7l)}{Nc<qc*P;@OPvjmTK3RfnIjfpHVr4;vhpzPB(e56`ue)+^ zV<puQ4Ra`IJ1<xY9>rw8E08Gxd`LTOID{*AC1m*6Nw@osfB%0oBF5sf<~wH1kL;sd zo)k6^VyRFU<BuKKXLDd>`)dt*iX^9&QtWbo6yE8XXH?`ztvpiOLgI3R+=MOBQ<kj1 z^+$eZoWa#nXjJMS{t(g~l-@9Ro*c@Zd2iRE?D?Zo&wSDp9cqKFwo)iB{||Ez9c*1E z4LKsK`*%O!d#7>9<gyqCJnWR~?z%;3dw3=(Pq|GAF4ceN5fzvX+wwedai5kotW7if w9)|ozV<th{;5oaSc=(C`Xv64I>=rMVgi<*CU%+d1PQQ0a1U=&b0vkF207%xU0ssI2 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 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 T({?pW00000NkvXXu0mjfu=P;| literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64e58ba64d180ce43ee13bf9a17835fbca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuH<!ckn_w-(t15itRHmqN0O$B3XH(E|jyV^QXq8=yM`Q**vy zpEpgQd+no=J<Tlv&+_>B%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}<p z;bEy|mw1;}P&gp|0ssKe4*;D3Dlh;r06r}ehC-pC4mGy`3;|+W04+#B4r_x~@mHHy z)H}bD|I2-n_L$pW;*I)~?=#N<)`92&<$3IR`#<SH6@I&FRQa6xBmQ5wPwJ2PAI(ne z$L2Yb@JHxb`+bLk*AjR$^`b?pr|?!6=+AboIQ2D-p)UI7x(J0|5(5~ur$_+)`>C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ<hjKiZs6mOSFB&+cIl`GV$93-<ciUjF#*1^<p~gh ziQ_{)r0dA7$It&Fe=obxu8n!+elxmgqxPbUL!FxW0;AOfqz@8JOz9Qbm)m-9!^7D) z480@BoIIb<oT``+rVla8L)8fXO&6}3P9n4v$`6WG<DUNWuKb9J9rUsAn7d-_YWT^U z{NXl@OAPIJ!>3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^Q<WhKYr9rXr6*~Tmpuq6NjnD6;;NNBGIg-1ZvfACQ4{ocrwM0)?`oL2ts zCXY5KT@`(ir63J0?%+_(-dDgf<6R$u{lCdy6Zi5d+Bf;1OXyD;xe3#Gug*&T|0o41 zD8;$|JvUv&@vsLIH&C5+S{!k&{~Z54^y@9r>X7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!T<mO diff --git a/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/dataconnect/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611da081676d42f6c3f78a2c91e7bcedddedb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1772 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 O>FJ$wK}0tWNB{uH;AM~i 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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 M8(G|{GjM<UKXCMpga7~l literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070fe34c611c42c0d3ad3013a0dce358be0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1900 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 diff --git a/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/dataconnect/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3918 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 diff --git a/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..8abe93867390c4047fcfbdc275e725434fd7f57e GIT binary patch literal 12140 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 literal 0 HcmV?d00001 diff --git a/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77f9f036a47549d47db79c16788749dca10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2884 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 diff --git a/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/dataconnect/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f5083623b375139afb391af71cc533a7dd37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5914 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�(p-{x$&=IACIfoAR<Nvvu>!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@<pC<8PLRXJuvO4y>xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5<Pf8lV3fF#Ppu>Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@<t56P}GC(ea zcuDo~%vU?vTqAIp#flPNMI2`5;t8&98^HQwmsAsoPNK_fx^Bggb1WVe*N(RqH8#4x zCQN_rN*!W39u4A)O3%B(eX4-oO~1PlFj1j%A~@Wj>9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`<bqO>pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0x<PZ)*(STjdw~o@Rw-Or2Ax?U!if0^R7qyS!JDYN`R9pBUrvi5{Lgqu za0I&Zd*A54UPC}|lJz-2f!1VYM+A-ElFU{V`W)8LtCk5Mx|)Il)$QH*gA63%JZFt} zGq@eFEWXJ~R>Ps?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeN<ek%udeE*tim(T?PyxRjZ^lIknLzS6Fbvpe_110000002i;2E&u=k 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/mipmap-xxxhdpi/ic_launcher.webp b/dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d6427e6fa1074b79ccd52ef67ac15c5637e85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3844 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 G0001O&w8c+ diff --git a/dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/dataconnect/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae37cbc3587421d6889eadd1d91fbf1994d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7778 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 From 6bb29881c84e34b2f27d0c91b1987ff3a8e20ff4 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 5 Jun 2024 22:37:49 +0100 Subject: [PATCH 10/63] add top 10 movies and latest to the MoviesScreen --- .../example/dataconnect/MainActivity.kt | 5 +- .../dataconnect/data/DataConnectMappers.kt | 24 ++++++++ .../{DataRepository.kt => MovieRepository.kt} | 13 +++- .../feature/movies/MoviesScreen.kt | 60 ++++++++++++++++--- .../feature/movies/MoviesUIState.kt | 15 +++++ .../feature/movies/MoviesViewModel.kt | 19 +++--- .../app/src/main/res/values/strings.xml | 4 ++ 7 files changed, 120 insertions(+), 20 deletions(-) rename dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/{DataRepository.kt => MovieRepository.kt} (54%) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesUIState.kt 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 index cccc1901f..5c8f6c15c 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -19,6 +20,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination @@ -96,7 +98,8 @@ class MainActivity : ComponentActivity() { NavHost( navController, startDestination = MOVIES_ROUTE, - Modifier.consumeWindowInsets(innerPadding), + Modifier.padding(innerPadding) + .consumeWindowInsets(innerPadding), ) { moviesScreen() genresScreen() diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt index 6d80d4da9..eacd17fc5 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt @@ -1,6 +1,8 @@ package com.google.firebase.example.dataconnect.data import com.google.firebase.dataconnect.movies.ListMoviesQuery +import com.google.firebase.dataconnect.movies.MoviesRecentlyReleasedQuery +import com.google.firebase.dataconnect.movies.MoviesTop10Query fun ListMoviesQuery.Data.MoviesItem.toMovie(): Movie { return Movie( @@ -12,4 +14,26 @@ fun ListMoviesQuery.Data.MoviesItem.toMovie(): Movie { rating = this.rating, tags = this.tags ) +} + +fun MoviesTop10Query.Data.MoviesItem.toMovie(): Movie { + return Movie( + id = this.id, + title = this.title, + imageUrl = this.imageUrl, + rating = this.rating, + genre = this.genre, + tags = this.tags + ) +} + +fun MoviesRecentlyReleasedQuery.Data.MoviesItem.toMovie(): Movie { + return Movie( + id = this.id, + title = this.title, + imageUrl = this.imageUrl, + rating = this.rating, + genre = this.genre, + tags = this.tags + ) } \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataRepository.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt similarity index 54% rename from dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataRepository.kt rename to dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt index 1b23f1ec2..f7bd483b7 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataRepository.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt @@ -4,11 +4,22 @@ import com.google.firebase.dataconnect.movies.MoviesConnector import com.google.firebase.dataconnect.movies.execute import com.google.firebase.dataconnect.movies.instance -class DataRepository( +class MovieRepository( private val moviesConnector: MoviesConnector = MoviesConnector.instance ) { + // Repositories suspend fun listMovies(): List<Movie> { return moviesConnector.listMovies.execute().data.movies.map { it.toMovie() } } + + suspend fun getTop10Movies(): List<Movie> { + return moviesConnector.moviesTop10.execute().data.movies.map { it.toMovie() } + } + + suspend fun getRecentlyReleasedMovies(): List<Movie> { + return moviesConnector.moviesRecentlyReleased.execute().data.movies.map { it.toMovie() } + } + + // Mutations } \ No newline at end of file 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 index ef63d19ed..f83e4d526 100644 --- 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 @@ -1,43 +1,85 @@ package com.google.firebase.example.dataconnect.feature.movies +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator 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.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 coil.compose.AsyncImage +import com.google.firebase.example.dataconnect.R import com.google.firebase.example.dataconnect.data.Movie @Composable fun MoviesScreen( moviesViewModel: MoviesViewModel = viewModel() ) { - val movies by moviesViewModel.movies.collectAsState() + val movies by moviesViewModel.uiState.collectAsState() MoviesScreen(movies) } @Composable fun MoviesScreen( + uiState: MoviesUIState +) { + when (uiState) { + MoviesUIState.Loading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator() + } + } + is MoviesUIState.Error -> { + Text(uiState.errorMessage) + } + is MoviesUIState.Success -> { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(R.string.title_top_10_movies), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + HorizontalMovieList(uiState.top10movies) + Text( + text = stringResource(R.string.title_latest_movies), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(vertical = 16.dp) + ) + HorizontalMovieList(uiState.latestMovies) + } + } + } +} + +@Composable +fun HorizontalMovieList( movies: List<Movie> ) { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = Modifier.padding(top = 48.dp) - ) { + LazyRow { items(movies) { movie -> Card( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + modifier = Modifier.padding(8.dp) + .fillParentMaxWidth(0.3f), ) { AsyncImage( model = movie.imageUrl, @@ -56,4 +98,4 @@ fun MoviesScreen( } } } -} \ No newline at end of file +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesUIState.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesUIState.kt new file mode 100644 index 000000000..73aca9a07 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesUIState.kt @@ -0,0 +1,15 @@ +package com.google.firebase.example.dataconnect.feature.movies + +import com.google.firebase.example.dataconnect.data.Movie + +sealed class MoviesUIState { + + data object Loading: MoviesUIState() + + data class Error(val errorMessage: String): MoviesUIState() + + data class Success( + val top10movies: List<Movie>, + val latestMovies: List<Movie> + ) : MoviesUIState() +} \ No newline at end of file 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 index 175286962..09fe510bd 100644 --- 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 @@ -1,28 +1,29 @@ package com.google.firebase.example.dataconnect.feature.movies -import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.example.dataconnect.data.DataRepository -import com.google.firebase.example.dataconnect.data.Movie +import com.google.firebase.example.dataconnect.data.MovieRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class MoviesViewModel( - private val dataRepository: DataRepository = DataRepository() + private val dataRepository: MovieRepository = MovieRepository() ) : ViewModel() { - private val _movies = MutableStateFlow<List<Movie>>(emptyList()) - val movies: StateFlow<List<Movie>> - get() = _movies + private val _uiState = MutableStateFlow<MoviesUIState>(MoviesUIState.Loading) + val uiState: StateFlow<MoviesUIState> + get() = _uiState init { viewModelScope.launch { try { - _movies.value = dataRepository.listMovies() + val top10Movies = dataRepository.getTop10Movies() + val latestMovies = dataRepository.getRecentlyReleasedMovies() + + _uiState.value = MoviesUIState.Success(top10Movies, latestMovies) } catch (e: Exception) { - e.printStackTrace() + _uiState.value = MoviesUIState.Error(e.localizedMessage) } } } diff --git a/dataconnect/app/src/main/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml index a2cda77fb..881ef05a7 100644 --- a/dataconnect/app/src/main/res/values/strings.xml +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -6,4 +6,8 @@ <string name="label_genres">Genres</string> <string name="label_search">Search</string> <string name="label_profile">Profile</string> + + <!-- Movies Screen --> + <string name="title_top_10_movies">Top 10 Movies</string> + <string name="title_latest_movies">Latest Movies</string> </resources> \ No newline at end of file From 84bf1b0e0456f2a23b26eae33e298e6325a98f4a Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 5 Jun 2024 22:51:07 +0100 Subject: [PATCH 11/63] create GenresScreen --- .../example/dataconnect/MainActivity.kt | 20 ++++------- .../feature/genres/GenresScreen.kt | 35 +++++++++++++++++++ .../dataconnect/feature/genres/Navigation.kt | 2 +- 3 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/GenresScreen.kt 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 index 5c8f6c15c..db91c05a3 100644 --- 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 @@ -29,12 +29,15 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.google.firebase.example.dataconnect.feature.genres.GENRES_ROUTE import com.google.firebase.example.dataconnect.feature.genres.genresScreen +import com.google.firebase.example.dataconnect.feature.genres.navigateToGenres import com.google.firebase.example.dataconnect.feature.movies.MOVIES_ROUTE import com.google.firebase.example.dataconnect.feature.movies.moviesScreen import com.google.firebase.example.dataconnect.feature.movies.navigateToMovies import com.google.firebase.example.dataconnect.feature.profile.PROFILE_ROUTE +import com.google.firebase.example.dataconnect.feature.profile.navigateToProfile import com.google.firebase.example.dataconnect.feature.profile.profileScreen import com.google.firebase.example.dataconnect.feature.search.SEARCH_ROUTE +import com.google.firebase.example.dataconnect.feature.search.navigateToSearch import com.google.firebase.example.dataconnect.feature.search.searchScreen import com.google.firebase.example.dataconnect.ui.theme.FirebaseDataConnectTheme @@ -56,19 +59,7 @@ class MainActivity : ComponentActivity() { label = { Text(stringResource(R.string.label_movies)) }, selected = isRouteSelected(currentDestination, MOVIES_ROUTE), onClick = { - navController.navigateToMovies { - // Pop up to the start destination of the graph to - // avoid building up a large stack of destinations - // on the back stack as users select items - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - // Avoid multiple copies of the same destination when - // reselecting the same item - launchSingleTop = true - // Restore state when reselecting a previously selected item - restoreState = true - } + navController.navigateToMovies { } } ) NavigationBarItem( @@ -76,6 +67,7 @@ class MainActivity : ComponentActivity() { label = { Text(stringResource(R.string.label_genres)) }, selected = isRouteSelected(currentDestination, GENRES_ROUTE), onClick = { + navController.navigateToGenres { } } ) NavigationBarItem( @@ -83,6 +75,7 @@ class MainActivity : ComponentActivity() { label = { Text(stringResource(R.string.label_search)) }, selected = isRouteSelected(currentDestination, SEARCH_ROUTE), onClick = { + navController.navigateToSearch { } } ) NavigationBarItem( @@ -90,6 +83,7 @@ class MainActivity : ComponentActivity() { label = { Text(stringResource(R.string.label_profile)) }, selected = isRouteSelected(currentDestination, PROFILE_ROUTE), onClick = { + navController.navigateToProfile { } } ) } diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/GenresScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/GenresScreen.kt new file mode 100644 index 000000000..f182e50c0 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/GenresScreen.kt @@ -0,0 +1,35 @@ +package com.google.firebase.example.dataconnect.feature.genres + +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 + + +@Composable +fun GenresScreen() { + // 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) + ) { + 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/genres/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt index 686c9efbb..4f9d5f27d 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt @@ -14,7 +14,7 @@ fun NavGraphBuilder.genresScreen( ) { composable(route = GENRES_ROUTE) { - // TODO: Call composable + GenresScreen() } } From 0b156e24ee9a817d309f33e6e90455bbf59602dc Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 5 Jun 2024 23:37:39 +0100 Subject: [PATCH 12/63] create GenreDetailScreen --- .../example/dataconnect/MainActivity.kt | 21 +++-- .../dataconnect/data/DataConnectMappers.kt | 25 +++++- .../dataconnect/data/MovieRepository.kt | 7 ++ .../example/dataconnect/data/MoviesByGenre.kt | 6 ++ .../feature/genredetail/GenreDetailScreen.kt | 78 +++++++++++++++++++ .../feature/genredetail/GenreDetailUIState.kt | 16 ++++ .../genredetail/GenreDetailViewModel.kt | 34 ++++++++ .../feature/genredetail/Navigation.kt | 27 +++++++ .../feature/genres/GenresScreen.kt | 8 +- .../dataconnect/feature/genres/Navigation.kt | 6 +- .../feature/movies/MoviesScreen.kt | 14 +++- .../app/src/main/res/values/strings.xml | 5 ++ 12 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MoviesByGenre.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/genredetail/Navigation.kt 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 index db91c05a3..b8152989c 100644 --- 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 @@ -27,6 +27,8 @@ import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.google.firebase.example.dataconnect.feature.genredetail.genreDetailScreen +import com.google.firebase.example.dataconnect.feature.genredetail.navigateToGenreDetail import com.google.firebase.example.dataconnect.feature.genres.GENRES_ROUTE import com.google.firebase.example.dataconnect.feature.genres.genresScreen import com.google.firebase.example.dataconnect.feature.genres.navigateToGenres @@ -59,7 +61,7 @@ class MainActivity : ComponentActivity() { label = { Text(stringResource(R.string.label_movies)) }, selected = isRouteSelected(currentDestination, MOVIES_ROUTE), onClick = { - navController.navigateToMovies { } + navController.navigateToMovies { launchSingleTop = true } } ) NavigationBarItem( @@ -67,7 +69,7 @@ class MainActivity : ComponentActivity() { label = { Text(stringResource(R.string.label_genres)) }, selected = isRouteSelected(currentDestination, GENRES_ROUTE), onClick = { - navController.navigateToGenres { } + navController.navigateToGenres { launchSingleTop = true } } ) NavigationBarItem( @@ -75,7 +77,7 @@ class MainActivity : ComponentActivity() { label = { Text(stringResource(R.string.label_search)) }, selected = isRouteSelected(currentDestination, SEARCH_ROUTE), onClick = { - navController.navigateToSearch { } + navController.navigateToSearch { launchSingleTop = true } } ) NavigationBarItem( @@ -83,7 +85,7 @@ class MainActivity : ComponentActivity() { label = { Text(stringResource(R.string.label_profile)) }, selected = isRouteSelected(currentDestination, PROFILE_ROUTE), onClick = { - navController.navigateToProfile { } + navController.navigateToProfile { launchSingleTop = true } } ) } @@ -96,7 +98,14 @@ class MainActivity : ComponentActivity() { .consumeWindowInsets(innerPadding), ) { moviesScreen() - genresScreen() + genresScreen( + onGenreClicked = { genre -> + navController.navigateToGenreDetail(genre) { + launchSingleTop = true + } + } + ) + genreDetailScreen() searchScreen() profileScreen() } @@ -107,4 +116,4 @@ class MainActivity : ComponentActivity() { } private fun isRouteSelected(currentDestination: NavDestination?, route: String) = - currentDestination?.hierarchy?.any { it.route == route } == true + currentDestination?.hierarchy?.any { it.route?.startsWith(route) ?: false } == true diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt index eacd17fc5..288a7246e 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt @@ -1,5 +1,6 @@ package com.google.firebase.example.dataconnect.data +import com.google.firebase.dataconnect.movies.ListMoviesByGenreQuery import com.google.firebase.dataconnect.movies.ListMoviesQuery import com.google.firebase.dataconnect.movies.MoviesRecentlyReleasedQuery import com.google.firebase.dataconnect.movies.MoviesTop10Query @@ -36,4 +37,26 @@ fun MoviesRecentlyReleasedQuery.Data.MoviesItem.toMovie(): Movie { genre = this.genre, tags = this.tags ) -} \ No newline at end of file +} + +fun ListMoviesByGenreQuery.Data.MostRecentItem.toMovie(): Movie { + return Movie( + id = this.id, + title = this.title, + imageUrl = this.imageUrl, + rating = this.rating, + tags = this.tags + ) +} + +fun ListMoviesByGenreQuery.Data.MostPopularItem.toMovie(): Movie { + return Movie( + id = this.id, + title = this.title, + imageUrl = this.imageUrl, + rating = this.rating, + tags = this.tags + ) +} + + diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt index f7bd483b7..cc382dfb5 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt @@ -21,5 +21,12 @@ class MovieRepository( return moviesConnector.moviesRecentlyReleased.execute().data.movies.map { it.toMovie() } } + suspend fun getMoviesByGenre(genre: String): MoviesByGenre { + val data = moviesConnector.listMoviesByGenre.execute(genre).data + val mostPopular = data.mostPopular.map { it.toMovie() } + val mostRecent = data.mostRecent.map { it.toMovie() } + return MoviesByGenre(mostPopular, mostRecent) + } + // Mutations } \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MoviesByGenre.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MoviesByGenre.kt new file mode 100644 index 000000000..e26e14a62 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MoviesByGenre.kt @@ -0,0 +1,6 @@ +package com.google.firebase.example.dataconnect.data + +data class MoviesByGenre( + val mostPopular: List<Movie>, + val mostRecent: List<Movie> +) diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailScreen.kt new file mode 100644 index 000000000..1313174c2 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailScreen.kt @@ -0,0 +1,78 @@ +package com.google.firebase.example.dataconnect.feature.genredetail + +import androidx.compose.foundation.gestures.ScrollableState +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +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.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.example.dataconnect.R +import com.google.firebase.example.dataconnect.feature.movies.HorizontalMovieList + +@Composable +fun GenreDetailScreen( + genre: String, + moviesViewModel: GenreDetailViewModel = viewModel() +) { + moviesViewModel.setGenre(genre) + val movies by moviesViewModel.uiState.collectAsState() + GenreDetailScreen(movies) +} + +@Composable +fun GenreDetailScreen( + uiState: GenreDetailUIState +) { + when (uiState) { + GenreDetailUIState.Loading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator() + } + } + is GenreDetailUIState.Error -> { + Text(uiState.errorMessage) + } + is GenreDetailUIState.Success -> { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(scrollState) + ) { + Text( + text = stringResource(R.string.title_genre_detail, uiState.genreName), + style = MaterialTheme.typography.headlineLarge + ) + Text( + text = stringResource(R.string.title_most_popular), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(vertical = 16.dp) + ) + HorizontalMovieList(uiState.moviesByGenre.mostPopular) + Text( + text = stringResource(R.string.title_most_recent), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(vertical = 16.dp) + ) + HorizontalMovieList(uiState.moviesByGenre.mostRecent) + } + } + } +} + diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailUIState.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailUIState.kt new file mode 100644 index 000000000..b4b99178e --- /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.example.dataconnect.data.Movie +import com.google.firebase.example.dataconnect.data.MoviesByGenre + +sealed class GenreDetailUIState { + + data object Loading: GenreDetailUIState() + + data class Error(val errorMessage: String): GenreDetailUIState() + + data class Success( + val genreName: String, + val moviesByGenre: MoviesByGenre + ) : GenreDetailUIState() +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailViewModel.kt new file mode 100644 index 000000000..912e11540 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/GenreDetailViewModel.kt @@ -0,0 +1,34 @@ +package com.google.firebase.example.dataconnect.feature.genredetail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.example.dataconnect.data.MovieRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class GenreDetailViewModel( + private val dataRepository: MovieRepository = MovieRepository() +) : ViewModel() { + private var genre = "" + + private val _uiState = MutableStateFlow<GenreDetailUIState>(GenreDetailUIState.Loading) + val uiState: StateFlow<GenreDetailUIState> + get() = _uiState + + // TODO(thatfiredev): Create a ViewModelFactory to set genre + fun setGenre(genre: String) { + this.genre = genre + viewModelScope.launch { + try { + val movies = dataRepository.getMoviesByGenre(genre.lowercase()) + _uiState.value = GenreDetailUIState.Success( + genreName = genre, + moviesByGenre = movies + ) + } catch (e: Exception) { + _uiState.value = GenreDetailUIState.Error(e.message ?: "") + } + } + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/Navigation.kt new file mode 100644 index 000000000..45edf83b3 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/Navigation.kt @@ -0,0 +1,27 @@ +package com.google.firebase.example.dataconnect.feature.genredetail + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navArgument + +const val GENRE_DETAIL_ROUTE = "genres/{genre}" + +fun NavController.navigateToGenreDetail( + genre: String, + navOptions: NavOptionsBuilder.() -> Unit = { } +) = navigate(GENRE_DETAIL_ROUTE.replace("{genre}", genre), navOptions) + +fun NavGraphBuilder.genreDetailScreen() { + composable( + route = GENRE_DETAIL_ROUTE + ) { backStackEntry -> + backStackEntry.arguments?.let { + GenreDetailScreen(it.getString("genre", "Action")) + } + } +} + + 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 index f182e50c0..4bcd6bf40 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -13,7 +14,9 @@ import androidx.compose.ui.unit.dp @Composable -fun GenresScreen() { +fun GenresScreen( + onGenreClicked: (genre: String) -> Unit = {} +) { // Hardcoding genres for now val genres = arrayOf("Action", "Crime", "Drama", "Sci-Fi") @@ -23,6 +26,9 @@ fun GenresScreen() { modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp, horizontal = 16.dp) + .clickable { + onGenreClicked(genre) + } ) { Text( text = genre, diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt index 4f9d5f27d..f2fce931a 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt @@ -5,16 +5,16 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptionsBuilder import androidx.navigation.compose.composable -const val GENRES_ROUTE = "genres_route" +const val GENRES_ROUTE = "genres" fun NavController.navigateToGenres(navOptions: NavOptionsBuilder.() -> Unit) = navigate(GENRES_ROUTE, navOptions) fun NavGraphBuilder.genresScreen( - + onGenreClicked: (genre: String) -> Unit ) { composable(route = GENRES_ROUTE) { - GenresScreen() + GenresScreen(onGenreClicked) } } 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 index f83e4d526..1da620796 100644 --- 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 @@ -10,6 +10,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -19,6 +21,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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 @@ -51,8 +54,10 @@ fun MoviesScreen( Text(uiState.errorMessage) } is MoviesUIState.Success -> { + val scrollState = rememberScrollState() Column( modifier = Modifier.padding(16.dp) + .verticalScroll(scrollState) ) { Text( text = stringResource(R.string.title_top_10_movies), @@ -78,22 +83,25 @@ fun HorizontalMovieList( LazyRow { items(movies) { movie -> Card( - modifier = Modifier.padding(8.dp) + modifier = Modifier + .padding(4.dp) .fillParentMaxWidth(0.3f), ) { AsyncImage( model = movie.imageUrl, contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth() ) Text( text = movie.title, style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(8.dp) + modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp) ) Text( text = "Rating: ${movie.rating}", - modifier = Modifier.padding(bottom = 8.dp, start = 8.dp) + modifier = Modifier.padding(bottom = 8.dp, start = 8.dp, end = 8.dp), + style = MaterialTheme.typography.bodySmall ) } } diff --git a/dataconnect/app/src/main/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml index 881ef05a7..d34dbccb6 100644 --- a/dataconnect/app/src/main/res/values/strings.xml +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -10,4 +10,9 @@ <!-- Movies Screen --> <string name="title_top_10_movies">Top 10 Movies</string> <string name="title_latest_movies">Latest Movies</string> + + <!-- Genre Detail Title --> + <string name="title_genre_detail">%s Movies</string> + <string name="title_most_popular">Most Popular</string> + <string name="title_most_recent">Most Recent</string> </resources> \ No newline at end of file From d288623ce2ad75f817c613d014000d56d40b5f3f Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 7 Jun 2024 16:06:59 +0100 Subject: [PATCH 13/63] create MovieDetailScreen --- .../example/dataconnect/MainActivity.kt | 22 +-- .../dataconnect/data/DataConnectMappers.kt | 13 +- .../example/dataconnect/data/Movie.kt | 2 +- .../dataconnect/data/MovieRepository.kt | 6 + .../feature/genredetail/GenreDetailScreen.kt | 14 +- .../feature/moviedetail/MovieDetailScreen.kt | 127 ++++++++++++++++++ .../feature/moviedetail/MovieDetailUIState.kt | 14 ++ .../moviedetail/MovieDetailViewModel.kt | 30 +++++ .../feature/moviedetail/Navigation.kt | 29 ++++ .../feature/movies/MoviesScreen.kt | 22 +-- .../dataconnect/feature/movies/Navigation.kt | 6 +- .../app/src/main/res/values/strings.xml | 6 +- 12 files changed, 266 insertions(+), 25 deletions(-) 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/Navigation.kt 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 index b8152989c..a0d8bdd30 100644 --- 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 @@ -32,6 +32,8 @@ import com.google.firebase.example.dataconnect.feature.genredetail.navigateToGen import com.google.firebase.example.dataconnect.feature.genres.GENRES_ROUTE import com.google.firebase.example.dataconnect.feature.genres.genresScreen import com.google.firebase.example.dataconnect.feature.genres.navigateToGenres +import com.google.firebase.example.dataconnect.feature.moviedetail.movieDetailScreen +import com.google.firebase.example.dataconnect.feature.moviedetail.navigateToMovieDetail import com.google.firebase.example.dataconnect.feature.movies.MOVIES_ROUTE import com.google.firebase.example.dataconnect.feature.movies.moviesScreen import com.google.firebase.example.dataconnect.feature.movies.navigateToMovies @@ -94,17 +96,21 @@ class MainActivity : ComponentActivity() { NavHost( navController, startDestination = MOVIES_ROUTE, - Modifier.padding(innerPadding) + Modifier + .padding(innerPadding) .consumeWindowInsets(innerPadding), ) { - moviesScreen() - genresScreen( - onGenreClicked = { genre -> - navController.navigateToGenreDetail(genre) { - launchSingleTop = true - } + moviesScreen(onMovieClicked = { movieId -> + navController.navigateToMovieDetail(movieId) { + launchSingleTop = true + } + }) + movieDetailScreen() + genresScreen(onGenreClicked = { genre -> + navController.navigateToGenreDetail(genre) { + launchSingleTop = true } - ) + }) genreDetailScreen() searchScreen() profileScreen() diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt index 288a7246e..8dc63c5e5 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt @@ -1,5 +1,6 @@ package com.google.firebase.example.dataconnect.data +import com.google.firebase.dataconnect.movies.GetMovieByIdQuery import com.google.firebase.dataconnect.movies.ListMoviesByGenreQuery import com.google.firebase.dataconnect.movies.ListMoviesQuery import com.google.firebase.dataconnect.movies.MoviesRecentlyReleasedQuery @@ -59,4 +60,14 @@ fun ListMoviesByGenreQuery.Data.MostPopularItem.toMovie(): Movie { ) } - +fun GetMovieByIdQuery.Data.Movie.toMovie(): Movie { + return Movie( + id = this.id, + title = this.title, + description = this.description, + imageUrl = this.imageUrl, + rating = this.rating, + genre = this.genre, + tags = this.tags + ) +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt index c4466547c..bbd08c19b 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt @@ -9,6 +9,6 @@ data class Movie( val releaseYear: Int? = 1970, val genre: String? = "", val rating: Double? = 0.0, - val description: String? = "", + val description: String? = null, val tags: List<String?>? = emptyList() ) diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt index cc382dfb5..00dad663c 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt @@ -3,6 +3,7 @@ package com.google.firebase.example.dataconnect.data 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 class MovieRepository( private val moviesConnector: MoviesConnector = MoviesConnector.instance @@ -28,5 +29,10 @@ class MovieRepository( return MoviesByGenre(mostPopular, mostRecent) } + suspend fun getMovieByID(movieID: String): Movie? { + val id = UUID.fromString(movieID) + return moviesConnector.getMovieById.execute(id).data.movie?.toMovie() + } + // Mutations } \ 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 index 1313174c2..40808c519 100644 --- 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 @@ -64,13 +64,23 @@ fun GenreDetailScreen( style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(vertical = 16.dp) ) - HorizontalMovieList(uiState.moviesByGenre.mostPopular) + HorizontalMovieList( + uiState.moviesByGenre.mostPopular, + onMovieClicked = { + // TODO(thatfiredev) + } + ) Text( text = stringResource(R.string.title_most_recent), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(vertical = 16.dp) ) - HorizontalMovieList(uiState.moviesByGenre.mostRecent) + HorizontalMovieList( + uiState.moviesByGenre.mostRecent, + onMovieClicked = { + // TODO(thatfiredev) + } + ) } } } diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailScreen.kt new file mode 100644 index 000000000..f43463d2f --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailScreen.kt @@ -0,0 +1,127 @@ +package com.google.firebase.example.dataconnect.feature.moviedetail + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.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.SuggestionChip +import androidx.compose.material3.Text +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.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.example.dataconnect.R +import com.google.firebase.example.dataconnect.data.Movie + +@Composable +fun MovieDetailScreen( + movieId: String, + movieDetailViewModel: MovieDetailViewModel = viewModel() +) { + movieDetailViewModel.setMovieId(movieId) + val uiState by movieDetailViewModel.uiState.collectAsState() + MovieDetailScreen(uiState) +} + +@Composable +fun MovieDetailScreen( + uiState: MovieDetailUIState +) { + when (uiState) { + is MovieDetailUIState.Error -> { + ErrorMessage(uiState.errorMessage) + } + + MovieDetailUIState.Loading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator() + } + } + + is MovieDetailUIState.Success -> { + val movie = uiState.movie + MovieInformation(movie) + } + } +} + +@Composable +fun MovieInformation(movie: Movie?) { + if (movie == null) { + ErrorMessage(stringResource(R.string.error_movie_not_found)) + } else { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(scrollState) + ) { + Text( + text = movie.title, + style = MaterialTheme.typography.headlineLarge + ) + Row { + Text( + text = movie.releaseYear.toString(), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(end = 4.dp) + ) + Text( + text = movie.rating?.toString() ?: "0.0", + style = MaterialTheme.typography.labelMedium + ) + } + Row { + AsyncImage( + model = movie.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(vertical = 8.dp) + ) + Column( + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Row { + movie.tags?.let { movieTags -> + movieTags.filterNotNull().forEach { tag -> + SuggestionChip( + onClick = { }, + label = { Text(tag) }, + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 8.dp) + ) + } + } + } + Text( + text = movie.description ?: stringResource(R.string.description_not_available), + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} + +@Composable +fun ErrorMessage( + message: String +) { + Text(message) +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailUIState.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailUIState.kt new file mode 100644 index 000000000..685cb73e1 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailUIState.kt @@ -0,0 +1,14 @@ +package com.google.firebase.example.dataconnect.feature.moviedetail + +import com.google.firebase.example.dataconnect.data.Movie + +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: Movie? + ) : MovieDetailUIState() +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailViewModel.kt new file mode 100644 index 000000000..7697bce16 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailViewModel.kt @@ -0,0 +1,30 @@ +package com.google.firebase.example.dataconnect.feature.moviedetail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.example.dataconnect.data.MovieRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class MovieDetailViewModel( + private val repository: MovieRepository = MovieRepository() +) : ViewModel() { + private var movieId: String = "" + + private val _uiState = MutableStateFlow<MovieDetailUIState>(MovieDetailUIState.Loading) + val uiState: StateFlow<MovieDetailUIState> + get() = _uiState + + fun setMovieId(id: String) { + movieId = id + viewModelScope.launch { + try { + val movie = repository.getMovieByID(movieId) + _uiState.value = MovieDetailUIState.Success(movie) + } 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/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt new file mode 100644 index 000000000..b80048200 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt @@ -0,0 +1,29 @@ +package com.google.firebase.example.dataconnect.feature.moviedetail + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable + +const val MOVIE_DETAIL_ROUTE = "movies/{movie}" + +fun NavController.navigateToMovieDetail( + movieId: String, + navOptions: NavOptionsBuilder.() -> Unit = { } +) = navigate(MOVIE_DETAIL_ROUTE.replace("{movie}", movieId), navOptions) + +fun NavGraphBuilder.movieDetailScreen() { + composable( + route = MOVIE_DETAIL_ROUTE + ) { backStackEntry -> + backStackEntry.arguments?.let { + val movieId = it.getString("movie") + movieId?.let { id -> + MovieDetailScreen(id) + } + + } + } +} + + 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 index 1da620796..1fddeb992 100644 --- 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 @@ -1,14 +1,12 @@ package com.google.firebase.example.dataconnect.feature.movies +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -31,15 +29,17 @@ import com.google.firebase.example.dataconnect.data.Movie @Composable fun MoviesScreen( + onMovieClicked: (movie: String) -> Unit, moviesViewModel: MoviesViewModel = viewModel() ) { val movies by moviesViewModel.uiState.collectAsState() - MoviesScreen(movies) + MoviesScreen(movies, onMovieClicked) } @Composable fun MoviesScreen( - uiState: MoviesUIState + uiState: MoviesUIState, + onMovieClicked: (movie: String) -> Unit ) { when (uiState) { MoviesUIState.Loading -> { @@ -64,13 +64,13 @@ fun MoviesScreen( style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(bottom = 16.dp) ) - HorizontalMovieList(uiState.top10movies) + HorizontalMovieList(uiState.top10movies, onMovieClicked) Text( text = stringResource(R.string.title_latest_movies), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(vertical = 16.dp) ) - HorizontalMovieList(uiState.latestMovies) + HorizontalMovieList(uiState.latestMovies, onMovieClicked) } } } @@ -78,14 +78,18 @@ fun MoviesScreen( @Composable fun HorizontalMovieList( - movies: List<Movie> + movies: List<Movie>, + onMovieClicked: (movie: String) -> Unit ) { LazyRow { items(movies) { movie -> Card( modifier = Modifier .padding(4.dp) - .fillParentMaxWidth(0.3f), + .fillParentMaxWidth(0.4f) + .clickable { + onMovieClicked(movie.id.toString()) + }, ) { AsyncImage( model = movie.imageUrl, diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt index 5861da685..06c256d04 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt @@ -5,16 +5,16 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptionsBuilder import androidx.navigation.compose.composable -const val MOVIES_ROUTE = "movies_route" +const val MOVIES_ROUTE = "movies" fun NavController.navigateToMovies(navOptions: NavOptionsBuilder.() -> Unit) = navigate(MOVIES_ROUTE, navOptions) fun NavGraphBuilder.moviesScreen( - + onMovieClicked: (movie: String) -> Unit ) { composable(route = MOVIES_ROUTE) { - MoviesScreen() + MoviesScreen(onMovieClicked) } } diff --git a/dataconnect/app/src/main/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml index d34dbccb6..e173c48a7 100644 --- a/dataconnect/app/src/main/res/values/strings.xml +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -11,8 +11,12 @@ <string name="title_top_10_movies">Top 10 Movies</string> <string name="title_latest_movies">Latest Movies</string> - <!-- Genre Detail Title --> + <!-- Genre Detail Screen --> <string name="title_genre_detail">%s Movies</string> <string name="title_most_popular">Most Popular</string> <string name="title_most_recent">Most Recent</string> + + <!-- Movie Detail Screen --> + <string name="error_movie_not_found">Couldnt find movie in the database</string> + <string name="description_not_available">Description not available</string> </resources> \ No newline at end of file From bdcd7e890b3b5a609be794b92d88116d13dbf1f3 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 13 Sep 2024 15:47:43 +0100 Subject: [PATCH 14/63] chore: bump gradle plugins --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5e230d1ca..81dddba11 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] -agp = "8.4.0" +agp = "8.6.0" coilCompose = "2.6.0" firebaseAuth = "23.0.0" firebaseDataConnect = "16.0.0-alpha03" -kotlin = "1.9.23" +kotlin = "2.0.20" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.1.5" @@ -11,7 +11,7 @@ espressoCore = "3.5.1" lifecycle = "2.8.1" activityCompose = "1.9.0" composeBom = "2023.08.00" -googleServices = "4.4.1" +googleServices = "4.4.2" composeNavigation = "2.7.7" [libraries] From 27aa537d2e601bd86bc6cfcc76259bd80737d61f Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 13 Sep 2024 15:48:30 +0100 Subject: [PATCH 15/63] test: delete test modules --- .../dataconnect/ExampleInstrumentedTest.kt | 24 ------------------- .../example/dataconnect/ExampleUnitTest.kt | 17 ------------- 2 files changed, 41 deletions(-) delete mode 100644 dataconnect/app/src/androidTest/java/com/google/firebase/example/dataconnect/ExampleInstrumentedTest.kt delete mode 100644 dataconnect/app/src/test/java/com/google/firebase/example/dataconnect/ExampleUnitTest.kt diff --git a/dataconnect/app/src/androidTest/java/com/google/firebase/example/dataconnect/ExampleInstrumentedTest.kt b/dataconnect/app/src/androidTest/java/com/google/firebase/example/dataconnect/ExampleInstrumentedTest.kt deleted file mode 100644 index 2ab99c2c8..000000000 --- a/dataconnect/app/src/androidTest/java/com/google/firebase/example/dataconnect/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.google.firebase.example.dataconnect - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.google.firebase.example.dataconnect", appContext.packageName) - } -} \ No newline at end of file diff --git a/dataconnect/app/src/test/java/com/google/firebase/example/dataconnect/ExampleUnitTest.kt b/dataconnect/app/src/test/java/com/google/firebase/example/dataconnect/ExampleUnitTest.kt deleted file mode 100644 index 7c4c4ce12..000000000 --- a/dataconnect/app/src/test/java/com/google/firebase/example/dataconnect/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.google.firebase.example.dataconnect - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file From 24ca051647cc21ab9b95bcf395c6fbd34ed560cc Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 13 Sep 2024 16:06:56 +0100 Subject: [PATCH 16/63] chore: add compose compiler --- build.gradle.kts | 1 + dataconnect/app/build.gradle.kts | 3 ++- dataconnect/build.gradle.kts | 3 ++- dataconnect/gradle/wrapper/gradle-wrapper.properties | 2 +- gradle/libs.versions.toml | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 01aa79b16..e795bc8d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,7 @@ plugins { id("com.google.firebase.firebase-perf") version "1.4.2" apply false id("androidx.navigation.safeargs") version "2.8.0" apply false id("com.github.ben-manes.versions") version "0.51.0" apply true + id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false } allprojects { diff --git a/dataconnect/app/build.gradle.kts b/dataconnect/app/build.gradle.kts index ff39640d0..e6d0e400a 100644 --- a/dataconnect/app/build.gradle.kts +++ b/dataconnect/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.google.services) + alias(libs.plugins.compose.compiler) } android { @@ -80,4 +81,4 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) -} \ No newline at end of file +} diff --git a/dataconnect/build.gradle.kts b/dataconnect/build.gradle.kts index 7252638e5..8e1379dd6 100644 --- a/dataconnect/build.gradle.kts +++ b/dataconnect/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.google.services) apply false -} \ No newline at end of file + alias(libs.plugins.compose.compiler) apply false +} diff --git a/dataconnect/gradle/wrapper/gradle-wrapper.properties b/dataconnect/gradle/wrapper/gradle-wrapper.properties index a048a37a2..367d4dffd 100644 --- a/dataconnect/gradle/wrapper/gradle-wrapper.properties +++ b/dataconnect/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed May 08 19:29:05 BST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 81dddba11..d1ea661b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,3 +41,4 @@ android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } From ab176f6405cd389318eff24395537896f7961515 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 13 Sep 2024 16:15:47 +0100 Subject: [PATCH 17/63] chore: move location from firebase.json to dataconnect.yaml --- dataconnect/dataconnect/dataconnect.yaml | 1 + dataconnect/firebase.json | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dataconnect/dataconnect/dataconnect.yaml b/dataconnect/dataconnect/dataconnect.yaml index 5d09e5a65..a74d3b653 100644 --- a/dataconnect/dataconnect/dataconnect.yaml +++ b/dataconnect/dataconnect/dataconnect.yaml @@ -1,5 +1,6 @@ specVersion: "v1alpha" serviceId: "dataconnect" +location: "us-central1" schema: source: "./schema" datasource: diff --git a/dataconnect/firebase.json b/dataconnect/firebase.json index fe0eb6bfc..73f599717 100644 --- a/dataconnect/firebase.json +++ b/dataconnect/firebase.json @@ -1,6 +1,5 @@ { "dataconnect": { - "source": "dataconnect", - "location": "us-central1" + "source": "dataconnect" } } From de4b232f31452f766dfe57aa829a584425cdb84a Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 13 Sep 2024 17:58:04 +0100 Subject: [PATCH 18/63] feat: create an auth screen --- .../dataconnect/feature/profile/Navigation.kt | 2 +- .../feature/profile/ProfileScreen.kt | 195 ++++++++++++++++++ .../feature/profile/ProfileUIState.kt | 14 ++ .../feature/profile/ProfileViewModel.kt | 74 +++++++ 4 files changed, 284 insertions(+), 1 deletion(-) 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 diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt index 156ba105b..f6ce7a7f8 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt @@ -14,7 +14,7 @@ fun NavGraphBuilder.profileScreen( ) { composable(route = PROFILE_ROUTE) { - // TODO: Call composable + ProfileScreen() } } diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt new file mode 100644 index 000000000..81c2c6557 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt @@ -0,0 +1,195 @@ +package com.google.firebase.example.dataconnect.feature.profile + +import androidx.compose.foundation.layout.Arrangement +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.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.firebase.dataconnect.movies.GetUserByIdQuery +import com.google.firebase.dataconnect.movies.ListUsersQuery + +@Composable +fun ProfileScreen( + profileViewModel: ProfileViewModel = viewModel() +) { + val uiState by profileViewModel.uiState.collectAsState() + when (uiState) { + is ProfileUIState.Error -> { + Text((uiState as ProfileUIState.Error).errorMessage) + } + + is ProfileUIState.SignUpState -> { + AuthScreen( + onSignUp = { email, password, displayName -> + profileViewModel.signUp(email, password, displayName) + }, + onSignIn = {email, password -> + profileViewModel.signIn(email, password) + } + ) + } + + is ProfileUIState.ProfileState -> { + val userName = (uiState as ProfileUIState.ProfileState).username + ProfileScreen( + userName ?: "User", + emptyList(), + emptyList(), + emptyList() + ) + } + + ProfileUIState.Loading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator() + } + } + } +} + +@Composable +fun ProfileScreen( + name: String, + reviews: List<GetUserByIdQuery.Data.User.ReviewsItem>, + favoriteMovies: List<ListUsersQuery.Data.UsersItem.FavoriteMoviesOnUserItem.Movie>, + favoriteActors: List<ListUsersQuery.Data.UsersItem.FavoriteActorsOnUserItem.Actor> +) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Welcome back, $name!", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + + ProfileSection(title = "Reviews", content = { ReviewsList(reviews) }) + Spacer(modifier = Modifier.height(16.dp)) + + ProfileSection(title = "Favorite Movies", content = { FavoriteMoviesList(favoriteMovies) }) + Spacer(modifier = Modifier.height(16.dp)) + + ProfileSection(title = "Favorite Actors", content = { FavoriteActorsList(favoriteActors) }) + } +} + +@Composable +fun ProfileSection(title: String, content: @Composable () -> Unit) { + Column { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + content() + } +} + +@Composable +fun ReviewsList(reviews: List<GetUserByIdQuery.Data.User.ReviewsItem>) { + // Display the list of reviews +} + +@Composable +fun FavoriteMoviesList(movies: List<ListUsersQuery.Data.UsersItem.FavoriteMoviesOnUserItem.Movie>) { + // Display the list of favorite movies +} + +@Composable +fun FavoriteActorsList(actors: List<ListUsersQuery.Data.UsersItem.FavoriteActorsOnUserItem.Actor>) { + // Display the list of favorite actors +} + +@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(8.dp)) +// Button(onClick = { /* Handle Google Sign-in */ }) { +// Text("Sign in with Google") +// } + + 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" + } + ) + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileUIState.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileUIState.kt new file mode 100644 index 000000000..a9c5970c0 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileUIState.kt @@ -0,0 +1,14 @@ +package com.google.firebase.example.dataconnect.feature.profile + +sealed class ProfileUIState { + data object Loading: ProfileUIState() + + data class Error(val errorMessage: String): ProfileUIState() + + data object SignUpState: ProfileUIState() + + data class ProfileState( + val username: String?, + // TODO: add reviews and favorites🔄 + ) : ProfileUIState() +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileViewModel.kt new file mode 100644 index 000000000..977d00366 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileViewModel.kt @@ -0,0 +1,74 @@ +package com.google.firebase.example.dataconnect.feature.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +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.auth.userProfileChangeRequest +import com.google.firebase.example.dataconnect.feature.moviedetail.MovieDetailUIState +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 +) : ViewModel() { + private val _uiState = MutableStateFlow<ProfileUIState>(ProfileUIState.Loading) + val uiState: StateFlow<ProfileUIState> + get() = _uiState + + private val authStateListener: AuthStateListener + + init { + authStateListener = object : AuthStateListener { + override fun onAuthStateChanged(auth: FirebaseAuth) { + val currentUser = auth.currentUser + if (currentUser != null) { + _uiState.value = ProfileUIState.ProfileState(currentUser.displayName) + } else { + _uiState.value = ProfileUIState.SignUpState + } + } + } + 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() + } catch (e: Exception) { + _uiState.value = ProfileUIState.Error(e.message ?: "") + } + } + } + + fun signIn(email: String, password: String) { + viewModelScope.launch { + try { + auth.signInWithEmailAndPassword(email, password).await() + } catch (e: Exception) { + _uiState.value = ProfileUIState.Error(e.message ?: "") + } + } + } + + override fun onCleared() { + super.onCleared() + auth.removeAuthStateListener(authStateListener) + } +} \ No newline at end of file From d6de3ccb5a4eb7e519f9325b209a5245942dd871 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Tue, 17 Sep 2024 19:17:19 +0100 Subject: [PATCH 19/63] create user profile favorites list and sign out button --- .../dataconnect/data/MovieRepository.kt | 5 +- .../dataconnect/data/UserRepository.kt | 19 ++++ .../feature/moviedetail/MovieDetailScreen.kt | 39 +++++++-- .../moviedetail/MovieDetailViewModel.kt | 10 +++ .../feature/profile/ProfileScreen.kt | 86 +++++++++++++++---- .../feature/profile/ProfileUIState.kt | 7 +- .../feature/profile/ProfileViewModel.kt | 30 ++++++- .../dataconnect/ui/components/MovieTile.kt | 48 +++++++++++ 8 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/UserRepository.kt create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt index 00dad663c..966ca7c4c 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt @@ -9,7 +9,7 @@ class MovieRepository( private val moviesConnector: MoviesConnector = MoviesConnector.instance ) { - // Repositories + // Queries suspend fun listMovies(): List<Movie> { return moviesConnector.listMovies.execute().data.movies.map { it.toMovie() } } @@ -35,4 +35,7 @@ class MovieRepository( } // Mutations + suspend fun addMovieToFavorites(movieID: String) { + moviesConnector.addFavoritedMovie.execute(UUID.fromString(movieID)) + } } \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/UserRepository.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/UserRepository.kt new file mode 100644 index 000000000..528c7a265 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/UserRepository.kt @@ -0,0 +1,19 @@ +package com.google.firebase.example.dataconnect.data + +import com.google.firebase.dataconnect.movies.GetUserByIdQuery +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance + +class UserRepository( + private val moviesConnector: MoviesConnector = MoviesConnector.instance +) { + + suspend fun getUserById(userId: String): GetUserByIdQuery.Data.User? { + return moviesConnector.getUserById.execute(id = userId).data.user + } + + suspend fun addUser(userName: String) { + moviesConnector.upsertUser.execute(username = userName) + } +} \ 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 index f43463d2f..26ace9ce5 100644 --- 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 @@ -8,8 +8,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.filled.FavoriteBorder import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -32,11 +38,28 @@ fun MovieDetailScreen( ) { movieDetailViewModel.setMovieId(movieId) val uiState by movieDetailViewModel.uiState.collectAsState() - MovieDetailScreen(uiState) + // TODO: Create a movie favorited toggle + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { + movieDetailViewModel.addToFavorite() + } + ) { + Icon(Icons.Filled.FavoriteBorder, "Favorite") + } + } + ) { padding -> + MovieDetailScreen( + modifier = Modifier.padding(padding), + uiState = uiState + ) + } } @Composable fun MovieDetailScreen( + modifier: Modifier = Modifier, uiState: MovieDetailUIState ) { when (uiState) { @@ -47,7 +70,7 @@ fun MovieDetailScreen( MovieDetailUIState.Loading -> { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() + modifier = modifier.fillMaxSize() ) { CircularProgressIndicator() } @@ -55,19 +78,25 @@ fun MovieDetailScreen( is MovieDetailUIState.Success -> { val movie = uiState.movie - MovieInformation(movie) + MovieInformation( + modifier = modifier, + movie = movie + ) } } } @Composable -fun MovieInformation(movie: Movie?) { +fun MovieInformation( + modifier: Modifier = Modifier, + movie: Movie? +) { if (movie == null) { ErrorMessage(stringResource(R.string.error_movie_not_found)) } else { val scrollState = rememberScrollState() Column( - modifier = Modifier + modifier = modifier .padding(16.dp) .verticalScroll(scrollState) ) { 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 index 7697bce16..008ac7f32 100644 --- 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 @@ -27,4 +27,14 @@ class MovieDetailViewModel( } } } + + fun addToFavorite() { + viewModelScope.launch { + try { + repository.addMovieToFavorites(movieId) + } 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/profile/ProfileScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt index 81c2c6557..cb492b484 100644 --- 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 @@ -7,11 +7,16 @@ 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.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField 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 @@ -24,7 +29,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.google.firebase.dataconnect.movies.GetUserByIdQuery -import com.google.firebase.dataconnect.movies.ListUsersQuery +import com.google.firebase.example.dataconnect.ui.components.MovieTile @Composable fun ProfileScreen( @@ -41,19 +46,23 @@ fun ProfileScreen( onSignUp = { email, password, displayName -> profileViewModel.signUp(email, password, displayName) }, - onSignIn = {email, password -> + onSignIn = { email, password -> profileViewModel.signIn(email, password) } ) } is ProfileUIState.ProfileState -> { - val userName = (uiState as ProfileUIState.ProfileState).username + val uiState = uiState as ProfileUIState.ProfileState ProfileScreen( - userName ?: "User", - emptyList(), - emptyList(), - emptyList() + uiState.username ?: "User", + uiState.reviews, + uiState.watchedMovies, + uiState.favoriteMovies, + uiState.favoriteActors, + onSignOut = { + profileViewModel.signOut() + } ) } @@ -72,19 +81,34 @@ fun ProfileScreen( fun ProfileScreen( name: String, reviews: List<GetUserByIdQuery.Data.User.ReviewsItem>, - favoriteMovies: List<ListUsersQuery.Data.UsersItem.FavoriteMoviesOnUserItem.Movie>, - favoriteActors: List<ListUsersQuery.Data.UsersItem.FavoriteActorsOnUserItem.Actor> + watchedMovies: List<GetUserByIdQuery.Data.User.WatchedItem>, + favoriteMovies: List<GetUserByIdQuery.Data.User.FavoriteMoviesItem>, + favoriteActors: List<GetUserByIdQuery.Data.User.FavoriteActorsItem>, + onSignOut: () -> Unit ) { - Column(modifier = Modifier.padding(16.dp)) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(scrollState) + ) { Text( text = "Welcome back, $name!", style = MaterialTheme.typography.titleLarge ) + TextButton(onClick = { + onSignOut() + }) { + Text("Sign out") + } Spacer(modifier = Modifier.height(16.dp)) ProfileSection(title = "Reviews", content = { ReviewsList(reviews) }) Spacer(modifier = Modifier.height(16.dp)) + ProfileSection(title = "Watched Movies", content = { WatchedMoviesList(watchedMovies) }) + Spacer(modifier = Modifier.height(16.dp)) + ProfileSection(title = "Favorite Movies", content = { FavoriteMoviesList(favoriteMovies) }) Spacer(modifier = Modifier.height(16.dp)) @@ -107,12 +131,43 @@ fun ReviewsList(reviews: List<GetUserByIdQuery.Data.User.ReviewsItem>) { } @Composable -fun FavoriteMoviesList(movies: List<ListUsersQuery.Data.UsersItem.FavoriteMoviesOnUserItem.Movie>) { - // Display the list of favorite movies +fun WatchedMoviesList(watchedItems: List<GetUserByIdQuery.Data.User.WatchedItem>) { + LazyRow { + items(watchedItems) { watchedItem -> + MovieTile( + modifier = Modifier.fillParentMaxWidth(0.4f), + movieId = watchedItem.movie.id.toString(), + movieImageUrl = watchedItem.movie.imageUrl, + movieTitle = watchedItem.movie.title, + movieRating = watchedItem.movie.rating ?: 0.0, + onMovieClicked = { + // TODO + } + ) + } + } } @Composable -fun FavoriteActorsList(actors: List<ListUsersQuery.Data.UsersItem.FavoriteActorsOnUserItem.Actor>) { +fun FavoriteMoviesList(favoriteItems: List<GetUserByIdQuery.Data.User.FavoriteMoviesItem>) { + LazyRow { + items(favoriteItems) { favoriteItem -> + MovieTile( + modifier = Modifier.fillParentMaxWidth(0.4f), + movieId = favoriteItem.movie.id.toString(), + movieImageUrl = favoriteItem.movie.imageUrl, + movieTitle = favoriteItem.movie.title, + movieRating = favoriteItem.movie.rating ?: 0.0, + onMovieClicked = { + // TODO + } + ) + } + } +} + +@Composable +fun FavoriteActorsList(actors: List<GetUserByIdQuery.Data.User.FavoriteActorsItem>) { // Display the list of favorite actors } @@ -160,11 +215,12 @@ fun AuthScreen( } }) { Text( - text= if (isSignUp) { + text = if (isSignUp) { "Sign up" } else { "Sign in" - }) + } + ) } // Spacer(modifier = Modifier.height(8.dp)) // Button(onClick = { /* Handle Google Sign-in */ }) { 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 index a9c5970c0..df60528fb 100644 --- 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 @@ -1,5 +1,7 @@ package com.google.firebase.example.dataconnect.feature.profile +import com.google.firebase.dataconnect.movies.GetUserByIdQuery + sealed class ProfileUIState { data object Loading: ProfileUIState() @@ -9,6 +11,9 @@ sealed class ProfileUIState { data class ProfileState( val username: String?, - // TODO: add reviews and favorites🔄 + val reviews: List<GetUserByIdQuery.Data.User.ReviewsItem> = emptyList(), + val watchedMovies: List<GetUserByIdQuery.Data.User.WatchedItem> = emptyList(), + val favoriteMovies: List<GetUserByIdQuery.Data.User.FavoriteMoviesItem> = emptyList(), + val favoriteActors: List<GetUserByIdQuery.Data.User.FavoriteActorsItem> = 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 index 977d00366..aa31fc7ec 100644 --- 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 @@ -1,5 +1,6 @@ package com.google.firebase.example.dataconnect.feature.profile +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel @@ -9,6 +10,7 @@ import com.google.firebase.auth.FirebaseAuth.AuthStateListener import com.google.firebase.auth.UserProfileChangeRequest import com.google.firebase.auth.auth import com.google.firebase.auth.userProfileChangeRequest +import com.google.firebase.example.dataconnect.data.UserRepository import com.google.firebase.example.dataconnect.feature.moviedetail.MovieDetailUIState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -16,7 +18,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await class ProfileViewModel( - private val auth: FirebaseAuth = Firebase.auth + private val auth: FirebaseAuth = Firebase.auth, + private val repository: UserRepository = UserRepository() ) : ViewModel() { private val _uiState = MutableStateFlow<ProfileUIState>(ProfileUIState.Loading) val uiState: StateFlow<ProfileUIState> @@ -29,7 +32,7 @@ class ProfileViewModel( override fun onAuthStateChanged(auth: FirebaseAuth) { val currentUser = auth.currentUser if (currentUser != null) { - _uiState.value = ProfileUIState.ProfileState(currentUser.displayName) + displayUser(currentUser.uid) } else { _uiState.value = ProfileUIState.SignUpState } @@ -51,8 +54,10 @@ class ProfileViewModel( .setDisplayName(displayName) .build() )?.await() + repository.addUser(displayName) } catch (e: Exception) { _uiState.value = ProfileUIState.Error(e.message ?: "") + e.printStackTrace() } } } @@ -67,6 +72,27 @@ class ProfileViewModel( } } + fun signOut() { + auth.signOut() + } + + private fun displayUser( + userId: String + ) { + viewModelScope.launch { + try { + val user = repository.getUserById(userId) + _uiState.value = ProfileUIState.ProfileState( + user?.username, + favoriteMovies = user?.favoriteMovies ?: emptyList() + ) + Log.d("DisplayUser", "$user") + } catch (e: Exception) { + _uiState.value = ProfileUIState.Error(e.message ?: "") + } + } + } + override fun onCleared() { super.onCleared() auth.removeAuthStateListener(authStateListener) diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt new file mode 100644 index 000000000..02167f5b0 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt @@ -0,0 +1,48 @@ +package com.google.firebase.example.dataconnect.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.unit.dp +import coil.compose.AsyncImage + +@Composable +fun MovieTile( + modifier: Modifier = Modifier, + movieId: String, + movieImageUrl: String, + movieTitle: String, + movieRating: Double, + onMovieClicked: (movieId: String) -> Unit +) { + Card( + modifier = modifier + .padding(4.dp) + .clickable { + onMovieClicked(movieId) + }, + ) { + AsyncImage( + model = movieImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = movieTitle, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp) + ) + Text( + text = "Rating: $movieRating", + modifier = Modifier.padding(bottom = 8.dp, start = 8.dp, end = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + } +} \ No newline at end of file From acc78fc96595be1139f3baee03f637fe648275f0 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 00:27:19 +0100 Subject: [PATCH 20/63] ellipsize text in Movie Tile --- .../feature/profile/ProfileScreen.kt | 22 ++++++++++++------- .../dataconnect/ui/components/MovieTile.kt | 16 ++++++++++++-- 2 files changed, 28 insertions(+), 10 deletions(-) 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 index cb492b484..dc14399ac 100644 --- 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 @@ -89,16 +89,20 @@ fun ProfileScreen( val scrollState = rememberScrollState() Column( modifier = Modifier - .padding(16.dp) + .padding(vertical = 16.dp) .verticalScroll(scrollState) ) { Text( text = "Welcome back, $name!", - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.displaySmall, + modifier = Modifier.padding(horizontal = 16.dp) ) - TextButton(onClick = { - onSignOut() - }) { + TextButton( + onClick = { + onSignOut() + }, + modifier = Modifier.padding(horizontal = 16.dp) + ) { Text("Sign out") } Spacer(modifier = Modifier.height(16.dp)) @@ -119,7 +123,11 @@ fun ProfileScreen( @Composable fun ProfileSection(title: String, content: @Composable () -> Unit) { Column { - Text(text = title, style = MaterialTheme.typography.titleMedium) + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) Spacer(modifier = Modifier.height(8.dp)) content() } @@ -135,7 +143,6 @@ fun WatchedMoviesList(watchedItems: List<GetUserByIdQuery.Data.User.WatchedItem> LazyRow { items(watchedItems) { watchedItem -> MovieTile( - modifier = Modifier.fillParentMaxWidth(0.4f), movieId = watchedItem.movie.id.toString(), movieImageUrl = watchedItem.movie.imageUrl, movieTitle = watchedItem.movie.title, @@ -153,7 +160,6 @@ fun FavoriteMoviesList(favoriteItems: List<GetUserByIdQuery.Data.User.FavoriteMo LazyRow { items(favoriteItems) { favoriteItem -> MovieTile( - modifier = Modifier.fillParentMaxWidth(0.4f), movieId = favoriteItem.movie.id.toString(), movieImageUrl = favoriteItem.movie.imageUrl, movieTitle = favoriteItem.movie.title, diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt index 02167f5b0..503df2bf7 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt @@ -1,17 +1,26 @@ 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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn 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 coil.compose.AsyncImage +val MAX_MOVIE_CARD_WIDTH = 120.dp + @Composable fun MovieTile( modifier: Modifier = Modifier, @@ -23,7 +32,8 @@ fun MovieTile( ) { Card( modifier = modifier - .padding(4.dp) + .padding(vertical = 16.dp, horizontal = 4.dp) + .sizeIn(maxWidth = MAX_MOVIE_CARD_WIDTH) .clickable { onMovieClicked(movieId) }, @@ -37,7 +47,9 @@ fun MovieTile( Text( text = movieTitle, style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp) + modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Text( text = "Rating: $movieRating", From f37f21ea60713836ab07ff7d146086281147ba6b Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 00:40:53 +0100 Subject: [PATCH 21/63] refactor: reuse MovieTile UI component --- .../feature/genredetail/GenreDetailScreen.kt | 41 ++++++++---- .../feature/movies/MoviesScreen.kt | 66 ++++++++----------- .../dataconnect/ui/components/MovieTile.kt | 5 +- 3 files changed, 61 insertions(+), 51 deletions(-) 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 index 40808c519..9f836e020 100644 --- 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 @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator @@ -20,7 +22,7 @@ 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.feature.movies.HorizontalMovieList +import com.google.firebase.example.dataconnect.ui.components.MovieTile @Composable fun GenreDetailScreen( @@ -45,9 +47,11 @@ fun GenreDetailScreen( CircularProgressIndicator() } } + is GenreDetailUIState.Error -> { Text(uiState.errorMessage) } + is GenreDetailUIState.Success -> { val scrollState = rememberScrollState() Column( @@ -64,23 +68,38 @@ fun GenreDetailScreen( style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(vertical = 16.dp) ) - HorizontalMovieList( - uiState.moviesByGenre.mostPopular, - onMovieClicked = { - // TODO(thatfiredev) + LazyRow { + items(uiState.moviesByGenre.mostPopular) { movie -> + MovieTile( + movieId = movie.id.toString(), + movieTitle = movie.title, + movieImageUrl = movie.imageUrl, + movieRating = movie.rating ?: 0.0, + onMovieClicked = { + // TODO + } + ) } - ) + } + Text( text = stringResource(R.string.title_most_recent), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(vertical = 16.dp) ) - HorizontalMovieList( - uiState.moviesByGenre.mostRecent, - onMovieClicked = { - // TODO(thatfiredev) + LazyRow { + items(uiState.moviesByGenre.mostRecent) { movie -> + MovieTile( + movieId = movie.id.toString(), + movieTitle = movie.title, + movieImageUrl = movie.imageUrl, + movieRating = movie.rating ?: 0.0, + onMovieClicked = { + // TODO(thatfiredev) + } + ) } - ) + } } } } 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 index 1fddeb992..d2484cde7 100644 --- 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 @@ -26,6 +26,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.google.firebase.example.dataconnect.R import com.google.firebase.example.dataconnect.data.Movie +import com.google.firebase.example.dataconnect.ui.components.MovieTile @Composable fun MoviesScreen( @@ -64,49 +65,38 @@ fun MoviesScreen( style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(bottom = 16.dp) ) - HorizontalMovieList(uiState.top10movies, onMovieClicked) + LazyRow { + items(uiState.top10movies) { movie -> + MovieTile( + movieId = movie.id.toString(), + movieTitle = movie.title, + movieImageUrl = movie.imageUrl, + movieRating = movie.rating ?: 0.0, + onMovieClicked = { + onMovieClicked(movie.id.toString()) + } + ) + } + } + Text( text = stringResource(R.string.title_latest_movies), style = MaterialTheme.typography.headlineMedium, modifier = Modifier.padding(vertical = 16.dp) ) - HorizontalMovieList(uiState.latestMovies, onMovieClicked) - } - } - } -} - -@Composable -fun HorizontalMovieList( - movies: List<Movie>, - onMovieClicked: (movie: String) -> Unit -) { - LazyRow { - items(movies) { movie -> - Card( - modifier = Modifier - .padding(4.dp) - .fillParentMaxWidth(0.4f) - .clickable { - onMovieClicked(movie.id.toString()) - }, - ) { - AsyncImage( - model = movie.imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = movie.title, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp) - ) - Text( - text = "Rating: ${movie.rating}", - modifier = Modifier.padding(bottom = 8.dp, start = 8.dp, end = 8.dp), - style = MaterialTheme.typography.bodySmall - ) + LazyRow { + items(uiState.latestMovies) { movie -> + MovieTile( + movieId = movie.id.toString(), + movieTitle = movie.title, + movieImageUrl = movie.imageUrl, + movieRating = movie.rating ?: 0.0, + onMovieClicked = { + onMovieClicked(movie.id.toString()) + } + ) + } + } } } } diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt index 503df2bf7..70f7ecba7 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -19,7 +20,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage -val MAX_MOVIE_CARD_WIDTH = 120.dp +val MAX_MOVIE_CARD_WIDTH = 150.dp @Composable fun MovieTile( @@ -42,7 +43,7 @@ fun MovieTile( model = movieImageUrl, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.aspectRatio(9f / 16f) ) Text( text = movieTitle, From afe7d6924f999899a115ea296d7bdf9bd1cd2d8b Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 00:43:08 +0100 Subject: [PATCH 22/63] delete UserRepository --- .../dataconnect/data/UserRepository.kt | 19 ------------------- .../feature/profile/ProfileViewModel.kt | 10 ++++++---- 2 files changed, 6 insertions(+), 23 deletions(-) delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/UserRepository.kt diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/UserRepository.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/UserRepository.kt deleted file mode 100644 index 528c7a265..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/UserRepository.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.google.firebase.example.dataconnect.data - -import com.google.firebase.dataconnect.movies.GetUserByIdQuery -import com.google.firebase.dataconnect.movies.MoviesConnector -import com.google.firebase.dataconnect.movies.execute -import com.google.firebase.dataconnect.movies.instance - -class UserRepository( - private val moviesConnector: MoviesConnector = MoviesConnector.instance -) { - - suspend fun getUserById(userId: String): GetUserByIdQuery.Data.User? { - return moviesConnector.getUserById.execute(id = userId).data.user - } - - suspend fun addUser(userName: String) { - moviesConnector.upsertUser.execute(username = userName) - } -} \ No newline at end of file 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 index aa31fc7ec..e24206ecd 100644 --- 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 @@ -10,7 +10,9 @@ import com.google.firebase.auth.FirebaseAuth.AuthStateListener import com.google.firebase.auth.UserProfileChangeRequest import com.google.firebase.auth.auth import com.google.firebase.auth.userProfileChangeRequest -import com.google.firebase.example.dataconnect.data.UserRepository +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance import com.google.firebase.example.dataconnect.feature.moviedetail.MovieDetailUIState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,7 +21,7 @@ import kotlinx.coroutines.tasks.await class ProfileViewModel( private val auth: FirebaseAuth = Firebase.auth, - private val repository: UserRepository = UserRepository() + private val moviesConnector: MoviesConnector = MoviesConnector.instance ) : ViewModel() { private val _uiState = MutableStateFlow<ProfileUIState>(ProfileUIState.Loading) val uiState: StateFlow<ProfileUIState> @@ -54,7 +56,7 @@ class ProfileViewModel( .setDisplayName(displayName) .build() )?.await() - repository.addUser(displayName) + moviesConnector.upsertUser.execute(username = displayName) } catch (e: Exception) { _uiState.value = ProfileUIState.Error(e.message ?: "") e.printStackTrace() @@ -81,7 +83,7 @@ class ProfileViewModel( ) { viewModelScope.launch { try { - val user = repository.getUserById(userId) + val user = moviesConnector.getUserById.execute(id = userId).data.user _uiState.value = ProfileUIState.ProfileState( user?.username, favoriteMovies = user?.favoriteMovies ?: emptyList() From e3dd016382c737d6de5c3221478e8e3b0debc478 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 00:48:36 +0100 Subject: [PATCH 23/63] refactor: delete MovieRepository --- .../dataconnect/data/MovieRepository.kt | 41 ------------------- .../genredetail/GenreDetailViewModel.kt | 13 ++++-- .../moviedetail/MovieDetailViewModel.kt | 14 +++++-- .../feature/movies/MoviesViewModel.kt | 11 +++-- 4 files changed, 27 insertions(+), 52 deletions(-) delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt deleted file mode 100644 index 966ca7c4c..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MovieRepository.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.google.firebase.example.dataconnect.data - -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 - -class MovieRepository( - private val moviesConnector: MoviesConnector = MoviesConnector.instance -) { - - // Queries - suspend fun listMovies(): List<Movie> { - return moviesConnector.listMovies.execute().data.movies.map { it.toMovie() } - } - - suspend fun getTop10Movies(): List<Movie> { - return moviesConnector.moviesTop10.execute().data.movies.map { it.toMovie() } - } - - suspend fun getRecentlyReleasedMovies(): List<Movie> { - return moviesConnector.moviesRecentlyReleased.execute().data.movies.map { it.toMovie() } - } - - suspend fun getMoviesByGenre(genre: String): MoviesByGenre { - val data = moviesConnector.listMoviesByGenre.execute(genre).data - val mostPopular = data.mostPopular.map { it.toMovie() } - val mostRecent = data.mostRecent.map { it.toMovie() } - return MoviesByGenre(mostPopular, mostRecent) - } - - suspend fun getMovieByID(movieID: String): Movie? { - val id = UUID.fromString(movieID) - return moviesConnector.getMovieById.execute(id).data.movie?.toMovie() - } - - // Mutations - suspend fun addMovieToFavorites(movieID: String) { - moviesConnector.addFavoritedMovie.execute(UUID.fromString(movieID)) - } -} \ 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 index 912e11540..80a053b7a 100644 --- 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 @@ -2,13 +2,17 @@ package com.google.firebase.example.dataconnect.feature.genredetail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.example.dataconnect.data.MovieRepository +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance +import com.google.firebase.example.dataconnect.data.MoviesByGenre +import com.google.firebase.example.dataconnect.data.toMovie import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class GenreDetailViewModel( - private val dataRepository: MovieRepository = MovieRepository() + private val moviesConnector: MoviesConnector = MoviesConnector.instance ) : ViewModel() { private var genre = "" @@ -21,7 +25,10 @@ class GenreDetailViewModel( this.genre = genre viewModelScope.launch { try { - val movies = dataRepository.getMoviesByGenre(genre.lowercase()) + val data = moviesConnector.listMoviesByGenre.execute(genre.lowercase()).data + val mostPopular = data.mostPopular.map { it.toMovie() } + val mostRecent = data.mostRecent.map { it.toMovie() } + val movies = MoviesByGenre(mostPopular, mostRecent) _uiState.value = GenreDetailUIState.Success( genreName = genre, moviesByGenre = movies 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 index 008ac7f32..afeb672f0 100644 --- 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 @@ -2,13 +2,17 @@ package com.google.firebase.example.dataconnect.feature.moviedetail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.example.dataconnect.data.MovieRepository +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance +import com.google.firebase.example.dataconnect.data.toMovie +import java.util.UUID import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class MovieDetailViewModel( - private val repository: MovieRepository = MovieRepository() + private val moviesConnector: MoviesConnector = MoviesConnector.instance ) : ViewModel() { private var movieId: String = "" @@ -20,7 +24,9 @@ class MovieDetailViewModel( movieId = id viewModelScope.launch { try { - val movie = repository.getMovieByID(movieId) + val movie = moviesConnector.getMovieById.execute( + id = UUID.fromString(movieId) + ).data.movie?.toMovie() _uiState.value = MovieDetailUIState.Success(movie) } catch (e: Exception) { _uiState.value = MovieDetailUIState.Error(e.message ?: "") @@ -31,7 +37,7 @@ class MovieDetailViewModel( fun addToFavorite() { viewModelScope.launch { try { - repository.addMovieToFavorites(movieId) + moviesConnector.addFavoritedMovie.execute(UUID.fromString(movieId)) } catch (e: Exception) { _uiState.value = MovieDetailUIState.Error(e.message ?: "") } 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 index 09fe510bd..63f0cc3be 100644 --- 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 @@ -2,13 +2,16 @@ package com.google.firebase.example.dataconnect.feature.movies import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.example.dataconnect.data.MovieRepository +import com.google.firebase.dataconnect.movies.MoviesConnector +import com.google.firebase.dataconnect.movies.execute +import com.google.firebase.dataconnect.movies.instance +import com.google.firebase.example.dataconnect.data.toMovie import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class MoviesViewModel( - private val dataRepository: MovieRepository = MovieRepository() + private val moviesConnector: MoviesConnector = MoviesConnector.instance ) : ViewModel() { private val _uiState = MutableStateFlow<MoviesUIState>(MoviesUIState.Loading) @@ -18,8 +21,8 @@ class MoviesViewModel( init { viewModelScope.launch { try { - val top10Movies = dataRepository.getTop10Movies() - val latestMovies = dataRepository.getRecentlyReleasedMovies() + val top10Movies = moviesConnector.moviesTop10.execute().data.movies.map { it.toMovie() } + val latestMovies = moviesConnector.moviesRecentlyReleased.execute().data.movies.map { it.toMovie() } _uiState.value = MoviesUIState.Success(top10Movies, latestMovies) } catch (e: Exception) { From eb76c9290f2cced7a3869fa30a8ac6b15c27cdee Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 00:57:22 +0100 Subject: [PATCH 24/63] refactor: delete the data package --- .../dataconnect/data/DataConnectMappers.kt | 73 ------------------- .../example/dataconnect/data/Movie.kt | 14 ---- .../example/dataconnect/data/MoviesByGenre.kt | 6 -- .../feature/genredetail/GenreDetailScreen.kt | 4 +- .../feature/genredetail/GenreDetailUIState.kt | 6 +- .../genredetail/GenreDetailViewModel.kt | 12 ++- .../feature/moviedetail/MovieDetailScreen.kt | 5 +- .../feature/moviedetail/MovieDetailUIState.kt | 5 +- .../moviedetail/MovieDetailViewModel.kt | 3 +- .../feature/movies/MoviesScreen.kt | 1 - .../feature/movies/MoviesUIState.kt | 9 ++- .../feature/movies/MoviesViewModel.kt | 7 +- 12 files changed, 24 insertions(+), 121 deletions(-) delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MoviesByGenre.kt diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt deleted file mode 100644 index 8dc63c5e5..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/DataConnectMappers.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.google.firebase.example.dataconnect.data - -import com.google.firebase.dataconnect.movies.GetMovieByIdQuery -import com.google.firebase.dataconnect.movies.ListMoviesByGenreQuery -import com.google.firebase.dataconnect.movies.ListMoviesQuery -import com.google.firebase.dataconnect.movies.MoviesRecentlyReleasedQuery -import com.google.firebase.dataconnect.movies.MoviesTop10Query - -fun ListMoviesQuery.Data.MoviesItem.toMovie(): Movie { - return Movie( - id = this.id, - title = this.title, - genre = this.genre, - imageUrl = this.imageUrl, - releaseYear = this.releaseYear, - rating = this.rating, - tags = this.tags - ) -} - -fun MoviesTop10Query.Data.MoviesItem.toMovie(): Movie { - return Movie( - id = this.id, - title = this.title, - imageUrl = this.imageUrl, - rating = this.rating, - genre = this.genre, - tags = this.tags - ) -} - -fun MoviesRecentlyReleasedQuery.Data.MoviesItem.toMovie(): Movie { - return Movie( - id = this.id, - title = this.title, - imageUrl = this.imageUrl, - rating = this.rating, - genre = this.genre, - tags = this.tags - ) -} - -fun ListMoviesByGenreQuery.Data.MostRecentItem.toMovie(): Movie { - return Movie( - id = this.id, - title = this.title, - imageUrl = this.imageUrl, - rating = this.rating, - tags = this.tags - ) -} - -fun ListMoviesByGenreQuery.Data.MostPopularItem.toMovie(): Movie { - return Movie( - id = this.id, - title = this.title, - imageUrl = this.imageUrl, - rating = this.rating, - tags = this.tags - ) -} - -fun GetMovieByIdQuery.Data.Movie.toMovie(): Movie { - return Movie( - id = this.id, - title = this.title, - description = this.description, - imageUrl = this.imageUrl, - rating = this.rating, - genre = this.genre, - tags = this.tags - ) -} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt deleted file mode 100644 index bbd08c19b..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/Movie.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.google.firebase.example.dataconnect.data - -import java.util.UUID - -data class Movie( - val id: UUID, - val title: String, - val imageUrl: String, - val releaseYear: Int? = 1970, - val genre: String? = "", - val rating: Double? = 0.0, - val description: String? = null, - val tags: List<String?>? = emptyList() -) diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MoviesByGenre.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MoviesByGenre.kt deleted file mode 100644 index e26e14a62..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/data/MoviesByGenre.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.google.firebase.example.dataconnect.data - -data class MoviesByGenre( - val mostPopular: List<Movie>, - val mostRecent: List<Movie> -) 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 index 9f836e020..22a4c8f4e 100644 --- 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 @@ -69,7 +69,7 @@ fun GenreDetailScreen( modifier = Modifier.padding(vertical = 16.dp) ) LazyRow { - items(uiState.moviesByGenre.mostPopular) { movie -> + items(uiState.mostPopular) { movie -> MovieTile( movieId = movie.id.toString(), movieTitle = movie.title, @@ -88,7 +88,7 @@ fun GenreDetailScreen( modifier = Modifier.padding(vertical = 16.dp) ) LazyRow { - items(uiState.moviesByGenre.mostRecent) { movie -> + items(uiState.mostRecent) { movie -> MovieTile( movieId = movie.id.toString(), movieTitle = movie.title, 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 index b4b99178e..fefb51f1d 100644 --- 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 @@ -1,7 +1,6 @@ package com.google.firebase.example.dataconnect.feature.genredetail -import com.google.firebase.example.dataconnect.data.Movie -import com.google.firebase.example.dataconnect.data.MoviesByGenre +import com.google.firebase.dataconnect.movies.ListMoviesByGenreQuery sealed class GenreDetailUIState { @@ -11,6 +10,7 @@ sealed class GenreDetailUIState { data class Success( val genreName: String, - val moviesByGenre: MoviesByGenre + val mostPopular: List<ListMoviesByGenreQuery.Data.MostPopularItem>, + val mostRecent: List<ListMoviesByGenreQuery.Data.MostRecentItem> ) : 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 index 80a053b7a..811250a78 100644 --- 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 @@ -5,8 +5,6 @@ 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 com.google.firebase.example.dataconnect.data.MoviesByGenre -import com.google.firebase.example.dataconnect.data.toMovie import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -26,16 +24,16 @@ class GenreDetailViewModel( viewModelScope.launch { try { val data = moviesConnector.listMoviesByGenre.execute(genre.lowercase()).data - val mostPopular = data.mostPopular.map { it.toMovie() } - val mostRecent = data.mostRecent.map { it.toMovie() } - val movies = MoviesByGenre(mostPopular, mostRecent) + val mostPopular = data.mostPopular + val mostRecent = data.mostRecent _uiState.value = GenreDetailUIState.Success( genreName = genre, - moviesByGenre = movies + mostPopular = mostPopular, + mostRecent = mostRecent ) } catch (e: Exception) { _uiState.value = GenreDetailUIState.Error(e.message ?: "") } } } -} \ 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 index 26ace9ce5..4b3c067d4 100644 --- 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 @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding 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.filled.FavoriteBorder import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FloatingActionButton @@ -28,8 +27,8 @@ 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.data.Movie @Composable fun MovieDetailScreen( @@ -89,7 +88,7 @@ fun MovieDetailScreen( @Composable fun MovieInformation( modifier: Modifier = Modifier, - movie: Movie? + movie: GetMovieByIdQuery.Data.Movie? ) { if (movie == null) { ErrorMessage(stringResource(R.string.error_movie_not_found)) 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 index 685cb73e1..0f245e440 100644 --- 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 @@ -1,6 +1,7 @@ package com.google.firebase.example.dataconnect.feature.moviedetail -import com.google.firebase.example.dataconnect.data.Movie +import com.google.firebase.dataconnect.movies.GetMovieByIdQuery + sealed class MovieDetailUIState { data object Loading: MovieDetailUIState() @@ -9,6 +10,6 @@ sealed class MovieDetailUIState { data class Success( // Movie is null if it can't be found on the DB - val movie: Movie? + val movie: GetMovieByIdQuery.Data.Movie? ) : 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 index afeb672f0..e36729496 100644 --- 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 @@ -5,7 +5,6 @@ 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 com.google.firebase.example.dataconnect.data.toMovie import java.util.UUID import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,7 +25,7 @@ class MovieDetailViewModel( try { val movie = moviesConnector.getMovieById.execute( id = UUID.fromString(movieId) - ).data.movie?.toMovie() + ).data.movie _uiState.value = MovieDetailUIState.Success(movie) } catch (e: Exception) { _uiState.value = MovieDetailUIState.Error(e.message ?: "") 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 index d2484cde7..3fc75dce5 100644 --- 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 @@ -25,7 +25,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.google.firebase.example.dataconnect.R -import com.google.firebase.example.dataconnect.data.Movie import com.google.firebase.example.dataconnect.ui.components.MovieTile @Composable 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 index 73aca9a07..92b8f70f3 100644 --- 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 @@ -1,6 +1,7 @@ package com.google.firebase.example.dataconnect.feature.movies -import com.google.firebase.example.dataconnect.data.Movie +import com.google.firebase.dataconnect.movies.MoviesRecentlyReleasedQuery +import com.google.firebase.dataconnect.movies.MoviesTop10Query sealed class MoviesUIState { @@ -9,7 +10,7 @@ sealed class MoviesUIState { data class Error(val errorMessage: String): MoviesUIState() data class Success( - val top10movies: List<Movie>, - val latestMovies: List<Movie> + val top10movies: List<MoviesTop10Query.Data.MoviesItem>, + val latestMovies: List<MoviesRecentlyReleasedQuery.Data.MoviesItem> ) : MoviesUIState() -} \ No newline at end of file +} 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 index 63f0cc3be..932dca76e 100644 --- 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 @@ -5,7 +5,6 @@ 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 com.google.firebase.example.dataconnect.data.toMovie import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -21,8 +20,8 @@ class MoviesViewModel( init { viewModelScope.launch { try { - val top10Movies = moviesConnector.moviesTop10.execute().data.movies.map { it.toMovie() } - val latestMovies = moviesConnector.moviesRecentlyReleased.execute().data.movies.map { it.toMovie() } + val top10Movies = moviesConnector.moviesTop10.execute().data.movies + val latestMovies = moviesConnector.moviesRecentlyReleased.execute().data.movies _uiState.value = MoviesUIState.Success(top10Movies, latestMovies) } catch (e: Exception) { @@ -30,4 +29,4 @@ class MoviesViewModel( } } } -} \ No newline at end of file +} From 6a29c550d8373c869340d80fe546d476b38f8d65 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 01:21:27 +0100 Subject: [PATCH 25/63] feat: list actors in the movie details screen --- .../feature/moviedetail/MovieDetailScreen.kt | 62 +++++++++++++++++-- .../dataconnect/ui/components/ActorTile.kt | 56 +++++++++++++++++ 2 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt 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 index 4b3c067d4..ced985a6c 100644 --- 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 @@ -3,9 +3,14 @@ package com.google.firebase.example.dataconnect.feature.moviedetail import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -29,6 +34,7 @@ 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.ActorTile @Composable fun MovieDetailScreen( @@ -77,10 +83,18 @@ fun MovieDetailScreen( is MovieDetailUIState.Success -> { val movie = uiState.movie - MovieInformation( - modifier = modifier, - movie = movie - ) + val scrollState = rememberScrollState() + Column( + modifier = Modifier.verticalScroll(scrollState) + ) { + MovieInformation( + modifier = modifier, + movie = movie + ) + MainActorsList(movie?.mainActors ?: emptyList()) + SupportingActorsList(movie?.supportingActors ?: emptyList()) + } + } } } @@ -93,11 +107,9 @@ fun MovieInformation( if (movie == null) { ErrorMessage(stringResource(R.string.error_movie_not_found)) } else { - val scrollState = rememberScrollState() Column( modifier = modifier .padding(16.dp) - .verticalScroll(scrollState) ) { Text( text = movie.title, @@ -147,6 +159,44 @@ fun MovieInformation( } } +@Composable +fun MainActorsList( + actors: List<GetMovieByIdQuery.Data.Movie.MainActorsItem?> +) { + Text( + text = "Main Actors", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyRow { + items(actors) { actor -> + actor?.let { + ActorTile(it.name, it.imageUrl) + } + } + } +} + +@Composable +fun SupportingActorsList( + actors: List<GetMovieByIdQuery.Data.Movie.SupportingActorsItem?> +) { + Text( + text = "Supporting Actors", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyRow { + items(actors) { actor -> + actor?.let { + ActorTile(it.name, it.imageUrl) + } + } + } +} + @Composable fun ErrorMessage( message: String diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt new file mode 100644 index 000000000..3a972ae69 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt @@ -0,0 +1,56 @@ +package com.google.firebase.example.dataconnect.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.compose.rememberAsyncImagePainter + +val ACTOR_CARD_SIZE = 80.dp + +@Composable +fun ActorTile( + actorName: String, + actorImageUrl: String +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.sizeIn( + maxWidth = ACTOR_CARD_SIZE, + maxHeight = ACTOR_CARD_SIZE + 32.dp + ).padding(4.dp) + ) { + AsyncImage( + model = actorImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(ACTOR_CARD_SIZE) + .clip(CircleShape) + ) + Text( + text = actorName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} From 0b9ef775efc07d0cf6054a16ac863f511ec766a3 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 02:56:45 +0100 Subject: [PATCH 26/63] refactor: movie review list to bottom of profile screen --- .../feature/profile/ProfileScreen.kt | 35 ++++++++++-- .../dataconnect/ui/components/ReviewCard.kt | 57 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ReviewCard.kt 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 index dc14399ac..156450853 100644 --- 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 @@ -7,6 +7,7 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -29,7 +30,9 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation 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.ui.components.ActorTile import com.google.firebase.example.dataconnect.ui.components.MovieTile +import com.google.firebase.example.dataconnect.ui.components.ReviewCard @Composable fun ProfileScreen( @@ -107,9 +110,6 @@ fun ProfileScreen( } Spacer(modifier = Modifier.height(16.dp)) - ProfileSection(title = "Reviews", content = { ReviewsList(reviews) }) - Spacer(modifier = Modifier.height(16.dp)) - ProfileSection(title = "Watched Movies", content = { WatchedMoviesList(watchedMovies) }) Spacer(modifier = Modifier.height(16.dp)) @@ -117,6 +117,9 @@ fun ProfileScreen( Spacer(modifier = Modifier.height(16.dp)) ProfileSection(title = "Favorite Actors", content = { FavoriteActorsList(favoriteActors) }) + + ProfileSection(title = "Reviews", content = { ReviewsList(name, reviews) }) + Spacer(modifier = Modifier.height(16.dp)) } } @@ -134,8 +137,21 @@ fun ProfileSection(title: String, content: @Composable () -> Unit) { } @Composable -fun ReviewsList(reviews: List<GetUserByIdQuery.Data.User.ReviewsItem>) { - // Display the list of reviews +fun ReviewsList( + userName: String, + reviews: List<GetUserByIdQuery.Data.User.ReviewsItem> +) { + Column { + // TODO(thatfiredev): Handle cases where the list is too long to display + reviews.forEach { review -> + ReviewCard( + userName = userName, + date = review.reviewDate.toString(), + rating = review.rating?.toDouble() ?: 0.0, + text = review.reviewText ?: "" + ) + } + } } @Composable @@ -174,7 +190,14 @@ fun FavoriteMoviesList(favoriteItems: List<GetUserByIdQuery.Data.User.FavoriteMo @Composable fun FavoriteActorsList(actors: List<GetUserByIdQuery.Data.User.FavoriteActorsItem>) { - // Display the list of favorite actors + LazyRow { + items(actors) { favoriteActor -> + ActorTile( + actorName = favoriteActor.actor.name, + actorImageUrl = favoriteActor.actor.imageUrl + ) + } + } } @Composable diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ReviewCard.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ReviewCard.kt new file mode 100644 index 000000000..585b0fdd2 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ReviewCard.kt @@ -0,0 +1,57 @@ +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 + +@Composable +fun ReviewCard( + userName: String, + date: String, + rating: Double, + text: String +) { + Card( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.onSecondaryContainer) + .fillMaxWidth() + .padding(8.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + ) { + Text( + text = userName, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Row( + modifier = Modifier.padding(bottom = 8.dp) + ) { + Text(text = date) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "Rating: ") + Text(text = "$rating") + } + Text( + text = text, + modifier = Modifier.fillMaxWidth() + ) + } + } +} From 1af90a7db8fb2b8fc3cc3e5d0a0c979b81792786 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 02:56:57 +0100 Subject: [PATCH 27/63] feat: add reviews to movie detail screen --- .../feature/moviedetail/MovieDetailScreen.kt | 115 +++++++++++++++--- 1 file changed, 99 insertions(+), 16 deletions(-) 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 index ced985a6c..ed62960d8 100644 --- 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 @@ -4,27 +4,36 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Favorite +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold 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.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 @@ -35,6 +44,7 @@ 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.ActorTile +import com.google.firebase.example.dataconnect.ui.components.ReviewCard @Composable fun MovieDetailScreen( @@ -45,15 +55,15 @@ fun MovieDetailScreen( val uiState by movieDetailViewModel.uiState.collectAsState() // TODO: Create a movie favorited toggle Scaffold( - floatingActionButton = { - FloatingActionButton( - onClick = { - movieDetailViewModel.addToFavorite() - } - ) { - Icon(Icons.Filled.FavoriteBorder, "Favorite") - } - } +// floatingActionButton = { +// FloatingActionButton( +// onClick = { +// movieDetailViewModel.addToFavorite() +// } +// ) { +// Icon(Icons.Filled.FavoriteBorder, "Favorite") +// } +// } ) { padding -> MovieDetailScreen( modifier = Modifier.padding(padding), @@ -93,6 +103,12 @@ fun MovieDetailScreen( ) MainActorsList(movie?.mainActors ?: emptyList()) SupportingActorsList(movie?.supportingActors ?: emptyList()) + UserReviews( + onReviewSubmitted = { + + }, + movie?.reviews ?: emptyList() + ) } } @@ -115,15 +131,20 @@ fun MovieInformation( text = movie.title, style = MaterialTheme.typography.headlineLarge ) - Row { + Row( + verticalAlignment = Alignment.CenterVertically + ) { Text( text = movie.releaseYear.toString(), - style = MaterialTheme.typography.labelMedium, + 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.labelMedium + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 2.dp) ) } Row { @@ -132,10 +153,12 @@ fun MovieInformation( contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier + .width(150.dp) + .aspectRatio(9f / 16f) .padding(vertical = 8.dp) ) Column( - modifier = Modifier.padding(horizontal = 8.dp) + modifier = Modifier.padding(horizontal = 16.dp) ) { Row { movie.tags?.let { movieTags -> @@ -144,7 +167,7 @@ fun MovieInformation( onClick = { }, label = { Text(tag) }, modifier = Modifier - .padding(horizontal = 4.dp, vertical = 8.dp) + .padding(horizontal = 4.dp) ) } } @@ -155,6 +178,22 @@ fun MovieInformation( ) } } + Spacer(modifier = Modifier.height(8.dp)) + Row { + OutlinedButton(onClick = { + + }) { + Icon(Icons.Outlined.Check, "Watched") + Text("Mark as watched", modifier = Modifier.padding(start = 4.dp)) + } + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton(onClick = { + + }) { + Icon(Icons.Outlined.Favorite, "Favorite") + Text("Add to Favorites", modifier = Modifier.padding(start = 4.dp)) + } + } } } } @@ -197,6 +236,50 @@ fun SupportingActorsList( } } +@Composable +fun UserReviews( + onReviewSubmitted: (String) -> Unit, + reviews: List<GetMovieByIdQuery.Data.Movie.ReviewsItem> +) { + 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 + ) { + TextField( + value = reviewText, + onValueChange = { reviewText = it }, + label = { Text("Write your review") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = { onReviewSubmitted(reviewText) }) { + Text("Submit Review") + } + } + Column { + // TODO(thatfiredev): Handle cases where the list is too long to display + reviews.forEach { + ReviewCard( + userName = it.user.username, + date = it.reviewDate.toString(), + rating = it.rating?.toDouble() ?: 0.0, + text = it.reviewText ?: "" + ) + } + } +} + @Composable fun ErrorMessage( message: String From d9293aee871e79c60afbce54aa971a38ffe038c9 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 03:22:56 +0100 Subject: [PATCH 28/63] refactor: dateformat for reviews --- .../feature/moviedetail/MovieDetailScreen.kt | 10 +++++++--- .../feature/profile/ProfileScreen.kt | 2 +- .../dataconnect/ui/components/ReviewCard.kt | 17 ++++++++++++++--- 3 files changed, 22 insertions(+), 7 deletions(-) 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 index ed62960d8..7130d572d 100644 --- 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 @@ -208,7 +208,9 @@ fun MainActorsList( modifier = Modifier.padding(horizontal = 16.dp) ) Spacer(modifier = Modifier.height(8.dp)) - LazyRow { + LazyRow( + modifier = Modifier.padding(horizontal = 16.dp) + ) { items(actors) { actor -> actor?.let { ActorTile(it.name, it.imageUrl) @@ -227,7 +229,9 @@ fun SupportingActorsList( modifier = Modifier.padding(horizontal = 16.dp) ) Spacer(modifier = Modifier.height(8.dp)) - LazyRow { + LazyRow( + modifier = Modifier.padding(horizontal = 16.dp) + ) { items(actors) { actor -> actor?.let { ActorTile(it.name, it.imageUrl) @@ -272,7 +276,7 @@ fun UserReviews( reviews.forEach { ReviewCard( userName = it.user.username, - date = it.reviewDate.toString(), + 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/profile/ProfileScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt index 156450853..e6059823e 100644 --- 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 @@ -146,7 +146,7 @@ fun ReviewsList( reviews.forEach { review -> ReviewCard( userName = userName, - date = review.reviewDate.toString(), + 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/ui/components/ReviewCard.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ReviewCard.kt index 585b0fdd2..4df00e3c0 100644 --- 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 @@ -17,22 +17,28 @@ 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: String, + date: Date, rating: Double, text: String ) { Card( modifier = Modifier - .background(color = MaterialTheme.colorScheme.onSecondaryContainer) .fillMaxWidth() .padding(8.dp) ) { Column( modifier = Modifier + .background(color = MaterialTheme.colorScheme.secondaryContainer) .padding(16.dp) ) { Text( @@ -43,7 +49,12 @@ fun ReviewCard( Row( modifier = Modifier.padding(bottom = 8.dp) ) { - Text(text = date) + Text( + text = SimpleDateFormat( + "dd MMM, yyyy", + Locale.getDefault() + ).format(date) + ) Spacer(modifier = Modifier.width(8.dp)) Text(text = "Rating: ") Text(text = "$rating") From a309c175ad02a8ac620b786ed87581cd76ee44a2 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 03:52:40 +0100 Subject: [PATCH 29/63] feat: mark movies as watched and/or favorite --- .../feature/moviedetail/MovieDetailScreen.kt | 134 +++++++++++------- .../feature/moviedetail/MovieDetailUIState.kt | 5 +- .../moviedetail/MovieDetailViewModel.kt | 66 ++++++++- .../feature/profile/ProfileViewModel.kt | 5 +- 4 files changed, 152 insertions(+), 58 deletions(-) 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 index 7130d572d..6695c101a 100644 --- 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 @@ -10,17 +10,19 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items 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.Favorite +import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.Star import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -65,10 +67,51 @@ fun MovieDetailScreen( // } // } ) { padding -> - MovieDetailScreen( - modifier = Modifier.padding(padding), - uiState = uiState - ) + when (uiState) { + is MovieDetailUIState.Error -> { + ErrorMessage((uiState as MovieDetailUIState.Error).errorMessage) + } + + MovieDetailUIState.Loading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator() + } + } + + 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) + } + ) + MainActorsList(movie?.mainActors ?: emptyList()) + SupportingActorsList(movie?.supportingActors ?: emptyList()) + UserReviews( + onReviewSubmitted = { + + }, + movie?.reviews ?: emptyList() + ) + } + + } + } } } @@ -77,48 +120,17 @@ fun MovieDetailScreen( modifier: Modifier = Modifier, uiState: MovieDetailUIState ) { - when (uiState) { - is MovieDetailUIState.Error -> { - ErrorMessage(uiState.errorMessage) - } - MovieDetailUIState.Loading -> { - Box( - contentAlignment = Alignment.Center, - modifier = modifier.fillMaxSize() - ) { - CircularProgressIndicator() - } - } - - is MovieDetailUIState.Success -> { - val movie = uiState.movie - val scrollState = rememberScrollState() - Column( - modifier = Modifier.verticalScroll(scrollState) - ) { - MovieInformation( - modifier = modifier, - movie = movie - ) - MainActorsList(movie?.mainActors ?: emptyList()) - SupportingActorsList(movie?.supportingActors ?: emptyList()) - UserReviews( - onReviewSubmitted = { - - }, - movie?.reviews ?: emptyList() - ) - } - - } - } } @Composable fun MovieInformation( modifier: Modifier = Modifier, - movie: GetMovieByIdQuery.Data.Movie? + movie: GetMovieByIdQuery.Data.Movie?, + isMovieWatched: Boolean, + isMovieFavorite: Boolean, + onWatchToggled: (newValue: Boolean) -> Unit, + onFavoriteToggled: (newValue: Boolean) -> Unit ) { if (movie == null) { ErrorMessage(stringResource(R.string.error_movie_not_found)) @@ -180,18 +192,36 @@ fun MovieInformation( } Spacer(modifier = Modifier.height(8.dp)) Row { - OutlinedButton(onClick = { - - }) { - Icon(Icons.Outlined.Check, "Watched") - Text("Mark as watched", modifier = Modifier.padding(start = 4.dp)) + if (isMovieWatched) { + FilledTonalButton(onClick = { + onWatchToggled(false) + }) { + Icon(Icons.Filled.CheckCircle, "Watched") + Text("Watched", modifier = Modifier.padding(start = 4.dp)) + } + } else { + OutlinedButton(onClick = { + onWatchToggled(true) + }) { + Icon(Icons.Outlined.Check, "Watched") + Text("Mark as watched", modifier = Modifier.padding(start = 4.dp)) + } } Spacer(modifier = Modifier.width(8.dp)) - OutlinedButton(onClick = { - - }) { - Icon(Icons.Outlined.Favorite, "Favorite") - Text("Add to Favorites", modifier = Modifier.padding(start = 4.dp)) + if (isMovieFavorite) { + FilledTonalButton(onClick = { + onFavoriteToggled(false) + }) { + Icon(Icons.Filled.Favorite, "Favorite") + Text("Favorite", modifier = Modifier.padding(start = 4.dp)) + } + } else { + OutlinedButton(onClick = { + onFavoriteToggled(true) + }) { + Icon(Icons.Outlined.FavoriteBorder, "Favorite") + Text("Add to Favorites", modifier = Modifier.padding(start = 4.dp)) + } } } } 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 index 0f245e440..d1e3bf071 100644 --- 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 @@ -10,6 +10,9 @@ sealed class MovieDetailUIState { data class Success( // Movie is null if it can't be found on the DB - val movie: GetMovieByIdQuery.Data.Movie? + 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 index e36729496..5ace553be 100644 --- 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 @@ -2,15 +2,20 @@ package com.google.firebase.example.dataconnect.feature.moviedetail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +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.flow.update import kotlinx.coroutines.launch class MovieDetailViewModel( + private val firebaseAuth: FirebaseAuth = Firebase.auth, private val moviesConnector: MoviesConnector = MoviesConnector.instance ) : ViewModel() { private var movieId: String = "" @@ -23,20 +28,73 @@ class MovieDetailViewModel( movieId = id viewModelScope.launch { try { - val movie = moviesConnector.getMovieById.execute( + val user = firebaseAuth.currentUser + val movie = moviesConnector.getMovieById.execute( id = UUID.fromString(movieId) ).data.movie - _uiState.value = MovieDetailUIState.Success(movie) + + _uiState.value = if (user == null) { + MovieDetailUIState.Success(movie, isUserSignedIn = false) + } else { + val isWatched = moviesConnector.getIfWatched.execute( + id = user.uid, + movieId = UUID.fromString(movieId) + ).data.watchedMovie != null + + val isFavorite = moviesConnector.getIfFavoritedMovie.execute( + id = user.uid, + movieId = UUID.fromString(movieId) + ).data.favoriteMovie != null + + MovieDetailUIState.Success( + movie = movie, + isUserSignedIn = true, + isWatched = isWatched, + isFavorite = isFavorite + ) + } + } catch (e: Exception) { + _uiState.value = MovieDetailUIState.Error(e.message ?: "") + } + } + } + + fun toggleFavorite(newValue: Boolean) { + viewModelScope.launch { + try { + if (newValue) { + moviesConnector.addFavoritedMovie.execute(UUID.fromString(movieId)) + } else { + // TODO(thatfiredev): investigate whether this is a schema error + // userId probably shouldn't be here. + moviesConnector.deleteFavoritedMovie.execute( + userId = firebaseAuth.currentUser?.uid ?: "", + movieId = UUID.fromString(movieId) + ) + } + // Re-run the query to fetch movie + setMovieId(movieId) } catch (e: Exception) { _uiState.value = MovieDetailUIState.Error(e.message ?: "") } } } - fun addToFavorite() { + fun toggleWatched(newValue: Boolean) { viewModelScope.launch { try { - moviesConnector.addFavoritedMovie.execute(UUID.fromString(movieId)) + if (newValue) { + moviesConnector.addWatchedMovie.execute(UUID.fromString(movieId)) + } else { + // TODO(thatfiredev): investigate whether this is a schema error + // userId probably shouldn't be here. + moviesConnector.deleteWatchedMovie.execute( + userId = firebaseAuth.currentUser?.uid ?: "", + movieId = UUID.fromString(movieId) + ) + } + // Re-run the query to fetch movie + setMovieId(movieId) } catch (e: Exception) { _uiState.value = MovieDetailUIState.Error(e.message ?: "") } 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 index e24206ecd..9faccc0b7 100644 --- 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 @@ -86,7 +86,10 @@ class ProfileViewModel( val user = moviesConnector.getUserById.execute(id = userId).data.user _uiState.value = ProfileUIState.ProfileState( user?.username, - favoriteMovies = user?.favoriteMovies ?: emptyList() + favoriteMovies = user?.favoriteMovies ?: emptyList(), + watchedMovies = user?.watched ?: emptyList(), + favoriteActors = user?.favoriteActors ?: emptyList(), + reviews = user?.reviews ?: emptyList() ) Log.d("DisplayUser", "$user") } catch (e: Exception) { From 7fa780347938471d155b43e236241f44c7680271 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 18 Sep 2024 04:04:38 +0100 Subject: [PATCH 30/63] refactor: move AuthScreen into its own file --- .../dataconnect/feature/profile/AuthScreen.kt | 94 ++++++++++++++++++ .../feature/profile/ProfileScreen.kt | 99 ++----------------- .../feature/profile/ProfileUIState.kt | 2 +- .../feature/profile/ProfileViewModel.kt | 5 +- 4 files changed, 103 insertions(+), 97 deletions(-) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/AuthScreen.kt diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/AuthScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/AuthScreen.kt new file mode 100644 index 000000000..a9cf6e1df --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/AuthScreen.kt @@ -0,0 +1,94 @@ +package com.google.firebase.example.dataconnect.feature.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp + +@Composable +fun AuthScreen( + onSignUp: (email: String, password: String, displayName: String) -> Unit, + onSignIn: (email: String, password: String) -> Unit, +) { + var isSignUp by remember { mutableStateOf(false) } + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf("") } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") } + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation() + ) + Spacer(modifier = Modifier.height(8.dp)) + if (isSignUp) { + OutlinedTextField( + value = displayName, + onValueChange = { displayName = it }, + label = { Text("Name") } + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { + if (isSignUp) { + onSignUp(email, password, displayName) + } else { + onSignIn(email, password) + } + }) { + Text( + text = if (isSignUp) { + "Sign up" + } else { + "Sign in" + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = if (isSignUp) { + "Already have an account?" + } else { + "Don't have an account?" + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + isSignUp = !isSignUp + }) { + Text( + text = if (isSignUp) { + "Sign in" + } else { + "Sign up" + } + ) + } + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt index e6059823e..5927fa498 100644 --- 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 @@ -1,32 +1,24 @@ package com.google.firebase.example.dataconnect.feature.profile -import androidx.compose.foundation.layout.Arrangement 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField 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.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 import androidx.lifecycle.viewmodel.compose.viewModel import com.google.firebase.dataconnect.movies.GetUserByIdQuery @@ -44,7 +36,7 @@ fun ProfileScreen( Text((uiState as ProfileUIState.Error).errorMessage) } - is ProfileUIState.SignUpState -> { + is ProfileUIState.AuthState -> { AuthScreen( onSignUp = { email, password, displayName -> profileViewModel.signUp(email, password, displayName) @@ -56,13 +48,13 @@ fun ProfileScreen( } is ProfileUIState.ProfileState -> { - val uiState = uiState as ProfileUIState.ProfileState + val ui = uiState as ProfileUIState.ProfileState ProfileScreen( - uiState.username ?: "User", - uiState.reviews, - uiState.watchedMovies, - uiState.favoriteMovies, - uiState.favoriteActors, + ui.username ?: "User", + ui.reviews, + ui.watchedMovies, + ui.favoriteMovies, + ui.favoriteActors, onSignOut = { profileViewModel.signOut() } @@ -200,81 +192,4 @@ fun FavoriteActorsList(actors: List<GetUserByIdQuery.Data.User.FavoriteActorsIte } } -@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(8.dp)) -// Button(onClick = { /* Handle Google Sign-in */ }) { -// Text("Sign in with Google") -// } - 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" - } - ) - } - } -} 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 index df60528fb..a86ce34b7 100644 --- 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 @@ -7,7 +7,7 @@ sealed class ProfileUIState { data class Error(val errorMessage: String): ProfileUIState() - data object SignUpState: ProfileUIState() + data object AuthState: ProfileUIState() data class ProfileState( val username: String?, 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 index 9faccc0b7..1fe8c75e4 100644 --- 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 @@ -3,17 +3,14 @@ package com.google.firebase.example.dataconnect.feature.profile import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.compose.viewModel 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.auth.userProfileChangeRequest import com.google.firebase.dataconnect.movies.MoviesConnector import com.google.firebase.dataconnect.movies.execute import com.google.firebase.dataconnect.movies.instance -import com.google.firebase.example.dataconnect.feature.moviedetail.MovieDetailUIState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -36,7 +33,7 @@ class ProfileViewModel( if (currentUser != null) { displayUser(currentUser.uid) } else { - _uiState.value = ProfileUIState.SignUpState + _uiState.value = ProfileUIState.AuthState } } } From 609e5040ba3f5e8ea85932067fde3f9fa93e2104 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Thu, 19 Sep 2024 16:52:07 +0100 Subject: [PATCH 31/63] docs: add a README file --- dataconnect/README.md | 74 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 dataconnect/README.md diff --git a/dataconnect/README.md b/dataconnect/README.md new file mode 100644 index 000000000..53057a5d7 --- /dev/null +++ b/dataconnect/README.md @@ -0,0 +1,74 @@ +# Firebase Data Connect Quickstart + +## Introduction + +This quickstart is a movie review app to demonstrate the use of Firebase Data Connect + with a Cloud SQL database. +For more information about Firebase Data Connect visit [the docs](https://firebase.google.com/docs/data-connect/). + +## Getting Started + +Follow these steps to get up and running with Firebase Data Connect. For more detailed instructions, +check out the [official documentation](https://firebase.google.com/docs/data-connect/quickstart). + +### 1. Create a New Data Connect Service and Cloud SQL Instance + +1. Open [Firebase Data Connect](https://console.firebase.google.com/u/0/project/_/dataconnect) in + your project in Firebase Console and select Get Started. +2. Create a new Data Connect service and a Cloud SQL instance. Ensure the Blaze plan is active. + Pricing details can be found at [Firebase Pricing](https://firebase.google.com/pricing). +3. Select your server region, if you wish to use vector search, make sure to select `us-central1` region. +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). + +### 2. Set Up Firebase CLI + +Ensure the Firebase CLI is installed and up to date: + +```bash +npm install -g firebase-tools +``` + +### 3. Cloning the repository +This repository contains the quickstart to get started with the functionalities of Data Connect. + +1. Clone this repository to your local machine. +1. (Private Preview only) Checkout the `fdc-quickstart` branch and open the project in Android Studio. +1. Open the a terminal window and initialize your Firebase project with `firebase init dataconnect`. +1. Overwrite only dataconnect.yaml when prompted, do not overwrite any other dataconnect files. + (Optional): If you intend on using other Firebase features, run `firebase init` instead, and select both DataConnect options as well as any feature you intend to use. +1. Allow domains for Firebase Auth in your [project console](https://console.firebase.google.com/project/_/authentication/settings) (e.g. http://127.0.0.1). + +### 4. Running queries and mutations in VS Code +The VSCode Firebase Extension allows you to generate Firebase Data Connect SDK code, run queries/mutations, and deploy Firebase Data Connect with a click. Alternatively, see below for CLI commands. + +1. Install [VS Code](https://code.visualstudio.com/). +2. Download the [Firebase extension](https://firebasestorage.googleapis.com/v0/b/firemat-preview-drop/o/vsix%2Ffirebase-vscode-latest.vsix?alt=media) and [install](https://code.visualstudio.com/docs/editor/extension-marketplace#_install-an-extension) it. +3. Open this quickstart in VS code, and in the left pane of the Firebase extension, and log in with your Firebase account. + (Optional): If your Firebase project was not initialized in the last section, you can click `Run firebase init` and select `Data Connect` to initialize. +4. Click on deploy to deploy your schema to your cloud SQL instance. Or run `firebase deploy --only dataconnect` (this will also activate vectors search if it's enabled in the schema). +5. Running the VSCode extension should automatically start the DataConnect emulators. If you see an emulators error, try running `firebase emulators:start dataconnect` manually. + +Now you should be able to deploy your schema, run mutations/queries, generate SDK code, and view your application locally. + +### 5. Populating the database +1. Run `1_movie_insert.gql`, `2_actor_insert.gql`, `3_movie_actor_insert.gql`, and `4_user_favorites_review_insert.gql` files in the `./dataconnect` directory in order using the VS code extension, + +### 6. Running the app + +Press the Run button in Android Studio to run the sample app on your device. + +## 🚧 Work in Progress + +This app is still missing some features which will be added before Public Preview: + +- [ ] Search +- [ ] Movie review + - [ ] Add a new review + - [ ] Update a review + - [ ] Delete a review +- [ ] Actors + - [ ] Show actor profile + - [ ] Mark actor as favorite +- [ ] Error handling + - (Some errors may cause the app to crash at the moment) From 30a904b7b66c4e06d9e10e3cbd321c1cd76bc984 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Thu, 19 Sep 2024 17:58:14 +0100 Subject: [PATCH 32/63] feat: support reviewing movies :) --- dataconnect/README.md | 2 +- .../feature/moviedetail/MovieDetailScreen.kt | 45 +++++++++---------- .../moviedetail/MovieDetailViewModel.kt | 20 +++++++++ 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/dataconnect/README.md b/dataconnect/README.md index 53057a5d7..d7cf5dd44 100644 --- a/dataconnect/README.md +++ b/dataconnect/README.md @@ -64,7 +64,7 @@ This app is still missing some features which will be added before Public Previe - [ ] Search - [ ] Movie review - - [ ] Add a new review + - [x] Add a new review - [ ] Update a review - [ ] Delete a review - [ ] Actors 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 index 6695c101a..f502030e1 100644 --- 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 @@ -27,12 +27,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton 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 @@ -55,18 +57,7 @@ fun MovieDetailScreen( ) { movieDetailViewModel.setMovieId(movieId) val uiState by movieDetailViewModel.uiState.collectAsState() - // TODO: Create a movie favorited toggle - Scaffold( -// floatingActionButton = { -// FloatingActionButton( -// onClick = { -// movieDetailViewModel.addToFavorite() -// } -// ) { -// Icon(Icons.Filled.FavoriteBorder, "Favorite") -// } -// } - ) { padding -> + Scaffold { padding -> when (uiState) { is MovieDetailUIState.Error -> { ErrorMessage((uiState as MovieDetailUIState.Error).errorMessage) @@ -103,8 +94,8 @@ fun MovieDetailScreen( MainActorsList(movie?.mainActors ?: emptyList()) SupportingActorsList(movie?.supportingActors ?: emptyList()) UserReviews( - onReviewSubmitted = { - + onReviewSubmitted = { rating, text -> + movieDetailViewModel.addRating(rating, text) }, movie?.reviews ?: emptyList() ) @@ -115,14 +106,6 @@ fun MovieDetailScreen( } } -@Composable -fun MovieDetailScreen( - modifier: Modifier = Modifier, - uiState: MovieDetailUIState -) { - -} - @Composable fun MovieInformation( modifier: Modifier = Modifier, @@ -272,7 +255,7 @@ fun SupportingActorsList( @Composable fun UserReviews( - onReviewSubmitted: (String) -> Unit, + onReviewSubmitted: (rating: Float, text: String) -> Unit, reviews: List<GetMovieByIdQuery.Data.Movie.ReviewsItem> ) { var reviewText by remember { mutableStateOf("") } @@ -288,6 +271,15 @@ fun UserReviews( .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 }, @@ -297,7 +289,12 @@ fun UserReviews( Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { onReviewSubmitted(reviewText) }) { + Button( + onClick = { + onReviewSubmitted(rating, reviewText) + reviewText = "" + } + ) { Text("Submit Review") } } 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 index 5ace553be..929c2225e 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -100,4 +101,23 @@ class MovieDetailViewModel( } } } + + fun addRating(rating: Float, text: String) { + viewModelScope.launch { + try { + moviesConnector.addReview.execute( + movieId = UUID.fromString(movieId), + // TODO(thatfiredev): this might have been an error in the mutation definition + // rating shouldn't be an Int!! + rating = rating.roundToInt(), + reviewText = text + ) + // TODO(thatfiredev): should we have a way of only refetching the reviews? + // Re-run the query to fetch movie + setMovieId(movieId) + } catch (e: Exception) { + _uiState.value = MovieDetailUIState.Error(e.message ?: "") + } + } + } } \ No newline at end of file From 14034c6085d6b3990752c8ddb12f7098731af409 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 20 Sep 2024 01:41:07 +0100 Subject: [PATCH 33/63] feat: create actor details screen --- dataconnect/README.md | 8 +- .../example/dataconnect/MainActivity.kt | 11 +- .../feature/actordetail/ActorDetailScreen.kt | 209 ++++++++++++++++++ .../feature/actordetail/ActorDetailUIState.kt | 18 ++ .../actordetail/ActorDetailViewModel.kt | 73 ++++++ .../feature/actordetail/Navigation.kt | 26 +++ .../feature/genredetail/GenreDetailScreen.kt | 4 +- .../feature/moviedetail/MovieDetailScreen.kt | 21 +- .../feature/moviedetail/Navigation.kt | 6 +- .../feature/movies/MoviesScreen.kt | 4 +- .../feature/profile/ProfileScreen.kt | 12 +- .../dataconnect/ui/components/ActorTile.kt | 18 +- .../dataconnect/ui/components/MovieTile.kt | 22 +- .../app/src/main/res/values/strings.xml | 1 + 14 files changed, 398 insertions(+), 35 deletions(-) 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/actordetail/Navigation.kt diff --git a/dataconnect/README.md b/dataconnect/README.md index d7cf5dd44..d363b080a 100644 --- a/dataconnect/README.md +++ b/dataconnect/README.md @@ -67,8 +67,8 @@ This app is still missing some features which will be added before Public Previe - [x] Add a new review - [ ] Update a review - [ ] Delete a review -- [ ] Actors - - [ ] Show actor profile - - [ ] Mark actor as favorite +- [x] Actors + - [x] Show actor profile + - [x] Mark actor as favorite - [ ] Error handling - - (Some errors may cause the app to crash at the moment) + Some errors may cause the app to crash, especially if there's no user logged in. 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 index a0d8bdd30..6beab2c7e 100644 --- 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 @@ -27,6 +27,8 @@ import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.google.firebase.example.dataconnect.feature.actordetail.actorDetailScreen +import com.google.firebase.example.dataconnect.feature.actordetail.navigateToActorDetail import com.google.firebase.example.dataconnect.feature.genredetail.genreDetailScreen import com.google.firebase.example.dataconnect.feature.genredetail.navigateToGenreDetail import com.google.firebase.example.dataconnect.feature.genres.GENRES_ROUTE @@ -105,7 +107,14 @@ class MainActivity : ComponentActivity() { launchSingleTop = true } }) - movieDetailScreen() + movieDetailScreen( + onActorClicked = { actorId -> + navController.navigateToActorDetail(actorId) { + launchSingleTop = true + } + } + ) + actorDetailScreen() genresScreen(onGenreClicked = { genre -> navController.navigateToGenreDetail(genre) { launchSingleTop = true diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailScreen.kt new file mode 100644 index 000000000..8d429970b --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailScreen.kt @@ -0,0 +1,209 @@ +package com.google.firebase.example.dataconnect.feature.actordetail + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.lazy.LazyRow +import androidx.compose.foundation.lazy.items +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.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.GetActorByIdQuery +import com.google.firebase.example.dataconnect.R +import com.google.firebase.example.dataconnect.feature.moviedetail.ErrorMessage +import com.google.firebase.example.dataconnect.ui.components.ActorTile +import com.google.firebase.example.dataconnect.ui.components.MovieTile + +@Composable +fun ActorDetailScreen( + actorId: String, + actorDetailViewModel: ActorDetailViewModel = viewModel() +) { + actorDetailViewModel.setActorId(actorId) + val uiState by actorDetailViewModel.uiState.collectAsState() + Scaffold { innerPadding -> + when (uiState) { + is ActorDetailUIState.Error -> { + ErrorMessage((uiState as ActorDetailUIState.Error).errorMessage) + } + + ActorDetailUIState.Loading -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator() + } + } + + is ActorDetailUIState.Success -> { + val ui = uiState as ActorDetailUIState.Success + val scrollState = rememberScrollState() + Column( + modifier = Modifier.verticalScroll(scrollState) + ) { + ActorInformation( + modifier = Modifier.padding(innerPadding), + actor = ui.actor, + isActorFavorite = ui.isFavorite, + onFavoriteToggled = { + actorDetailViewModel.toggleFavorite(it) + } + ) + } + } + } + } +} + +@Composable +fun ActorInformation( + modifier: Modifier = Modifier, + actor: GetActorByIdQuery.Data.Actor?, + isActorFavorite: Boolean, + onFavoriteToggled: (newValue: Boolean) -> Unit +) { + if (actor == null) { + ErrorMessage(stringResource(R.string.error_movie_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) + ) + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = actor.biography ?: stringResource(R.string.biography_not_available), + modifier = Modifier.fillMaxWidth() + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row { + Spacer(modifier = Modifier.width(8.dp)) + if (isActorFavorite) { + FilledTonalButton(onClick = { + onFavoriteToggled(false) + }) { + Icon(Icons.Filled.Favorite, "Favorite") + Text("Favorite", modifier = Modifier.padding(start = 4.dp)) + } + } else { + OutlinedButton(onClick = { + onFavoriteToggled(true) + }) { + Icon(Icons.Outlined.FavoriteBorder, "Favorite") + Text("Add to Favorites", modifier = Modifier.padding(start = 4.dp)) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + MainRoles( + movies = actor.mainActors, + onMovieClicked = { movieId -> + // TODO(thatfiredev): Support navigating to movie + } + ) + SupportingRoles( + movies = actor.supportingActors, + onMovieClicked = { movieId -> + // TODO(thatfiredev): Support navigating to movie + } + ) + } + } +} + +@Composable +fun MainRoles( + movies: List<GetActorByIdQuery.Data.Actor.MainActorsItem?>, + onMovieClicked: (movieId: String) -> Unit +) { + Text( + text = "Main Roles", + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.height(4.dp)) + LazyRow { + items(movies) { movie -> + movie?.let { + MovieTile( + movieId = it.id.toString(), + movieTitle = it.title, + movieImageUrl = it.imageUrl, + tileWidth = 120.dp, + onMovieClicked = onMovieClicked, + ) + } + } + } +} + +@Composable +fun SupportingRoles( + movies: List<GetActorByIdQuery.Data.Actor.SupportingActorsItem?>, + onMovieClicked: (movieId: String) -> Unit +) { + Text( + text = "Supporting Roles", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(horizontal = 8.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + LazyRow( + modifier = Modifier.padding(horizontal = 8.dp) + ) { + items(movies) { movie -> + movie?.let { + MovieTile( + movieId = it.id.toString(), + movieTitle = it.title, + movieImageUrl = it.imageUrl, + tileWidth = 120.dp, + onMovieClicked = onMovieClicked, + ) + } + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailUIState.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailUIState.kt new file mode 100644 index 000000000..089ddb1b7 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailUIState.kt @@ -0,0 +1,18 @@ +package com.google.firebase.example.dataconnect.feature.actordetail + +import com.google.firebase.dataconnect.movies.GetActorByIdQuery +import com.google.firebase.dataconnect.movies.GetMovieByIdQuery + + +sealed class ActorDetailUIState { + data object Loading: ActorDetailUIState() + + data class Error(val errorMessage: String): ActorDetailUIState() + + data class Success( + // Actor is null if it can't be found on the DB + val actor: GetActorByIdQuery.Data.Actor?, + val isUserSignedIn: Boolean = false, + var isFavorite: Boolean = false + ) : ActorDetailUIState() +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailViewModel.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailViewModel.kt new file mode 100644 index 000000000..ae6877a8b --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/ActorDetailViewModel.kt @@ -0,0 +1,73 @@ +package com.google.firebase.example.dataconnect.feature.actordetail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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( + private val firebaseAuth: FirebaseAuth = Firebase.auth, + private val moviesConnector: MoviesConnector = MoviesConnector.instance +) : ViewModel() { + private var actorId: String = "" + + private val _uiState = MutableStateFlow<ActorDetailUIState>(ActorDetailUIState.Loading) + val uiState: StateFlow<ActorDetailUIState> + get() = _uiState + + fun setActorId(id: String) { + actorId = id + 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 isFavorite = moviesConnector.getIfFavoritedActor.execute( + id = user.uid, + actorId = UUID.fromString(actorId) + ).data.favoriteActor != null + + ActorDetailUIState.Success( + actor, + isUserSignedIn = true, + isFavorite = isFavorite + ) + } + } catch (e: Exception) { + _uiState.value = ActorDetailUIState.Error(e.message ?: "") + } + } + } + + fun toggleFavorite(newValue: Boolean) { + viewModelScope.launch { + try { + if (newValue) { + moviesConnector.addFavoritedActor.execute(UUID.fromString(actorId)) + } else { + moviesConnector.deleteFavoriteActor.execute( + userId = firebaseAuth.currentUser?.uid ?: "", + actorId = UUID.fromString(actorId) + ) + } + // Re-run the query to fetch the actor details + setActorId(actorId) + } 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/actordetail/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/Navigation.kt new file mode 100644 index 000000000..152c75971 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/Navigation.kt @@ -0,0 +1,26 @@ +package com.google.firebase.example.dataconnect.feature.actordetail + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptionsBuilder +import androidx.navigation.compose.composable + +const val ACTOR_DETAIL_ROUTE = "actors/{actor}" + +fun NavController.navigateToActorDetail( + actorId: String, + navOptions: NavOptionsBuilder.() -> Unit = { } +) = navigate(ACTOR_DETAIL_ROUTE.replace("{actor}", actorId), navOptions) + +fun NavGraphBuilder.actorDetailScreen() { + composable( + route = ACTOR_DETAIL_ROUTE + ) { navBackStackEntry -> + navBackStackEntry.arguments?.let { + val actorId = it.getString("actor") + actorId?.let { id -> + ActorDetailScreen(id) + } + } + } +} \ 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 index 22a4c8f4e..69e2010d5 100644 --- 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 @@ -74,7 +74,7 @@ fun GenreDetailScreen( movieId = movie.id.toString(), movieTitle = movie.title, movieImageUrl = movie.imageUrl, - movieRating = movie.rating ?: 0.0, + movieRating = movie.rating, onMovieClicked = { // TODO } @@ -93,7 +93,7 @@ fun GenreDetailScreen( movieId = movie.id.toString(), movieTitle = movie.title, movieImageUrl = movie.imageUrl, - movieRating = movie.rating ?: 0.0, + movieRating = movie.rating, onMovieClicked = { // TODO(thatfiredev) } 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 index f502030e1..8513b6f93 100644 --- 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 @@ -53,6 +53,7 @@ import com.google.firebase.example.dataconnect.ui.components.ReviewCard @Composable fun MovieDetailScreen( movieId: String, + onActorClicked: (actorId: String) -> Unit, movieDetailViewModel: MovieDetailViewModel = viewModel() ) { movieDetailViewModel.setMovieId(movieId) @@ -91,8 +92,14 @@ fun MovieDetailScreen( movieDetailViewModel.toggleWatched(newValue) } ) - MainActorsList(movie?.mainActors ?: emptyList()) - SupportingActorsList(movie?.supportingActors ?: emptyList()) + MainActorsList( + movie?.mainActors ?: emptyList(), + onActorClicked + ) + SupportingActorsList( + movie?.supportingActors ?: emptyList(), + onActorClicked + ) UserReviews( onReviewSubmitted = { rating, text -> movieDetailViewModel.addRating(rating, text) @@ -213,7 +220,8 @@ fun MovieInformation( @Composable fun MainActorsList( - actors: List<GetMovieByIdQuery.Data.Movie.MainActorsItem?> + actors: List<GetMovieByIdQuery.Data.Movie.MainActorsItem?>, + onActorClicked: (actorId: String) -> Unit ) { Text( text = "Main Actors", @@ -226,7 +234,7 @@ fun MainActorsList( ) { items(actors) { actor -> actor?.let { - ActorTile(it.name, it.imageUrl) + ActorTile(it.id.toString(), it.name, it.imageUrl, onActorClicked) } } } @@ -234,7 +242,8 @@ fun MainActorsList( @Composable fun SupportingActorsList( - actors: List<GetMovieByIdQuery.Data.Movie.SupportingActorsItem?> + actors: List<GetMovieByIdQuery.Data.Movie.SupportingActorsItem?>, + onActorClicked: (actorId: String) -> Unit, ) { Text( text = "Supporting Actors", @@ -247,7 +256,7 @@ fun SupportingActorsList( ) { items(actors) { actor -> actor?.let { - ActorTile(it.name, it.imageUrl) + ActorTile(it.id.toString(), it.name, it.imageUrl, onActorClicked) } } } diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt index b80048200..f1f9075b6 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt @@ -12,14 +12,16 @@ fun NavController.navigateToMovieDetail( navOptions: NavOptionsBuilder.() -> Unit = { } ) = navigate(MOVIE_DETAIL_ROUTE.replace("{movie}", movieId), navOptions) -fun NavGraphBuilder.movieDetailScreen() { +fun NavGraphBuilder.movieDetailScreen( + onActorClicked: (actorId: String) -> Unit +) { composable( route = MOVIE_DETAIL_ROUTE ) { backStackEntry -> backStackEntry.arguments?.let { val movieId = it.getString("movie") movieId?.let { id -> - MovieDetailScreen(id) + MovieDetailScreen(id, onActorClicked) } } 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 index 3fc75dce5..9f8430815 100644 --- 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 @@ -70,7 +70,7 @@ fun MoviesScreen( movieId = movie.id.toString(), movieTitle = movie.title, movieImageUrl = movie.imageUrl, - movieRating = movie.rating ?: 0.0, + movieRating = movie.rating, onMovieClicked = { onMovieClicked(movie.id.toString()) } @@ -89,7 +89,7 @@ fun MoviesScreen( movieId = movie.id.toString(), movieTitle = movie.title, movieImageUrl = movie.imageUrl, - movieRating = movie.rating ?: 0.0, + movieRating = movie.rating, onMovieClicked = { onMovieClicked(movie.id.toString()) } 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 index 5927fa498..8582bb096 100644 --- 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 @@ -154,7 +154,8 @@ fun WatchedMoviesList(watchedItems: List<GetUserByIdQuery.Data.User.WatchedItem> movieId = watchedItem.movie.id.toString(), movieImageUrl = watchedItem.movie.imageUrl, movieTitle = watchedItem.movie.title, - movieRating = watchedItem.movie.rating ?: 0.0, + movieRating = watchedItem.movie.rating, + tileWidth = 120.dp, onMovieClicked = { // TODO } @@ -171,7 +172,8 @@ fun FavoriteMoviesList(favoriteItems: List<GetUserByIdQuery.Data.User.FavoriteMo movieId = favoriteItem.movie.id.toString(), movieImageUrl = favoriteItem.movie.imageUrl, movieTitle = favoriteItem.movie.title, - movieRating = favoriteItem.movie.rating ?: 0.0, + movieRating = favoriteItem.movie.rating, + tileWidth = 120.dp, onMovieClicked = { // TODO } @@ -185,8 +187,12 @@ fun FavoriteActorsList(actors: List<GetUserByIdQuery.Data.User.FavoriteActorsIte LazyRow { items(actors) { favoriteActor -> ActorTile( + actorId = favoriteActor.actor.id.toString(), actorName = favoriteActor.actor.name, - actorImageUrl = favoriteActor.actor.imageUrl + actorImageUrl = favoriteActor.actor.imageUrl, + onActorClicked = { + // TODO + } ) } } diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt index 3a972ae69..d6d135100 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt @@ -1,6 +1,7 @@ package com.google.firebase.example.dataconnect.ui.components import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -28,15 +29,22 @@ val ACTOR_CARD_SIZE = 80.dp @Composable fun ActorTile( + actorId: String, actorName: String, - actorImageUrl: String + actorImageUrl: String, + onActorClicked: (actorId: String) -> Unit ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.sizeIn( - maxWidth = ACTOR_CARD_SIZE, - maxHeight = ACTOR_CARD_SIZE + 32.dp - ).padding(4.dp) + modifier = Modifier + .sizeIn( + maxWidth = ACTOR_CARD_SIZE, + maxHeight = ACTOR_CARD_SIZE + 32.dp + ) + .padding(4.dp) + .clickable { + onActorClicked(actorId) + } ) { AsyncImage( model = actorImageUrl, diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt index 70f7ecba7..3939d3a92 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt @@ -17,24 +17,24 @@ 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 -val MAX_MOVIE_CARD_WIDTH = 150.dp - @Composable fun MovieTile( modifier: Modifier = Modifier, + tileWidth: Dp = 150.dp, movieId: String, movieImageUrl: String, movieTitle: String, - movieRating: Double, + movieRating: Double? = null, onMovieClicked: (movieId: String) -> Unit ) { Card( modifier = modifier .padding(vertical = 16.dp, horizontal = 4.dp) - .sizeIn(maxWidth = MAX_MOVIE_CARD_WIDTH) + .sizeIn(maxWidth = tileWidth) .clickable { onMovieClicked(movieId) }, @@ -48,14 +48,16 @@ fun MovieTile( Text( text = movieTitle, style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp), + modifier = Modifier.padding(8.dp), maxLines = 1, overflow = TextOverflow.Ellipsis ) - Text( - text = "Rating: $movieRating", - modifier = Modifier.padding(bottom = 8.dp, start = 8.dp, end = 8.dp), - style = MaterialTheme.typography.bodySmall - ) + movieRating?.let { + Text( + text = "Rating: $movieRating", + 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/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml index e173c48a7..303332967 100644 --- a/dataconnect/app/src/main/res/values/strings.xml +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -19,4 +19,5 @@ <!-- Movie Detail Screen --> <string name="error_movie_not_found">Couldnt find movie in the database</string> <string name="description_not_available">Description not available</string> + <string name="biography_not_available">Biography not available</string> </resources> \ No newline at end of file From dff9533e34ebbd991cdb4831fd3d013457a8ea16 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 20 Sep 2024 02:33:57 +0100 Subject: [PATCH 34/63] refactor: make actor list reusable --- .../feature/actordetail/ActorDetailScreen.kt | 1 - .../feature/moviedetail/MovieDetailScreen.kt | 73 +++++-------------- .../feature/moviedetail/Navigation.kt | 1 - .../feature/profile/ProfileScreen.kt | 33 ++++----- .../{ActorTile.kt => ActorsList.kt} | 58 +++++++++++---- .../app/src/main/res/values/strings.xml | 18 +++++ 6 files changed, 94 insertions(+), 90 deletions(-) rename dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/{ActorTile.kt => ActorsList.kt} (59%) 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 index 8d429970b..4f23f8b1e 100644 --- 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 @@ -37,7 +37,6 @@ import coil.compose.AsyncImage import com.google.firebase.dataconnect.movies.GetActorByIdQuery import com.google.firebase.example.dataconnect.R import com.google.firebase.example.dataconnect.feature.moviedetail.ErrorMessage -import com.google.firebase.example.dataconnect.ui.components.ActorTile import com.google.firebase.example.dataconnect.ui.components.MovieTile @Composable 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 index 8513b6f93..9ae2d55f0 100644 --- 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 @@ -10,8 +10,6 @@ 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.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -47,7 +45,8 @@ 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.ActorTile +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.ReviewCard @Composable @@ -92,13 +91,21 @@ fun MovieDetailScreen( movieDetailViewModel.toggleWatched(newValue) } ) - MainActorsList( - movie?.mainActors ?: emptyList(), - onActorClicked + // Main Actors list + ActorsList( + listTitle = stringResource(R.string.title_main_actors), + actors = movie?.mainActors?.mapNotNull { + Actor(it.id.toString(), it.name, it.imageUrl) + }.orEmpty(), + onActorClicked = { onActorClicked(it) } ) - SupportingActorsList( - movie?.supportingActors ?: emptyList(), - onActorClicked + // Supporting Actors list + ActorsList( + listTitle = stringResource(R.string.title_supporting_actors), + actors = movie?.supportingActors?.mapNotNull { + Actor(it.id.toString(), it.name, it.imageUrl) + }.orEmpty(), + onActorClicked = { onActorClicked(it) } ) UserReviews( onReviewSubmitted = { rating, text -> @@ -218,50 +225,6 @@ fun MovieInformation( } } -@Composable -fun MainActorsList( - actors: List<GetMovieByIdQuery.Data.Movie.MainActorsItem?>, - onActorClicked: (actorId: String) -> Unit -) { - Text( - text = "Main Actors", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - LazyRow( - modifier = Modifier.padding(horizontal = 16.dp) - ) { - items(actors) { actor -> - actor?.let { - ActorTile(it.id.toString(), it.name, it.imageUrl, onActorClicked) - } - } - } -} - -@Composable -fun SupportingActorsList( - actors: List<GetMovieByIdQuery.Data.Movie.SupportingActorsItem?>, - onActorClicked: (actorId: String) -> Unit, -) { - Text( - text = "Supporting Actors", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - LazyRow( - modifier = Modifier.padding(horizontal = 16.dp) - ) { - items(actors) { actor -> - actor?.let { - ActorTile(it.id.toString(), it.name, it.imageUrl, onActorClicked) - } - } - } -} - @Composable fun UserReviews( onReviewSubmitted: (rating: Float, text: String) -> Unit, @@ -292,7 +255,7 @@ fun UserReviews( TextField( value = reviewText, onValueChange = { reviewText = it }, - label = { Text("Write your review") }, + label = { Text(stringResource(R.string.hint_write_review)) }, modifier = Modifier.fillMaxWidth() ) @@ -304,7 +267,7 @@ fun UserReviews( reviewText = "" } ) { - Text("Submit Review") + Text(stringResource(R.string.button_submit_review)) } } Column { diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt index f1f9075b6..ecef5023d 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt @@ -23,7 +23,6 @@ fun NavGraphBuilder.movieDetailScreen( movieId?.let { id -> MovieDetailScreen(id, onActorClicked) } - } } } 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 index 8582bb096..c9cf11c9f 100644 --- 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 @@ -19,10 +19,13 @@ 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.ui.components.ActorTile +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.MovieTile import com.google.firebase.example.dataconnect.ui.components.ReviewCard @@ -108,7 +111,15 @@ fun ProfileScreen( ProfileSection(title = "Favorite Movies", content = { FavoriteMoviesList(favoriteMovies) }) Spacer(modifier = Modifier.height(16.dp)) - ProfileSection(title = "Favorite Actors", content = { FavoriteActorsList(favoriteActors) }) + ActorsList( + listTitle = stringResource(R.string.title_favorite_actors), + actors = favoriteActors.mapNotNull { + Actor(it.actor.id.toString(), it.actor.name, it.actor.imageUrl) + }, + onActorClicked = { + // TODO + } + ) ProfileSection(title = "Reviews", content = { ReviewsList(name, reviews) }) Spacer(modifier = Modifier.height(16.dp)) @@ -181,21 +192,3 @@ fun FavoriteMoviesList(favoriteItems: List<GetUserByIdQuery.Data.User.FavoriteMo } } } - -@Composable -fun FavoriteActorsList(actors: List<GetUserByIdQuery.Data.User.FavoriteActorsItem>) { - LazyRow { - items(actors) { favoriteActor -> - ActorTile( - actorId = favoriteActor.actor.id.toString(), - actorName = favoriteActor.actor.name, - actorImageUrl = favoriteActor.actor.imageUrl, - onActorClicked = { - // TODO - } - ) - } - } -} - - diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorsList.kt similarity index 59% rename from dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt rename to dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorsList.kt index d6d135100..6b181694d 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorTile.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ActorsList.kt @@ -1,16 +1,14 @@ package com.google.firebase.example.dataconnect.ui.components -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -20,18 +18,52 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage -import coil.compose.rememberAsyncImagePainter val ACTOR_CARD_SIZE = 80.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<Actor>? = emptyList(), + onActorClicked: (actorId: String) -> Unit +) { + Column( + modifier = modifier.padding(horizontal = 16.dp) + ) { + 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( - actorId: String, - actorName: String, - actorImageUrl: String, + actor: Actor, onActorClicked: (actorId: String) -> Unit ) { Column( @@ -43,11 +75,11 @@ fun ActorTile( ) .padding(4.dp) .clickable { - onActorClicked(actorId) + onActorClicked(actor.id) } ) { AsyncImage( - model = actorImageUrl, + model = actor.imageUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -55,7 +87,7 @@ fun ActorTile( .clip(CircleShape) ) Text( - text = actorName, + text = actor.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis diff --git a/dataconnect/app/src/main/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml index 303332967..b2e46db97 100644 --- a/dataconnect/app/src/main/res/values/strings.xml +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -19,5 +19,23 @@ <!-- Movie Detail Screen --> <string name="error_movie_not_found">Couldnt find movie in the database</string> <string name="description_not_available">Description not available</string> + <string name="button_mark_watched">Mark as watched</string> + <string name="button_unmark_watched">Watched</string> + <string name="button_favorite">Add to favorites</string> + <string name="button_remove_favorite">Favorite</string> + <string name="title_main_actors">Main Actors</string> + <string name="title_supporting_actors">Supporting Actors</string> + <string name="title_user_reviews">User Reviews</string> + <string name="hint_write_review">Write your review</string> + <string name="button_submit_review">Submit Review</string> + + <!-- Actor Detail Screen --> <string name="biography_not_available">Biography not available</string> + + <!-- Profile Screen --> + <string name="title_watched_movies">Watched Movies</string> + <string name="title_favorite_movies">Favorite Movies</string> + <string name="title_favorite_actors">Favorite Actors</string> + <string name="title_reviews">Reviews</string> + </resources> \ No newline at end of file From 155d7a5b068ca51a9ecb311474ecf9ba40de7d7d Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 20 Sep 2024 02:57:19 +0100 Subject: [PATCH 35/63] refactor: make movies list reusable --- .../feature/actordetail/ActorDetailScreen.kt | 72 ++++--------------- .../feature/genredetail/GenreDetailScreen.kt | 56 +++++---------- .../feature/movies/MoviesScreen.kt | 63 +++++----------- .../feature/profile/ProfileScreen.kt | 62 ++++++---------- .../{MovieTile.kt => MoviesList.kt} | 68 ++++++++++++++---- .../app/src/main/res/values/strings.xml | 2 + 6 files changed, 123 insertions(+), 200 deletions(-) rename dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/{MovieTile.kt => MoviesList.kt} (53%) 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 index 4f23f8b1e..c6166ee1f 100644 --- 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 @@ -10,8 +10,6 @@ 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.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -37,7 +35,8 @@ import coil.compose.AsyncImage import com.google.firebase.dataconnect.movies.GetActorByIdQuery import com.google.firebase.example.dataconnect.R import com.google.firebase.example.dataconnect.feature.moviedetail.ErrorMessage -import com.google.firebase.example.dataconnect.ui.components.MovieTile +import com.google.firebase.example.dataconnect.ui.components.Movie +import com.google.firebase.example.dataconnect.ui.components.MoviesList @Composable fun ActorDetailScreen( @@ -138,14 +137,20 @@ fun ActorInformation( } } Spacer(modifier = Modifier.height(8.dp)) - MainRoles( - movies = actor.mainActors, + MoviesList( + listTitle = stringResource(R.string.title_main_roles), + movies = actor.mainActors.mapNotNull { + Movie(it.id.toString(), it.imageUrl, it.title) + }, onMovieClicked = { movieId -> // TODO(thatfiredev): Support navigating to movie } ) - SupportingRoles( - movies = actor.supportingActors, + MoviesList( + listTitle = stringResource(R.string.title_supporting_actors), + movies = actor.supportingActors.mapNotNull { + Movie(it.id.toString(), it.imageUrl, it.title) + }, onMovieClicked = { movieId -> // TODO(thatfiredev): Support navigating to movie } @@ -153,56 +158,3 @@ fun ActorInformation( } } } - -@Composable -fun MainRoles( - movies: List<GetActorByIdQuery.Data.Actor.MainActorsItem?>, - onMovieClicked: (movieId: String) -> Unit -) { - Text( - text = "Main Roles", - style = MaterialTheme.typography.headlineSmall - ) - Spacer(modifier = Modifier.height(4.dp)) - LazyRow { - items(movies) { movie -> - movie?.let { - MovieTile( - movieId = it.id.toString(), - movieTitle = it.title, - movieImageUrl = it.imageUrl, - tileWidth = 120.dp, - onMovieClicked = onMovieClicked, - ) - } - } - } -} - -@Composable -fun SupportingRoles( - movies: List<GetActorByIdQuery.Data.Actor.SupportingActorsItem?>, - onMovieClicked: (movieId: String) -> Unit -) { - Text( - text = "Supporting Roles", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(horizontal = 8.dp) - ) - Spacer(modifier = Modifier.height(4.dp)) - LazyRow( - modifier = Modifier.padding(horizontal = 8.dp) - ) { - items(movies) { movie -> - movie?.let { - MovieTile( - movieId = it.id.toString(), - movieTitle = it.title, - movieImageUrl = it.imageUrl, - tileWidth = 120.dp, - onMovieClicked = onMovieClicked, - ) - } - } - } -} 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 index 69e2010d5..9af1540d7 100644 --- 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 @@ -1,13 +1,9 @@ package com.google.firebase.example.dataconnect.feature.genredetail -import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator @@ -22,7 +18,8 @@ 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.MovieTile +import com.google.firebase.example.dataconnect.ui.components.Movie +import com.google.firebase.example.dataconnect.ui.components.MoviesList @Composable fun GenreDetailScreen( @@ -63,43 +60,24 @@ fun GenreDetailScreen( text = stringResource(R.string.title_genre_detail, uiState.genreName), style = MaterialTheme.typography.headlineLarge ) - Text( - text = stringResource(R.string.title_most_popular), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(vertical = 16.dp) - ) - LazyRow { - items(uiState.mostPopular) { movie -> - MovieTile( - movieId = movie.id.toString(), - movieTitle = movie.title, - movieImageUrl = movie.imageUrl, - movieRating = movie.rating, - onMovieClicked = { - // TODO - } - ) + 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 } - } - - Text( - text = stringResource(R.string.title_most_recent), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(vertical = 16.dp) ) - LazyRow { - items(uiState.mostRecent) { movie -> - MovieTile( - movieId = movie.id.toString(), - movieTitle = movie.title, - movieImageUrl = movie.imageUrl, - movieRating = movie.rating, - onMovieClicked = { - // TODO(thatfiredev) - } - ) + 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/movies/MoviesScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/MoviesScreen.kt index 9f8430815..ade279b1a 100644 --- 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 @@ -1,31 +1,26 @@ package com.google.firebase.example.dataconnect.feature.movies -import androidx.compose.foundation.clickable 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.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator -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.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.example.dataconnect.R -import com.google.firebase.example.dataconnect.ui.components.MovieTile +import com.google.firebase.example.dataconnect.ui.components.Movie +import com.google.firebase.example.dataconnect.ui.components.MoviesList @Composable fun MoviesScreen( @@ -56,46 +51,24 @@ fun MoviesScreen( is MoviesUIState.Success -> { val scrollState = rememberScrollState() Column( - modifier = Modifier.padding(16.dp) + modifier = Modifier .verticalScroll(scrollState) ) { - Text( - text = stringResource(R.string.title_top_10_movies), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(bottom = 16.dp) + 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 ) - LazyRow { - items(uiState.top10movies) { movie -> - MovieTile( - movieId = movie.id.toString(), - movieTitle = movie.title, - movieImageUrl = movie.imageUrl, - movieRating = movie.rating, - onMovieClicked = { - onMovieClicked(movie.id.toString()) - } - ) - } - } - - Text( - text = stringResource(R.string.title_latest_movies), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(vertical = 16.dp) + 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 ) - LazyRow { - items(uiState.latestMovies) { movie -> - MovieTile( - movieId = movie.id.toString(), - movieTitle = movie.title, - movieImageUrl = movie.imageUrl, - movieRating = movie.rating, - onMovieClicked = { - onMovieClicked(movie.id.toString()) - } - ) - } - } } } } 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 index c9cf11c9f..ebdc46ec7 100644 --- 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 @@ -6,8 +6,6 @@ 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.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator @@ -26,7 +24,8 @@ 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.MovieTile +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 @Composable @@ -105,10 +104,26 @@ fun ProfileScreen( } Spacer(modifier = Modifier.height(16.dp)) - ProfileSection(title = "Watched Movies", content = { WatchedMoviesList(watchedMovies) }) + 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)) - ProfileSection(title = "Favorite Movies", content = { FavoriteMoviesList(favoriteMovies) }) + 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( @@ -120,6 +135,7 @@ fun ProfileScreen( // TODO } ) + Spacer(modifier = Modifier.height(16.dp)) ProfileSection(title = "Reviews", content = { ReviewsList(name, reviews) }) Spacer(modifier = Modifier.height(16.dp)) @@ -156,39 +172,3 @@ fun ReviewsList( } } } - -@Composable -fun WatchedMoviesList(watchedItems: List<GetUserByIdQuery.Data.User.WatchedItem>) { - LazyRow { - items(watchedItems) { watchedItem -> - MovieTile( - movieId = watchedItem.movie.id.toString(), - movieImageUrl = watchedItem.movie.imageUrl, - movieTitle = watchedItem.movie.title, - movieRating = watchedItem.movie.rating, - tileWidth = 120.dp, - onMovieClicked = { - // TODO - } - ) - } - } -} - -@Composable -fun FavoriteMoviesList(favoriteItems: List<GetUserByIdQuery.Data.User.FavoriteMoviesItem>) { - LazyRow { - items(favoriteItems) { favoriteItem -> - MovieTile( - movieId = favoriteItem.movie.id.toString(), - movieImageUrl = favoriteItem.movie.imageUrl, - movieTitle = favoriteItem.movie.title, - movieRating = favoriteItem.movie.rating, - tileWidth = 120.dp, - onMovieClicked = { - // TODO - } - ) - } - } -} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MoviesList.kt similarity index 53% rename from dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt rename to dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MoviesList.kt index 3939d3a92..37444b97f 100644 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MovieTile.kt +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MoviesList.kt @@ -3,13 +3,10 @@ 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.defaultMinSize -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.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 @@ -21,40 +18,81 @@ 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<Movie>? = emptyList(), + onMovieClicked: (movieId: String) -> Unit +) { + Column( + modifier = modifier.padding(horizontal = 16.dp) + ) { + 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, - movieId: String, - movieImageUrl: String, - movieTitle: String, - movieRating: Double? = null, + movie: Movie, onMovieClicked: (movieId: String) -> Unit ) { Card( modifier = modifier - .padding(vertical = 16.dp, horizontal = 4.dp) + .padding(4.dp) .sizeIn(maxWidth = tileWidth) .clickable { - onMovieClicked(movieId) + onMovieClicked(movie.id) }, ) { AsyncImage( - model = movieImageUrl, + model = movie.imageUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.aspectRatio(9f / 16f) ) Text( - text = movieTitle, + text = movie.title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp), maxLines = 1, overflow = TextOverflow.Ellipsis ) - movieRating?.let { + movie.rating?.let { Text( - text = "Rating: $movieRating", + text = "Rating: $it", modifier = Modifier.padding(bottom = 8.dp, start = 8.dp, end = 8.dp), style = MaterialTheme.typography.bodySmall ) diff --git a/dataconnect/app/src/main/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml index b2e46db97..ca19a854b 100644 --- a/dataconnect/app/src/main/res/values/strings.xml +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -31,6 +31,8 @@ <!-- Actor Detail Screen --> <string name="biography_not_available">Biography not available</string> + <string name="title_main_roles">Main Roles</string> + <string name="title_supporting_roles">Supporting Roles</string> <!-- Profile Screen --> <string name="title_watched_movies">Watched Movies</string> From b15d0ab70efdad67b09219a0ce88dc8ffd1bd2ac Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 20 Sep 2024 03:19:05 +0100 Subject: [PATCH 36/63] refactor: make error and loading reusable components --- .../feature/actordetail/ActorDetailScreen.kt | 16 +++------ .../feature/genredetail/GenreDetailScreen.kt | 23 ++++-------- .../feature/moviedetail/MovieDetailScreen.kt | 22 +++--------- .../feature/movies/MoviesScreen.kt | 21 +++-------- .../feature/profile/ProfileScreen.kt | 13 +++---- .../dataconnect/ui/components/ActorsList.kt | 2 ++ .../dataconnect/ui/components/ErrorCard.kt | 35 +++++++++++++++++++ .../ui/components/LoadingScreen.kt | 21 +++++++++++ .../dataconnect/ui/components/MoviesList.kt | 2 ++ 9 files changed, 84 insertions(+), 71 deletions(-) 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 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 index c6166ee1f..a379eaba8 100644 --- 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 @@ -34,7 +34,8 @@ 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.feature.moviedetail.ErrorMessage +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 @@ -48,17 +49,10 @@ fun ActorDetailScreen( Scaffold { innerPadding -> when (uiState) { is ActorDetailUIState.Error -> { - ErrorMessage((uiState as ActorDetailUIState.Error).errorMessage) + ErrorCard((uiState as ActorDetailUIState.Error).errorMessage) } - ActorDetailUIState.Loading -> { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - CircularProgressIndicator() - } - } + ActorDetailUIState.Loading -> LoadingScreen() is ActorDetailUIState.Success -> { val ui = uiState as ActorDetailUIState.Success @@ -88,7 +82,7 @@ fun ActorInformation( onFavoriteToggled: (newValue: Boolean) -> Unit ) { if (actor == null) { - ErrorMessage(stringResource(R.string.error_movie_not_found)) + ErrorCard(stringResource(R.string.error_movie_not_found)) } else { Column( modifier = modifier 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 index 9af1540d7..24ea7a8f7 100644 --- 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 @@ -1,23 +1,21 @@ package com.google.firebase.example.dataconnect.feature.genredetail -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator 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.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.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 @@ -36,29 +34,20 @@ fun GenreDetailScreen( uiState: GenreDetailUIState ) { when (uiState) { - GenreDetailUIState.Loading -> { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - CircularProgressIndicator() - } - } + GenreDetailUIState.Loading -> LoadingScreen() - is GenreDetailUIState.Error -> { - Text(uiState.errorMessage) - } + is GenreDetailUIState.Error -> ErrorCard(uiState.errorMessage) is GenreDetailUIState.Success -> { val scrollState = rememberScrollState() Column( modifier = Modifier - .padding(16.dp) .verticalScroll(scrollState) ) { Text( text = stringResource(R.string.title_genre_detail, uiState.genreName), - style = MaterialTheme.typography.headlineLarge + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.padding(8.dp) ) MoviesList( listTitle = stringResource(R.string.title_most_popular), 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 index 9ae2d55f0..e5e2a1f29 100644 --- 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 @@ -47,6 +47,8 @@ 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 @Composable @@ -60,17 +62,10 @@ fun MovieDetailScreen( Scaffold { padding -> when (uiState) { is MovieDetailUIState.Error -> { - ErrorMessage((uiState as MovieDetailUIState.Error).errorMessage) + ErrorCard((uiState as MovieDetailUIState.Error).errorMessage) } - MovieDetailUIState.Loading -> { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - CircularProgressIndicator() - } - } + MovieDetailUIState.Loading -> LoadingScreen() is MovieDetailUIState.Success -> { val ui = uiState as MovieDetailUIState.Success @@ -130,7 +125,7 @@ fun MovieInformation( onFavoriteToggled: (newValue: Boolean) -> Unit ) { if (movie == null) { - ErrorMessage(stringResource(R.string.error_movie_not_found)) + ErrorCard(stringResource(R.string.error_movie_not_found)) } else { Column( modifier = modifier @@ -282,10 +277,3 @@ fun UserReviews( } } } - -@Composable -fun ErrorMessage( - message: String -) { - Text(message) -} 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 index ade279b1a..60a81b92b 100644 --- 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 @@ -1,24 +1,20 @@ package com.google.firebase.example.dataconnect.feature.movies -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.Text 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.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 @@ -37,17 +33,8 @@ fun MoviesScreen( onMovieClicked: (movie: String) -> Unit ) { when (uiState) { - MoviesUIState.Loading -> { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - CircularProgressIndicator() - } - } - is MoviesUIState.Error -> { - Text(uiState.errorMessage) - } + MoviesUIState.Loading -> LoadingScreen() + is MoviesUIState.Error -> ErrorCard(uiState.errorMessage) is MoviesUIState.Success -> { val scrollState = rememberScrollState() Column( 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 index ebdc46ec7..027c471af 100644 --- 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 @@ -24,6 +24,8 @@ 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 @@ -35,7 +37,7 @@ fun ProfileScreen( val uiState by profileViewModel.uiState.collectAsState() when (uiState) { is ProfileUIState.Error -> { - Text((uiState as ProfileUIState.Error).errorMessage) + ErrorCard((uiState as ProfileUIState.Error).errorMessage) } is ProfileUIState.AuthState -> { @@ -63,14 +65,7 @@ fun ProfileScreen( ) } - ProfileUIState.Loading -> { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - CircularProgressIndicator() - } - } + ProfileUIState.Loading -> LoadingScreen() } } 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 index 6b181694d..55bd95156 100644 --- 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 @@ -3,6 +3,7 @@ package com.google.firebase.example.dataconnect.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column 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 @@ -44,6 +45,7 @@ fun ActorsList( ) { Column( modifier = modifier.padding(horizontal = 16.dp) + .fillMaxWidth() ) { Text( text = listTitle, diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ErrorCard.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ErrorCard.kt new file mode 100644 index 000000000..08cb86dea --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ErrorCard.kt @@ -0,0 +1,35 @@ +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun ErrorCard( + errorMessage: String +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.padding(16.dp) + .fillMaxWidth() + ) { + Text( + text = errorMessage, + modifier = Modifier.padding(16.dp) + .fillMaxWidth() + ) + } +} + +@Composable +@Preview +fun ErrorCardPreview() { + ErrorCard("Something went terribly wrong :(") +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/LoadingScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/LoadingScreen.kt new file mode 100644 index 000000000..df9a5a438 --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/LoadingScreen.kt @@ -0,0 +1,21 @@ +package com.google.firebase.example.dataconnect.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +/** + * A screen that displays a loading spinner in the center. + */ +@Composable +fun LoadingScreen() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + CircularProgressIndicator() + } +} \ No newline at end of file diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MoviesList.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/MoviesList.kt index 37444b97f..0be44d7ab 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -40,6 +41,7 @@ fun MoviesList( ) { Column( modifier = modifier.padding(horizontal = 16.dp) + .fillMaxWidth() ) { Text( text = listTitle, From 49a20a18afd17a9a02cd88696e21506529b9f6cf Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 20 Sep 2024 03:35:19 +0100 Subject: [PATCH 37/63] refactor: make toggle button reusable --- .../feature/actordetail/ActorDetailScreen.kt | 84 ++++++++----------- .../feature/moviedetail/MovieDetailScreen.kt | 51 ++++------- .../dataconnect/ui/components/ToggleButton.kt | 36 ++++++++ 3 files changed, 86 insertions(+), 85 deletions(-) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ToggleButton.kt 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 index a379eaba8..1d30141b1 100644 --- 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 @@ -1,11 +1,9 @@ package com.google.firebase.example.dataconnect.feature.actordetail -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.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 @@ -15,17 +13,12 @@ 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.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource @@ -38,6 +31,7 @@ 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 @Composable fun ActorDetailScreen( @@ -68,6 +62,25 @@ fun ActorDetailScreen( actorDetailViewModel.toggleFavorite(it) } ) + MoviesList( + listTitle = stringResource(R.string.title_main_roles), + movies = ui.actor?.mainActors?.mapNotNull { + Movie(it.id.toString(), it.imageUrl, it.title) + }, + onMovieClicked = { movieId -> + // TODO(thatfiredev): Support navigating to movie + } + ) + Spacer(modifier = Modifier.height(8.dp)) + MoviesList( + listTitle = stringResource(R.string.title_supporting_actors), + movies = ui.actor?.supportingActors?.mapNotNull { + Movie(it.id.toString(), it.imageUrl, it.title) + }, + onMovieClicked = { movieId -> + // TODO(thatfiredev): Support navigating to movie + } + ) } } } @@ -102,52 +115,21 @@ fun ActorInformation( .aspectRatio(9f / 16f) .padding(vertical = 8.dp) ) - Column( - modifier = Modifier.padding(horizontal = 16.dp) - ) { - Text( - text = actor.biography ?: stringResource(R.string.biography_not_available), - modifier = Modifier.fillMaxWidth() - ) - } - } - Spacer(modifier = Modifier.height(8.dp)) - Row { - Spacer(modifier = Modifier.width(8.dp)) - if (isActorFavorite) { - FilledTonalButton(onClick = { - onFavoriteToggled(false) - }) { - Icon(Icons.Filled.Favorite, "Favorite") - Text("Favorite", modifier = Modifier.padding(start = 4.dp)) - } - } else { - OutlinedButton(onClick = { - onFavoriteToggled(true) - }) { - Icon(Icons.Outlined.FavoriteBorder, "Favorite") - Text("Add to Favorites", modifier = Modifier.padding(start = 4.dp)) - } - } + Text( + text = actor.biography ?: stringResource(R.string.biography_not_available), + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + ) } Spacer(modifier = Modifier.height(8.dp)) - MoviesList( - listTitle = stringResource(R.string.title_main_roles), - movies = actor.mainActors.mapNotNull { - Movie(it.id.toString(), it.imageUrl, it.title) - }, - onMovieClicked = { movieId -> - // TODO(thatfiredev): Support navigating to movie - } - ) - MoviesList( - listTitle = stringResource(R.string.title_supporting_actors), - movies = actor.supportingActors.mapNotNull { - Movie(it.id.toString(), it.imageUrl, it.title) - }, - onMovieClicked = { movieId -> - // TODO(thatfiredev): Support navigating to movie - } + 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/moviedetail/MovieDetailScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/MovieDetailScreen.kt index e5e2a1f29..248ff07ae 100644 --- 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 @@ -1,6 +1,5 @@ package com.google.firebase.example.dataconnect.feature.moviedetail -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -19,11 +18,8 @@ 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.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.SuggestionChip @@ -50,6 +46,7 @@ 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 @Composable fun MovieDetailScreen( @@ -184,37 +181,23 @@ fun MovieInformation( } Spacer(modifier = Modifier.height(8.dp)) Row { - if (isMovieWatched) { - FilledTonalButton(onClick = { - onWatchToggled(false) - }) { - Icon(Icons.Filled.CheckCircle, "Watched") - Text("Watched", modifier = Modifier.padding(start = 4.dp)) - } - } else { - OutlinedButton(onClick = { - onWatchToggled(true) - }) { - Icon(Icons.Outlined.Check, "Watched") - Text("Mark as watched", modifier = Modifier.padding(start = 4.dp)) - } - } + 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)) - if (isMovieFavorite) { - FilledTonalButton(onClick = { - onFavoriteToggled(false) - }) { - Icon(Icons.Filled.Favorite, "Favorite") - Text("Favorite", modifier = Modifier.padding(start = 4.dp)) - } - } else { - OutlinedButton(onClick = { - onFavoriteToggled(true) - }) { - Icon(Icons.Outlined.FavoriteBorder, "Favorite") - Text("Add to Favorites", modifier = Modifier.padding(start = 4.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/ui/components/ToggleButton.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ToggleButton.kt new file mode 100644 index 000000000..0d73abb8b --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/ui/components/ToggleButton.kt @@ -0,0 +1,36 @@ +package com.google.firebase.example.dataconnect.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun ToggleButton( + iconEnabled: ImageVector, + iconDisabled: ImageVector, + textEnabled: String, + textDisabled: String, + isEnabled: Boolean, + onToggle: (newValue: Boolean) -> Unit +) { + val onClick = { + onToggle(!isEnabled) + } + if (isEnabled) { + FilledTonalButton(onClick) { + Icon(iconEnabled, textEnabled) + Text(textEnabled, modifier = Modifier.padding(horizontal = 4.dp)) + } + } else { + OutlinedButton(onClick) { + Icon(iconDisabled, textDisabled) + Text(textDisabled, modifier = Modifier.padding(horizontal = 4.dp)) + } + } +} From 8546e348da4297511afe3e719cdb0c65a0821376 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 20 Sep 2024 03:46:34 +0100 Subject: [PATCH 38/63] refactor: make actor details screen stateless --- .../feature/actordetail/ActorDetailScreen.kt | 51 +++++++++++-------- .../app/src/main/res/values/strings.xml | 3 +- 2 files changed, 33 insertions(+), 21 deletions(-) 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 index 1d30141b1..d2f46cc36 100644 --- 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 @@ -40,36 +40,48 @@ fun ActorDetailScreen( ) { actorDetailViewModel.setActorId(actorId) val uiState by actorDetailViewModel.uiState.collectAsState() - Scaffold { innerPadding -> - when (uiState) { - is ActorDetailUIState.Error -> { - ErrorCard((uiState as ActorDetailUIState.Error).errorMessage) - } + 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) - ActorDetailUIState.Loading -> LoadingScreen() + ActorDetailUIState.Loading -> LoadingScreen() - is ActorDetailUIState.Success -> { - val ui = uiState as ActorDetailUIState.Success + is ActorDetailUIState.Success -> { + val ui = uiState + Scaffold { innerPadding -> val scrollState = rememberScrollState() Column( - modifier = Modifier.verticalScroll(scrollState) + modifier = Modifier + .padding(innerPadding) + .verticalScroll(scrollState) ) { ActorInformation( - modifier = Modifier.padding(innerPadding), actor = ui.actor, isActorFavorite = ui.isFavorite, - onFavoriteToggled = { - actorDetailViewModel.toggleFavorite(it) - } + onFavoriteToggled = onFavoriteToggled ) MoviesList( listTitle = stringResource(R.string.title_main_roles), movies = ui.actor?.mainActors?.mapNotNull { Movie(it.id.toString(), it.imageUrl, it.title) }, - onMovieClicked = { movieId -> - // TODO(thatfiredev): Support navigating to movie - } + onMovieClicked = onMovieClicked ) Spacer(modifier = Modifier.height(8.dp)) MoviesList( @@ -77,12 +89,11 @@ fun ActorDetailScreen( movies = ui.actor?.supportingActors?.mapNotNull { Movie(it.id.toString(), it.imageUrl, it.title) }, - onMovieClicked = { movieId -> - // TODO(thatfiredev): Support navigating to movie - } + onMovieClicked = onMovieClicked ) } } + } } } @@ -95,7 +106,7 @@ fun ActorInformation( onFavoriteToggled: (newValue: Boolean) -> Unit ) { if (actor == null) { - ErrorCard(stringResource(R.string.error_movie_not_found)) + ErrorCard(stringResource(R.string.error_actor_not_found)) } else { Column( modifier = modifier diff --git a/dataconnect/app/src/main/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml index ca19a854b..d58d70af7 100644 --- a/dataconnect/app/src/main/res/values/strings.xml +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -17,7 +17,7 @@ <string name="title_most_recent">Most Recent</string> <!-- Movie Detail Screen --> - <string name="error_movie_not_found">Couldnt find movie in the database</string> + <string name="error_movie_not_found">Couldn\'t find movie in the database</string> <string name="description_not_available">Description not available</string> <string name="button_mark_watched">Mark as watched</string> <string name="button_unmark_watched">Watched</string> @@ -30,6 +30,7 @@ <string name="button_submit_review">Submit Review</string> <!-- Actor Detail Screen --> + <string name="error_actor_not_found">Couldn\'t find actor in the database</string> <string name="biography_not_available">Biography not available</string> <string name="title_main_roles">Main Roles</string> <string name="title_supporting_roles">Supporting Roles</string> From a45c5d4e3af3eec4989151a580f4dbc8db871bc8 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 20 Sep 2024 03:54:21 +0100 Subject: [PATCH 39/63] refactor: move UserReviews into its own dedicated file --- .../feature/moviedetail/MovieDetailScreen.kt | 64 +------------- .../feature/moviedetail/UserReviews.kt | 84 +++++++++++++++++++ .../feature/profile/ProfileScreen.kt | 8 +- .../feature/profile/ProfileUIState.kt | 8 +- .../feature/profile/ProfileViewModel.kt | 8 +- 5 files changed, 99 insertions(+), 73 deletions(-) create mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/UserReviews.kt 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 index 248ff07ae..d0b8729b9 100644 --- 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 @@ -88,7 +88,7 @@ fun MovieDetailScreen( listTitle = stringResource(R.string.title_main_actors), actors = movie?.mainActors?.mapNotNull { Actor(it.id.toString(), it.name, it.imageUrl) - }.orEmpty(), + }, onActorClicked = { onActorClicked(it) } ) // Supporting Actors list @@ -96,14 +96,14 @@ fun MovieDetailScreen( listTitle = stringResource(R.string.title_supporting_actors), actors = movie?.supportingActors?.mapNotNull { Actor(it.id.toString(), it.name, it.imageUrl) - }.orEmpty(), + }, onActorClicked = { onActorClicked(it) } ) UserReviews( onReviewSubmitted = { rating, text -> movieDetailViewModel.addRating(rating, text) }, - movie?.reviews ?: emptyList() + movie?.reviews ) } @@ -202,61 +202,3 @@ fun MovieInformation( } } } - -@Composable -fun UserReviews( - onReviewSubmitted: (rating: Float, text: String) -> Unit, - reviews: List<GetMovieByIdQuery.Data.Movie.ReviewsItem> -) { - var reviewText by remember { mutableStateOf("") } - Text( - text = "User Reviews", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - var rating by remember { mutableFloatStateOf(3f) } - Text("Rating: ${rating}") - Slider( - value = rating, - // Round the value to the nearest 0.5 - onValueChange = { rating = (Math.round(it * 2) / 2.0).toFloat() }, - steps = 9, - valueRange = 1f..5f - ) - TextField( - value = reviewText, - onValueChange = { reviewText = it }, - label = { Text(stringResource(R.string.hint_write_review)) }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Button( - onClick = { - onReviewSubmitted(rating, reviewText) - reviewText = "" - } - ) { - Text(stringResource(R.string.button_submit_review)) - } - } - Column { - // TODO(thatfiredev): Handle cases where the list is too long to display - reviews.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/moviedetail/UserReviews.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/UserReviews.kt new file mode 100644 index 000000000..0b677d48d --- /dev/null +++ b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/UserReviews.kt @@ -0,0 +1,84 @@ +package com.google.firebase.example.dataconnect.feature.moviedetail + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.google.firebase.dataconnect.movies.GetMovieByIdQuery +import com.google.firebase.example.dataconnect.R +import com.google.firebase.example.dataconnect.ui.components.ReviewCard + +@Composable +fun UserReviews( + onReviewSubmitted: (rating: Float, text: String) -> Unit, + reviews: List<GetMovieByIdQuery.Data.Movie.ReviewsItem>? = emptyList() +) { + var reviewText by remember { mutableStateOf("") } + Text( + text = "User Reviews", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + var rating by remember { mutableFloatStateOf(3f) } + Text("Rating: ${rating}") + Slider( + value = rating, + // Round the value to the nearest 0.5 + onValueChange = { rating = (Math.round(it * 2) / 2.0).toFloat() }, + steps = 9, + valueRange = 1f..5f + ) + TextField( + value = reviewText, + onValueChange = { reviewText = it }, + label = { Text(stringResource(R.string.hint_write_review)) }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + onReviewSubmitted(rating, reviewText) + reviewText = "" + } + ) { + Text(stringResource(R.string.button_submit_review)) + } + } + Column { + // TODO(thatfiredev): Handle cases where the list is too long to display + reviews.orEmpty().forEach { + ReviewCard( + userName = it.user.username, + date = it.reviewDate, + rating = it.rating?.toDouble() ?: 0.0, + text = it.reviewText ?: "" + ) + } + } +} diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/ProfileScreen.kt index 027c471af..7b7ee77d2 100644 --- 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 @@ -55,10 +55,10 @@ fun ProfileScreen( val ui = uiState as ProfileUIState.ProfileState ProfileScreen( ui.username ?: "User", - ui.reviews, - ui.watchedMovies, - ui.favoriteMovies, - ui.favoriteActors, + ui.reviews.orEmpty(), + ui.watchedMovies.orEmpty(), + ui.favoriteMovies.orEmpty(), + ui.favoriteActors.orEmpty(), onSignOut = { profileViewModel.signOut() } 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 index a86ce34b7..9977d7b77 100644 --- 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 @@ -11,9 +11,9 @@ sealed class ProfileUIState { data class ProfileState( val username: String?, - val reviews: List<GetUserByIdQuery.Data.User.ReviewsItem> = emptyList(), - val watchedMovies: List<GetUserByIdQuery.Data.User.WatchedItem> = emptyList(), - val favoriteMovies: List<GetUserByIdQuery.Data.User.FavoriteMoviesItem> = emptyList(), - val favoriteActors: List<GetUserByIdQuery.Data.User.FavoriteActorsItem> = emptyList() + val reviews: List<GetUserByIdQuery.Data.User.ReviewsItem>? = emptyList(), + val watchedMovies: List<GetUserByIdQuery.Data.User.WatchedItem>? = emptyList(), + val favoriteMovies: List<GetUserByIdQuery.Data.User.FavoriteMoviesItem>? = emptyList(), + val favoriteActors: List<GetUserByIdQuery.Data.User.FavoriteActorsItem>? = 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 index 1fe8c75e4..9430d067e 100644 --- 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 @@ -83,10 +83,10 @@ class ProfileViewModel( val user = moviesConnector.getUserById.execute(id = userId).data.user _uiState.value = ProfileUIState.ProfileState( user?.username, - favoriteMovies = user?.favoriteMovies ?: emptyList(), - watchedMovies = user?.watched ?: emptyList(), - favoriteActors = user?.favoriteActors ?: emptyList(), - reviews = user?.reviews ?: emptyList() + favoriteMovies = user?.favoriteMovies, + watchedMovies = user?.watched, + favoriteActors = user?.favoriteActors, + reviews = user?.reviews ) Log.d("DisplayUser", "$user") } catch (e: Exception) { From f0f5ce487877e67622d38ab94705119e0384a5ba Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 20 Sep 2024 04:06:08 +0100 Subject: [PATCH 40/63] refactor: turn actor tile into a card --- .../dataconnect/ui/components/ActorsList.kt | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) 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 index 55bd95156..9e783df8d 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -11,6 +12,7 @@ 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 @@ -22,7 +24,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage -val ACTOR_CARD_SIZE = 80.dp +val ACTOR_CARD_SIZE = 64.dp /** * Used to represent an actor in a list UI @@ -68,31 +70,38 @@ fun ActorTile( actor: Actor, onActorClicked: (actorId: String) -> Unit ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, + Card( modifier = Modifier - .sizeIn( - maxWidth = ACTOR_CARD_SIZE, - maxHeight = ACTOR_CARD_SIZE + 32.dp - ) - .padding(4.dp) + .padding(end = 8.dp) .clickable { onActorClicked(actor.id) } ) { - AsyncImage( - model = actor.imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .size(ACTOR_CARD_SIZE) - .clip(CircleShape) - ) - Text( - text = actor.name, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + .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, + ) + } } } From bca483a2e6c55cf2cf9f50cd79f70aeed50fecca Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Fri, 20 Sep 2024 18:14:32 +0100 Subject: [PATCH 41/63] docs: update the getting started --- dataconnect/README.md | 77 ++++++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/dataconnect/README.md b/dataconnect/README.md index d363b080a..76a1600fe 100644 --- a/dataconnect/README.md +++ b/dataconnect/README.md @@ -11,15 +11,33 @@ For more information about Firebase Data Connect visit [the docs](https://fireba Follow these steps to get up and running with Firebase Data Connect. For more detailed instructions, check out the [official documentation](https://firebase.google.com/docs/data-connect/quickstart). -### 1. Create a New Data Connect Service and Cloud SQL Instance - -1. Open [Firebase Data Connect](https://console.firebase.google.com/u/0/project/_/dataconnect) in - your project in Firebase Console and select Get Started. -2. Create a new Data Connect service and a Cloud SQL instance. Ensure the Blaze plan is active. - Pricing details can be found at [Firebase Pricing](https://firebase.google.com/pricing). -3. Select your server region, if you wish to use vector search, make sure to select `us-central1` region. -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). +### 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 {{data_connect_short}} 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 and follow the setup workflow: + - Select a location for your Cloud SQL for PostgreSQL database. + - Fill in the following fields: + - Service ID: `dataconnect` + - Cloud SQL Instance: `cloud-sql-instance` + - Database name: `postgres` +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. Download and then add the Firebase Android configuration file (`google-services.json`) to your app: + 1. Click **Download google-services.json** to obtain your Firebase Android config file. + 2. Move your config file into the module (app-level) root directory of your app. ### 2. Set Up Firebase CLI @@ -32,24 +50,29 @@ npm install -g firebase-tools ### 3. Cloning the repository This repository contains the quickstart to get started with the functionalities of Data Connect. -1. Clone this repository to your local machine. -1. (Private Preview only) Checkout the `fdc-quickstart` branch and open the project in Android Studio. -1. Open the a terminal window and initialize your Firebase project with `firebase init dataconnect`. -1. Overwrite only dataconnect.yaml when prompted, do not overwrite any other dataconnect files. - (Optional): If you intend on using other Firebase features, run `firebase init` instead, and select both DataConnect options as well as any feature you intend to use. -1. Allow domains for Firebase Auth in your [project console](https://console.firebase.google.com/project/_/authentication/settings) (e.g. http://127.0.0.1). - -### 4. Running queries and mutations in VS Code -The VSCode Firebase Extension allows you to generate Firebase Data Connect SDK code, run queries/mutations, and deploy Firebase Data Connect with a click. Alternatively, see below for CLI commands. - -1. Install [VS Code](https://code.visualstudio.com/). -2. Download the [Firebase extension](https://firebasestorage.googleapis.com/v0/b/firemat-preview-drop/o/vsix%2Ffirebase-vscode-latest.vsix?alt=media) and [install](https://code.visualstudio.com/docs/editor/extension-marketplace#_install-an-extension) it. -3. Open this quickstart in VS code, and in the left pane of the Firebase extension, and log in with your Firebase account. - (Optional): If your Firebase project was not initialized in the last section, you can click `Run firebase init` and select `Data Connect` to initialize. -4. Click on deploy to deploy your schema to your cloud SQL instance. Or run `firebase deploy --only dataconnect` (this will also activate vectors search if it's enabled in the schema). -5. Running the VSCode extension should automatically start the DataConnect emulators. If you see an emulators error, try running `firebase emulators:start dataconnect` manually. - -Now you should be able to deploy your schema, run mutations/queries, generate SDK code, and view your application locally. +1. Clone this repository to your local machine: + ```sh + git clone https://github.com/firebase/quickstart-android.git + ``` + +2. (Private Preview only) Checkout the `fdc-quickstart` branch (`git checkout fdc-quickstart`) + and open the project in Android Studio. + +### 4. Deploy the service to Firebase and generate SDKs + +1. Open the `quickstart-android/dataconnect/dataconnect` directory and deploy the schema with + the following command: + ```bash + firebase deploy + ``` +2. Once the deploy is complete, you should be able to see the movie schema in the + [Data Connect section](https://console.firebase.google.com/u/0/project/_/dataconnect) + of the Firebase console. + +3. Generate the Kotlin SDK by running: + ```bash + firebase dataconnect:sdk:generate + ``` ### 5. Populating the database 1. Run `1_movie_insert.gql`, `2_actor_insert.gql`, `3_movie_actor_insert.gql`, and `4_user_favorites_review_insert.gql` files in the `./dataconnect` directory in order using the VS code extension, From a24417ea5bfc9886ad5d711a334ce0c6c4c10fc7 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 23 Sep 2024 15:09:56 +0100 Subject: [PATCH 42/63] docs: update README.md --- dataconnect/README.md | 8 ++++---- dataconnect/dataconnect/dataconnect.yaml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dataconnect/README.md b/dataconnect/README.md index 76a1600fe..784d089c2 100644 --- a/dataconnect/README.md +++ b/dataconnect/README.md @@ -27,17 +27,17 @@ check out the [official documentation](https://firebase.google.com/docs/data-con 3. Navigate to the [Data Connect section](https://console.firebase.google.com/u/0/project/_/dataconnect) of the Firebase console and follow the setup workflow: - - Select a location for your Cloud SQL for PostgreSQL database. + - 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. - Fill in the following fields: - Service ID: `dataconnect` - - Cloud SQL Instance: `cloud-sql-instance` - - Database name: `postgres` + - Cloud SQL Instance: `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. Download and then add the Firebase Android configuration file (`google-services.json`) to your app: 1. Click **Download google-services.json** to obtain your Firebase Android config file. - 2. Move your config file into the module (app-level) root directory of your app. + 2. Move your config file into the `quickstart-android/dataconnect/app` directory. ### 2. Set Up Firebase CLI diff --git a/dataconnect/dataconnect/dataconnect.yaml b/dataconnect/dataconnect/dataconnect.yaml index a74d3b653..1eea139ad 100644 --- a/dataconnect/dataconnect/dataconnect.yaml +++ b/dataconnect/dataconnect/dataconnect.yaml @@ -5,7 +5,7 @@ schema: source: "./schema" datasource: postgresql: - database: "postgres" + database: "fdcdb" cloudSql: - instanceId: "cloud-sql-instance" + instanceId: "fdc-sql" connectorDirs: ["./connectors"] From a8b2bf5aced6ab567cb7315d55b3ff6f6d59ac4c Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 23 Sep 2024 16:08:18 +0100 Subject: [PATCH 43/63] docs: update README.md --- dataconnect/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dataconnect/README.md b/dataconnect/README.md index 784d089c2..8cc4359fd 100644 --- a/dataconnect/README.md +++ b/dataconnect/README.md @@ -21,7 +21,7 @@ check out the [official documentation](https://firebase.google.com/docs/data-con for PostgreSQL instance. > Note: Though you set up billing in your Blaze upgrade, you won't be - charged for usage of {{data_connect_short}} or the + 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. From c06b22c398d6fd933412229003cc4470d834ae44 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 30 Sep 2024 14:27:42 +0100 Subject: [PATCH 44/63] add data_seed.gql and useEmulator() --- .../example/dataconnect/MainActivity.kt | 4 + dataconnect/dataconnect/data_seed.gql | 546 ++++++++++++++++++ 2 files changed, 550 insertions(+) create mode 100644 dataconnect/dataconnect/data_seed.gql 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 index 6beab2c7e..c35bd0718 100644 --- 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 @@ -27,6 +27,8 @@ import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost 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.actorDetailScreen import com.google.firebase.example.dataconnect.feature.actordetail.navigateToActorDetail import com.google.firebase.example.dataconnect.feature.genredetail.genreDetailScreen @@ -51,6 +53,8 @@ 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() diff --git a/dataconnect/dataconnect/data_seed.gql b/dataconnect/dataconnect/data_seed.gql new file mode 100644 index 000000000..4e912b7e3 --- /dev/null +++ b/dataconnect/dataconnect/data_seed.gql @@ -0,0 +1,546 @@ +mutation { + # Insert movies + movie_insertMany(data: [ + { + id: "550e8400-e29b-41d4-a716-446655440000", + title: "Inception", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Finception.jpg?alt=media&token=07b09781-b302-4623-a5c3-1956d0143168", + releaseYear: 2010, + genre: "sci-fi", + rating: 8.8, + description: "Dom Cobb (Leonardo DiCaprio) is a thief with the rare ability to enter people's dreams and steal their secrets from their subconscious. His skill has made him a valuable player in the world of corporate espionage but has also cost him everything he loves. Cobb gets a chance at redemption when he is offered a seemingly impossible task: plant an idea in someone's mind. If he succeeds, it will be the perfect crime, but a dangerous enemy anticipates Cobb's every move.", + tags: ["thriller", "action"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440001", + title: "The Matrix", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fthe_matrix.jpg?alt=media&token=4975645d-fef8-409e-84a5-bcc1046e2059", + releaseYear: 1999, + genre: "action", + rating: 8.7, + description: "Thomas Anderson, a computer programmer, discovers that the world is actually a simulation controlled by malevolent machines in a dystopian future. Known as Neo, he joins a group of underground rebels led by Morpheus to fight the machines and free humanity. Along the way, Neo learns to manipulate the simulated reality, uncovering his true destiny.", + tags: ["sci-fi", "adventure"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440002", + title: "John Wick 4", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fjohn_wick_4.jpg?alt=media&token=463ed467-9daa-4281-965d-44e7cc4172d5", + releaseYear: 2023, + genre: "action", + rating: 8.1, + description: "John Wick (Keanu Reeves) uncovers a path to defeating The High Table, but before he can earn his freedom, he must face off against a new enemy with powerful alliances across the globe. The film follows Wick as he battles through various international locations, facing relentless adversaries and forming new alliances.", + tags: ["action", "thriller"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440003", + title: "The Dark Knight", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fthe_dark_knight.jpg?alt=media&token=a9803c59-40d5-4758-a6f4-9a7c274a1218", + releaseYear: 2008, + genre: "action", + rating: 9.0, + description: "When the menace known as the Joker (Heath Ledger) emerges from his mysterious past, he wreaks havoc and chaos on the people of Gotham. The Dark Knight (Christian Bale) must accept one of the greatest psychological and physical tests of his ability to fight injustice.", + tags: ["action", "drama"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440004", + title: "Fight Club", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Ffight_club.jpg?alt=media&token=a4bc1933-2607-42cd-a860-e44c4587fd9c", + releaseYear: 1999, + genre: "drama", + rating: 8.8, + description: "An insomniac office worker (Edward Norton) and a devil-may-care soapmaker (Brad Pitt) form an underground fight club that evolves into something much more. The story explores themes of consumerism, masculinity, and the search for identity.", + tags: ["drama", "thriller"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440005", + title: "Pulp Fiction", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fpulp_fiction.jpg?alt=media&token=0df86e18-5cb1-45b3-a6d9-3f41563c3465", + releaseYear: 1994, + genre: "crime", + rating: 8.9, + description: "The lives of two mob hitmen, a boxer, a gangster and his wife, and a pair of diner bandits intertwine in four tales of violence and redemption. The film is known for its eclectic dialogue, ironic mix of humor and violence, and a host of cinematic allusions and pop culture references.", + tags: ["crime", "drama"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440006", + title: "The Lord of the Rings: The Fellowship of the Ring", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Flotr_fellowship.jpg?alt=media&token=92641d2d-6c52-4172-bd66-95fb86b4b96b", + releaseYear: 2001, + genre: "fantasy", + rating: 8.8, + description: "A meek Hobbit from the Shire, Frodo Baggins, and eight companions set out on a journey to destroy the powerful One Ring and save Middle-earth from the Dark Lord Sauron. The epic adventure begins the quest that will test their courage and bond.", + tags: ["fantasy", "adventure"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440007", + title: "The Shawshank Redemption", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fthe_shawshanks_redemption.jpg?alt=media&token=f67b5ab2-a435-48b2-8251-5bf866b183e9", + releaseYear: 1994, + genre: "drama", + rating: 9.3, + description: "Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency. The film follows Andy Dufresne (Tim Robbins), a banker sentenced to life in Shawshank State Penitentiary, and his friendship with Red (Morgan Freeman).", + tags: ["drama", "crime"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440008", + title: "Forrest Gump", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fforrest_gump.jpeg?alt=media&token=f21e88ce-6fab-4218-aa55-94738acc9b8f", + releaseYear: 1994, + genre: "drama", + rating: 8.8, + description: "The presidencies of Kennedy and Johnson, the events of Vietnam, Watergate, and other historical moments unfold from the perspective of an Alabama man with a low IQ. Forrest Gump (Tom Hanks) becomes an unwitting participant in many key moments of 20th-century U.S. history, all while maintaining his love for his childhood sweetheart Jenny (Robin Wright).", + tags: ["drama", "romance"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440009", + title: "The Godfather", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fthe_godfather.jpg?alt=media&token=5297fd94-ae87-4995-9de5-3755232bad52", + releaseYear: 1972, + genre: "crime", + rating: 9.2, + description: "The aging patriarch of an organized crime dynasty transfers control of his clandestine empire to his reluctant son. The story follows the powerful Corleone family as they navigate power, loyalty, and betrayal.", + tags: ["crime", "drama"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440010", + title: "The Silence of the Lambs", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fthe_silence_of_the_lambs.jpg?alt=media&token=7ca6abeb-b15c-4f5e-9280-5a590e89fe54", + releaseYear: 1991, + genre: "thriller", + rating: 8.6, + description: "A young F.B.I. cadet must receive the help of an incarcerated and manipulative cannibal killer to help catch another serial killer. Clarice Starling (Jodie Foster) seeks the assistance of Hannibal Lecter (Anthony Hopkins) to understand the mind of a killer.", + tags: ["thriller", "crime"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440011", + title: "Saving Private Ryan", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fsaving_private_ryan.jpg?alt=media&token=58ed877e-7ae0-4e30-9aee-d45c2deb7a00", + releaseYear: 1998, + genre: "war", + rating: 8.6, + description: "Following the Normandy Landings, a group of U.S. soldiers go behind enemy lines to retrieve a paratrooper whose brothers have been killed in action. The harrowing journey of Captain John H. Miller (Tom Hanks) and his men highlights the brutal reality of war.", + tags: ["war", "drama"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440012", + title: "The Avengers", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fthe_avengers.jpg?alt=media&token=3d68ccad-2fa1-48da-a83e-7941e246c9f9", + releaseYear: 2012, + genre: "action", + rating: 8.0, + description: "Earth's mightiest heroes, including Iron Man, Captain America, Thor, Hulk, Black Widow, and Hawkeye, must come together to stop Loki and his alien army from enslaving humanity. Directed by Joss Whedon, the film is known for its witty dialogue, intense action sequences, and the chemistry among its ensemble cast.", + tags: ["action", "sci-fi"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440013", + title: "Gladiator", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fgladiator.jpg?alt=media&token=61d75825-b79f-4add-afdb-7da5eed53407", + releaseYear: 2000, + genre: "action", + rating: 8.5, + description: "A former Roman General, Maximus Decimus Meridius, seeks vengeance against the corrupt emperor Commodus who murdered his family and sent him into slavery. Directed by Ridley Scott, the film is known for its epic scale, intense battle scenes, and Russell Crowe's powerful performance.", + tags: ["action", "drama"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440014", + title: "Titanic", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Ftitanic.png?alt=media&token=dd03dc83-486e-4b03-9b03-2f9ed83fd9d0", + releaseYear: 1997, + genre: "romance", + rating: 7.8, + description: "A romantic drama recounting the ill-fated voyage of the R.M.S. Titanic through the eyes of Jack Dawson, a poor artist, and Rose DeWitt Bukater, a wealthy aristocrat. Their forbidden romance unfolds aboard the luxurious ship, which tragically sinks after striking an iceberg. Directed by James Cameron, the film is known for its epic scale, emotional depth, and stunning visuals.", + tags: ["romance", "drama"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440015", + title: "Avatar", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Favatar.jpg?alt=media&token=1c75b09d-7c7a-44bf-b7ad-e7da4d0b7193", + releaseYear: 2009, + genre: "sci-fi", + rating: 7.8, + description: "A paraplegic Marine named Jake Sully is sent on a unique mission to Pandora, an alien world, to bridge relations with the native Na'vi people. Torn between following his orders and protecting the world he feels is his home, Jake's journey becomes a battle for survival. Directed by James Cameron, 'Avatar' is renowned for its groundbreaking special effects and immersive 3D experience.", + tags: ["sci-fi", "adventure"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440016", + title: "Jurassic Park", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fjurassic_park.jpg?alt=media&token=1731ce71-3384-4435-8a5b-821d4fd286d3", + releaseYear: 1993, + genre: "adventure", + rating: 8.1, + description: "During a preview tour, a theme park suffers a major power breakdown that allows its cloned dinosaur exhibits to run amok. Directed by Steven Spielberg, 'Jurassic Park' is known for its groundbreaking special effects, thrilling storyline, and the suspenseful chaos unleashed by its prehistoric creatures.", + tags: ["adventure", "sci-fi"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440017", + title: "The Lion King", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fthe_lion_king.jpg?alt=media&token=3e4e4265-6ae7-47d6-a5ba-584de126ef00", + releaseYear: 1994, + genre: "animation", + rating: 8.5, + description: "A young lion prince, Simba, must overcome betrayal and tragedy to reclaim his rightful place as king. 'The Lion King' is a beloved animated musical known for its memorable songs, stunning animation, and a timeless tale of courage, loyalty, and redemption.", + tags: ["animation", "drama"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440018", + title: "Star Wars: Episode IV - A New Hope", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fstar_wars_4.jpg?alt=media&token=b4ea7e0c-707f-43dd-8633-9d962e77b5a4", + releaseYear: 1977, + genre: "sci-fi", + rating: 8.6, + description: "Luke Skywalker joins forces with a Jedi Knight, a cocky pilot, a Wookiee, and two droids to save the galaxy from the Empire's world-destroying battle station, the Death Star. Directed by George Lucas, 'A New Hope' revolutionized the sci-fi genre with its groundbreaking special effects and unforgettable characters.", + tags: ["sci-fi", "adventure"] + }, + { + id: "550e8400-e29b-41d4-a716-446655440019", + title: "Blade Runner", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/movies%2Fblade_runner.jpg?alt=media&token=d8e94bdd-1477-49f3-b244-dd7a9c059fc1", + releaseYear: 1982, + genre: "sci-fi", + rating: 8.1, + description: "In a dystopian future, synthetic humans known as replicants are created by powerful corporations. A blade runner named Rick Deckard is tasked with hunting down and 'retiring' four replicants who have escaped to Earth. Directed by Ridley Scott, 'Blade Runner' is a seminal sci-fi thriller that explores themes of humanity and identity.", + tags: ["sci-fi", "thriller"] + } + ]) + + # Insert Actors + actor_insertMany(data: [ + { + id: "123e4567-e89b-12d3-a456-426614174000", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fdicaprio.jpeg?alt=media&token=452e030a-efa5-4ef4-bb81-502b23241316", + name: "Leonardo DiCaprio" + }, + { + id: "123e4567-e89b-12d3-a456-426614174001", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fkeanu.jpg?alt=media&token=6056520c-ef3e-4823-aad0-108aab163115", + name: "Keanu Reeves" + }, + { + id: "123e4567-e89b-12d3-a456-426614174002", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fmcconaoghey.jpg?alt=media&token=d340ca18-ef51-44ac-a160-08e45b242cd7", + name: "Matthew McConaughey" + }, + { + id: "123e4567-e89b-12d3-a456-426614174003", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fbale.jpg?alt=media&token=666f1690-a550-458f-a1cf-9505b7d1af7d", + name: "Christian Bale" + }, + { + id: "123e4567-e89b-12d3-a456-426614174004", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fpitt.jpeg?alt=media&token=3a5283d4-f85c-4ba7-be72-51bc87ca4133", + name: "Brad Pitt" + }, + { + id: "123e4567-e89b-12d3-a456-426614174005", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fjackson.jpg?alt=media&token=07be0601-19fe-4b5d-b111-84fa71f32139", + name: "Samuel L. Jackson" + }, + { + id: "123e4567-e89b-12d3-a456-426614174006", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fmortensen.jpeg?alt=media&token=e3d1ec99-b8e7-42e9-9d1c-03f56f61ecf7", + name: "Viggo Mortensen" + }, + { + id: "123e4567-e89b-12d3-a456-426614174007", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Ffreeman.jpg?alt=media&token=94bc6227-119e-4ab0-b350-55fac7aeb062", + name: "Morgan Freeman" + }, + { + id: "123e4567-e89b-12d3-a456-426614174008", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fhanks.jpeg?alt=media&token=d92979ce-da62-4b28-afbe-b8740bbb9d32", + name: "Tom Hanks" + }, + { + id: "123e4567-e89b-12d3-a456-426614174009", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fpacino.jpg?alt=media&token=9c0c54b1-6913-48b5-8e5e-d6551dd2f182", + name: "Al Pacino" + }, + { + id: "123e4567-e89b-12d3-a456-426614174010", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Ffoster.jpg?alt=media&token=b429734c-0f2d-4840-b75b-6857eac7bb4f", + name: "Jodie Foster" + }, + { + id: "123e4567-e89b-12d3-a456-426614174011", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fcruise.jpg?alt=media&token=d34b0326-a8d1-4f01-86e5-f3f094594e5a", + name: "Tom Cruise" + }, + { + id: "123e4567-e89b-12d3-a456-426614174012", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fdowney.jpg?alt=media&token=dd291c96-6ef0-42fc-841c-902c80748b37", + name: "Robert Downey Jr." + }, + { + id: "123e4567-e89b-12d3-a456-426614174013", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fcrowe.jpg?alt=media&token=46d413d5-ace8-404e-b018-8d7e6fe0d362", + name: "Russell Crowe" + }, + { + id: "123e4567-e89b-12d3-a456-426614174014", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fwinslet.jpg?alt=media&token=b675585e-356e-4361-a041-5ac1a6ee5922", + name: "Kate Winslet" + }, + { + id: "123e4567-e89b-12d3-a456-426614174015", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fweaver.jpeg?alt=media&token=263b5c3d-e0ee-43c3-854d-9b236c6df391", + name: "Sigourney Weaver" + }, + { + id: "123e4567-e89b-12d3-a456-426614174016", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fgoldblume.jpeg?alt=media&token=18277dd1-166c-4934-a02e-19ef141c86e2", + name: "Jeff Goldblum" + }, + { + id: "123e4567-e89b-12d3-a456-426614174017", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fjones.jpg?alt=media&token=f7ac9bc4-6e26-4b25-9a73-7a90f699424e", + name: "James Earl Jones" + }, + { + id: "123e4567-e89b-12d3-a456-426614174018", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fford.jpg?alt=media&token=928434c0-d492-4c8e-bdf0-0db585008d87", + name: "Harrison Ford" + }, + { + id: "123e4567-e89b-12d3-a456-426614174019", + imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-quickstart-web.appspot.com/o/actors%2Fschwarzenegger.jpeg?alt=media&token=c46fb249-41ef-4084-b4ad-9517bee6ab46", + name: "Arnold Schwarzenegger" + } + ]) + # Insert Movie Actor Joins + movieMetadata_insertMany(data: [ + { + movieId: "550e8400-e29b-41d4-a716-446655440000", + director: "Christopher Nolan" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440001", + director: "Lana Wachowski, Lilly Wachowski" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440002", + director: "Chad Stahelski" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440003", + director: "Christopher Nolan" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440004", + director: "David Fincher" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440005", + director: "Quentin Tarantino" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440006", + director: "Peter Jackson" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440007", + director: "Frank Darabont" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440008", + director: "Robert Zemeckis" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440009", + director: "Francis Ford Coppola" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440010", + director: "Jonathan Demme" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440011", + director: "Steven Spielberg" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440012", + director: "Joss Whedon" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440013", + director: "Ridley Scott" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440014", + director: "James Cameron" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440015", + director: "James Cameron" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440016", + director: "Steven Spielberg" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440017", + director: "Roger Allers, Rob Minkoff" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440018", + director: "George Lucas" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440019", + director: "Ridley Scott" + } + ]) + + movieActor_insertMany(data: [ + { + movieId: "550e8400-e29b-41d4-a716-446655440000", + actorId: "123e4567-e89b-12d3-a456-426614174000", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440001", + actorId: "123e4567-e89b-12d3-a456-426614174001", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440002", + actorId: "123e4567-e89b-12d3-a456-426614174001", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440003", + actorId: "123e4567-e89b-12d3-a456-426614174003", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440004", + actorId: "123e4567-e89b-12d3-a456-426614174004", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440005", + actorId: "123e4567-e89b-12d3-a456-426614174005", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440006", + actorId: "123e4567-e89b-12d3-a456-426614174006", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440007", + actorId: "123e4567-e89b-12d3-a456-426614174007", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440008", + actorId: "123e4567-e89b-12d3-a456-426614174008", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440009", + actorId: "123e4567-e89b-12d3-a456-426614174009", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440010", + actorId: "123e4567-e89b-12d3-a456-426614174010", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440011", + actorId: "123e4567-e89b-12d3-a456-426614174011", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440012", + actorId: "123e4567-e89b-12d3-a456-426614174012", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440013", + actorId: "123e4567-e89b-12d3-a456-426614174013", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440014", + actorId: "123e4567-e89b-12d3-a456-426614174014", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440015", + actorId: "123e4567-e89b-12d3-a456-426614174015", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440016", + actorId: "123e4567-e89b-12d3-a456-426614174016", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440017", + actorId: "123e4567-e89b-12d3-a456-426614174017", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440018", + actorId: "123e4567-e89b-12d3-a456-426614174018", + role: "main" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440019", + actorId: "123e4567-e89b-12d3-a456-426614174019", + role: "main" + }, + # Supporting actors + { + movieId: "550e8400-e29b-41d4-a716-446655440000", + actorId: "123e4567-e89b-12d3-a456-426614174001", + role: "supporting" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440001", + actorId: "123e4567-e89b-12d3-a456-426614174002", + role: "supporting" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440002", + actorId: "123e4567-e89b-12d3-a456-426614174003", + role: "supporting" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440003", + actorId: "123e4567-e89b-12d3-a456-426614174004", + role: "supporting" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440004", + actorId: "123e4567-e89b-12d3-a456-426614174005", + role: "supporting" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440005", + actorId: "123e4567-e89b-12d3-a456-426614174006", + role: "supporting" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440006", + actorId: "123e4567-e89b-12d3-a456-426614174007", + role: "supporting" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440007", + actorId: "123e4567-e89b-12d3-a456-426614174008", + role: "supporting" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440008", + actorId: "123e4567-e89b-12d3-a456-426614174009", + role: "supporting" + }, + { + movieId: "550e8400-e29b-41d4-a716-446655440009", + actorId: "123e4567-e89b-12d3-a456-426614174010", + role: "supporting" + } + ]) +} \ No newline at end of file From 2912f2951d2727579d72fc916d7819b8b01e8a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ros=C3=A1rio=20P=2E=20Fernandes?= <rosariopf@google.com> Date: Mon, 30 Sep 2024 19:06:43 +0100 Subject: [PATCH 45/63] Update README.md Co-authored-by: Marina Coelho <marinacoelho@google.com> --- dataconnect/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dataconnect/README.md b/dataconnect/README.md index 8cc4359fd..761f54539 100644 --- a/dataconnect/README.md +++ b/dataconnect/README.md @@ -26,16 +26,16 @@ check out the [official documentation](https://firebase.google.com/docs/data-con during the preview. 3. Navigate to the [Data Connect section](https://console.firebase.google.com/u/0/project/_/dataconnect) - of the Firebase console and follow the setup workflow: + 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. - - Fill in the following fields: + - Select the option to create a new Cloud SQL instance and fill in the following fields: - Service ID: `dataconnect` - - Cloud SQL Instance: `fdc-sql` + - 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. Download and then add the Firebase Android configuration file (`google-services.json`) to your app: +5. If you haven’t already, add an Android app to your Firebase project, with the android package name `com.google.firebase.example.dataconnect`. Download and then add the Firebase Android configuration file (`google-services.json`) to your app: 1. Click **Download google-services.json** to obtain your Firebase Android config file. 2. Move your config file into the `quickstart-android/dataconnect/app` directory. From 142dcb97347e64bfbf80f69f19032c89619ed1f2 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 30 Sep 2024 17:59:24 +0100 Subject: [PATCH 46/63] refactor: use Navigation type safety --- dataconnect/app/build.gradle.kts | 2 +- .../example/dataconnect/MainActivity.kt | 149 +++++++++--------- .../feature/actordetail/ActorDetailScreen.kt | 7 +- .../actordetail/ActorDetailViewModel.kt | 20 ++- .../feature/actordetail/Navigation.kt | 26 --- .../feature/genredetail/GenreDetailScreen.kt | 6 +- .../genredetail/GenreDetailViewModel.kt | 15 +- .../feature/genredetail/Navigation.kt | 27 ---- .../feature/genres/GenresScreen.kt | 3 + .../dataconnect/feature/genres/Navigation.kt | 21 --- .../feature/moviedetail/MovieDetailScreen.kt | 6 +- .../moviedetail/MovieDetailViewModel.kt | 24 ++- .../feature/moviedetail/Navigation.kt | 30 ---- .../feature/movies/MoviesScreen.kt | 4 + .../dataconnect/feature/movies/Navigation.kt | 21 --- .../dataconnect/feature/profile/Navigation.kt | 21 --- .../feature/profile/ProfileScreen.kt | 4 + .../feature/profile/ProfileViewModel.kt | 15 +- gradle/libs.versions.toml | 6 +- 19 files changed, 148 insertions(+), 259 deletions(-) delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/Navigation.kt delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/Navigation.kt delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt delete mode 100644 dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt diff --git a/dataconnect/app/build.gradle.kts b/dataconnect/app/build.gradle.kts index e6d0e400a..7484eb7c2 100644 --- a/dataconnect/app/build.gradle.kts +++ b/dataconnect/app/build.gradle.kts @@ -59,7 +59,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.viewmodel.android) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) 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 index c35bd0718..632b9ac78 100644 --- 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 @@ -11,7 +11,6 @@ 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.material.icons.filled.Search import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -19,36 +18,39 @@ 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.compose.ui.unit.dp -import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination 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.actorDetailScreen -import com.google.firebase.example.dataconnect.feature.actordetail.navigateToActorDetail -import com.google.firebase.example.dataconnect.feature.genredetail.genreDetailScreen -import com.google.firebase.example.dataconnect.feature.genredetail.navigateToGenreDetail -import com.google.firebase.example.dataconnect.feature.genres.GENRES_ROUTE -import com.google.firebase.example.dataconnect.feature.genres.genresScreen -import com.google.firebase.example.dataconnect.feature.genres.navigateToGenres -import com.google.firebase.example.dataconnect.feature.moviedetail.movieDetailScreen -import com.google.firebase.example.dataconnect.feature.moviedetail.navigateToMovieDetail -import com.google.firebase.example.dataconnect.feature.movies.MOVIES_ROUTE -import com.google.firebase.example.dataconnect.feature.movies.moviesScreen -import com.google.firebase.example.dataconnect.feature.movies.navigateToMovies -import com.google.firebase.example.dataconnect.feature.profile.PROFILE_ROUTE -import com.google.firebase.example.dataconnect.feature.profile.navigateToProfile -import com.google.firebase.example.dataconnect.feature.profile.profileScreen -import com.google.firebase.example.dataconnect.feature.search.SEARCH_ROUTE -import com.google.firebase.example.dataconnect.feature.search.navigateToSearch +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<T : Any>(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) @@ -64,75 +66,70 @@ class MainActivity : ComponentActivity() { NavigationBar { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination - NavigationBarItem( - icon = { Icon(Icons.Filled.Home, contentDescription = null) }, - label = { Text(stringResource(R.string.label_movies)) }, - selected = isRouteSelected(currentDestination, MOVIES_ROUTE), - onClick = { - navController.navigateToMovies { launchSingleTop = true } - } - ) - NavigationBarItem( - icon = { Icon(Icons.Filled.Menu, contentDescription = null) }, - label = { Text(stringResource(R.string.label_genres)) }, - selected = isRouteSelected(currentDestination, GENRES_ROUTE), - onClick = { - navController.navigateToGenres { launchSingleTop = true } - } - ) - NavigationBarItem( - icon = { Icon(Icons.Filled.Search, contentDescription = null) }, - label = { Text(stringResource(R.string.label_search)) }, - selected = isRouteSelected(currentDestination, SEARCH_ROUTE), - onClick = { - navController.navigateToSearch { launchSingleTop = true } - } - ) - NavigationBarItem( - icon = { Icon(Icons.Filled.Person, contentDescription = null) }, - label = { Text(stringResource(R.string.label_profile)) }, - selected = isRouteSelected(currentDestination, PROFILE_ROUTE), - onClick = { - navController.navigateToProfile { launchSingleTop = true } - } - ) + + 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 = MOVIES_ROUTE, + startDestination = MoviesRoute, Modifier .padding(innerPadding) .consumeWindowInsets(innerPadding), ) { - moviesScreen(onMovieClicked = { movieId -> - navController.navigateToMovieDetail(movieId) { - launchSingleTop = true - } - }) - movieDetailScreen( - onActorClicked = { actorId -> - navController.navigateToActorDetail(actorId) { - launchSingleTop = true + composable<MoviesRoute>() { + MoviesScreen( + onMovieClicked = { movieId -> + navController.navigate( + route = MovieDetailRoute(movieId), + builder = { + launchSingleTop = true + } + ) } - } - ) - actorDetailScreen() - genresScreen(onGenreClicked = { genre -> - navController.navigateToGenreDetail(genre) { - launchSingleTop = true - } - }) - genreDetailScreen() + ) + } + composable<MovieDetailRoute> { + MovieDetailScreen( + onActorClicked = { actorId -> + navController.navigate( + ActorDetailRoute(actorId), + { launchSingleTop = true } + ) + } + ) + } + composable<ActorDetailRoute>() { ActorDetailScreen() } + composable<GenresRoute> { + GenresScreen(onGenreClicked = { genre -> + navController.navigate( + GenreDetailRoute(genre), + { launchSingleTop = true } + ) + }) + } + composable<GenreDetailRoute> { GenreDetailScreen() } searchScreen() - profileScreen() + composable<ProfileRoute> { ProfileScreen() } } } } } } } - -private fun isRouteSelected(currentDestination: NavDestination?, route: String) = - currentDestination?.hierarchy?.any { it.route?.startsWith(route) ?: false } == true 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 index d2f46cc36..58f770dd7 100644 --- 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 @@ -32,13 +32,16 @@ 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( - actorId: String, actorDetailViewModel: ActorDetailViewModel = viewModel() ) { - actorDetailViewModel.setActorId(actorId) val uiState by actorDetailViewModel.uiState.collectAsState() ActorDetailScreen( uiState = uiState, 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 index ae6877a8b..6cadc7571 100644 --- 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 @@ -1,7 +1,9 @@ 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 @@ -14,17 +16,23 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class ActorDetailViewModel( - private val firebaseAuth: FirebaseAuth = Firebase.auth, - private val moviesConnector: MoviesConnector = MoviesConnector.instance + savedStateHandle: SavedStateHandle ) : ViewModel() { - private var actorId: String = "" + private val actorDetailRoute = savedStateHandle.toRoute<ActorDetailRoute>() + private val actorId: String = actorDetailRoute.actorId + + private val firebaseAuth: FirebaseAuth = Firebase.auth + private val moviesConnector: MoviesConnector = MoviesConnector.instance private val _uiState = MutableStateFlow<ActorDetailUIState>(ActorDetailUIState.Loading) val uiState: StateFlow<ActorDetailUIState> get() = _uiState - fun setActorId(id: String) { - actorId = id + init { + fetchActor() + } + + private fun fetchActor() { viewModelScope.launch { try { val user = firebaseAuth.currentUser @@ -64,7 +72,7 @@ class ActorDetailViewModel( ) } // Re-run the query to fetch the actor details - setActorId(actorId) + fetchActor() } catch (e: Exception) { _uiState.value = ActorDetailUIState.Error(e.message ?: "") } diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/Navigation.kt deleted file mode 100644 index 152c75971..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/actordetail/Navigation.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.google.firebase.example.dataconnect.feature.actordetail - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptionsBuilder -import androidx.navigation.compose.composable - -const val ACTOR_DETAIL_ROUTE = "actors/{actor}" - -fun NavController.navigateToActorDetail( - actorId: String, - navOptions: NavOptionsBuilder.() -> Unit = { } -) = navigate(ACTOR_DETAIL_ROUTE.replace("{actor}", actorId), navOptions) - -fun NavGraphBuilder.actorDetailScreen() { - composable( - route = ACTOR_DETAIL_ROUTE - ) { navBackStackEntry -> - navBackStackEntry.arguments?.let { - val actorId = it.getString("actor") - actorId?.let { id -> - ActorDetailScreen(id) - } - } - } -} \ 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 index 24ea7a8f7..c7e9e0e4b 100644 --- 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 @@ -18,13 +18,15 @@ 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( - genre: String, moviesViewModel: GenreDetailViewModel = viewModel() ) { - moviesViewModel.setGenre(genre) val movies by moviesViewModel.uiState.collectAsState() GenreDetailScreen(movies) } 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 index 811250a78..ec507e6c5 100644 --- 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 @@ -1,7 +1,9 @@ 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 @@ -10,17 +12,20 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class GenreDetailViewModel( - private val moviesConnector: MoviesConnector = MoviesConnector.instance + savedStateHandle: SavedStateHandle ) : ViewModel() { - private var genre = "" + private val genre = savedStateHandle.toRoute<GenreDetailRoute>().genre + private val moviesConnector: MoviesConnector = MoviesConnector.instance private val _uiState = MutableStateFlow<GenreDetailUIState>(GenreDetailUIState.Loading) val uiState: StateFlow<GenreDetailUIState> get() = _uiState - // TODO(thatfiredev): Create a ViewModelFactory to set genre - fun setGenre(genre: String) { - this.genre = genre + init { + fetchGenre() + } + + private fun fetchGenre() { viewModelScope.launch { try { val data = moviesConnector.listMoviesByGenre.execute(genre.lowercase()).data diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/Navigation.kt deleted file mode 100644 index 45edf83b3..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genredetail/Navigation.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.google.firebase.example.dataconnect.feature.genredetail - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptions -import androidx.navigation.NavOptionsBuilder -import androidx.navigation.compose.composable -import androidx.navigation.navArgument - -const val GENRE_DETAIL_ROUTE = "genres/{genre}" - -fun NavController.navigateToGenreDetail( - genre: String, - navOptions: NavOptionsBuilder.() -> Unit = { } -) = navigate(GENRE_DETAIL_ROUTE.replace("{genre}", genre), navOptions) - -fun NavGraphBuilder.genreDetailScreen() { - composable( - route = GENRE_DETAIL_ROUTE - ) { backStackEntry -> - backStackEntry.arguments?.let { - GenreDetailScreen(it.getString("genre", "Action")) - } - } -} - - 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 index 4bcd6bf40..efa98a37b 100644 --- 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 @@ -11,7 +11,10 @@ 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( diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt deleted file mode 100644 index f2fce931a..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/genres/Navigation.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.google.firebase.example.dataconnect.feature.genres - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptionsBuilder -import androidx.navigation.compose.composable - -const val GENRES_ROUTE = "genres" - -fun NavController.navigateToGenres(navOptions: NavOptionsBuilder.() -> Unit) = - navigate(GENRES_ROUTE, navOptions) - -fun NavGraphBuilder.genresScreen( - onGenreClicked: (genre: String) -> Unit -) { - composable(route = GENRES_ROUTE) { - GenresScreen(onGenreClicked) - } -} - - 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 index d0b8729b9..9ca23be69 100644 --- 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 @@ -47,14 +47,16 @@ 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( - movieId: String, onActorClicked: (actorId: String) -> Unit, movieDetailViewModel: MovieDetailViewModel = viewModel() ) { - movieDetailViewModel.setMovieId(movieId) val uiState by movieDetailViewModel.uiState.collectAsState() Scaffold { padding -> when (uiState) { 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 index 929c2225e..1212c7761 100644 --- 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 @@ -1,7 +1,9 @@ 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 @@ -16,17 +18,23 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class MovieDetailViewModel( - private val firebaseAuth: FirebaseAuth = Firebase.auth, - private val moviesConnector: MoviesConnector = MoviesConnector.instance + savedStateHandle: SavedStateHandle ) : ViewModel() { - private var movieId: String = "" + private val movieDetailRoute = savedStateHandle.toRoute<MovieDetailRoute>() + private val movieId: String = movieDetailRoute.movieId + + private val firebaseAuth: FirebaseAuth = Firebase.auth + private val moviesConnector: MoviesConnector = MoviesConnector.instance private val _uiState = MutableStateFlow<MovieDetailUIState>(MovieDetailUIState.Loading) val uiState: StateFlow<MovieDetailUIState> get() = _uiState - fun setMovieId(id: String) { - movieId = id + init { + fetchMovie() + } + + private fun fetchMovie() { viewModelScope.launch { try { val user = firebaseAuth.currentUser @@ -74,7 +82,7 @@ class MovieDetailViewModel( ) } // Re-run the query to fetch movie - setMovieId(movieId) + fetchMovie() } catch (e: Exception) { _uiState.value = MovieDetailUIState.Error(e.message ?: "") } @@ -95,7 +103,7 @@ class MovieDetailViewModel( ) } // Re-run the query to fetch movie - setMovieId(movieId) + fetchMovie() } catch (e: Exception) { _uiState.value = MovieDetailUIState.Error(e.message ?: "") } @@ -114,7 +122,7 @@ class MovieDetailViewModel( ) // TODO(thatfiredev): should we have a way of only refetching the reviews? // Re-run the query to fetch movie - setMovieId(movieId) + fetchMovie() } catch (e: Exception) { _uiState.value = MovieDetailUIState.Error(e.message ?: "") } diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt deleted file mode 100644 index ecef5023d..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/moviedetail/Navigation.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.google.firebase.example.dataconnect.feature.moviedetail - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptionsBuilder -import androidx.navigation.compose.composable - -const val MOVIE_DETAIL_ROUTE = "movies/{movie}" - -fun NavController.navigateToMovieDetail( - movieId: String, - navOptions: NavOptionsBuilder.() -> Unit = { } -) = navigate(MOVIE_DETAIL_ROUTE.replace("{movie}", movieId), navOptions) - -fun NavGraphBuilder.movieDetailScreen( - onActorClicked: (actorId: String) -> Unit -) { - composable( - route = MOVIE_DETAIL_ROUTE - ) { backStackEntry -> - backStackEntry.arguments?.let { - val movieId = it.getString("movie") - movieId?.let { id -> - MovieDetailScreen(id, onActorClicked) - } - } - } -} - - 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 index 60a81b92b..c76090264 100644 --- 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 @@ -17,6 +17,10 @@ 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( diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt deleted file mode 100644 index 06c256d04..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/movies/Navigation.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.google.firebase.example.dataconnect.feature.movies - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptionsBuilder -import androidx.navigation.compose.composable - -const val MOVIES_ROUTE = "movies" - -fun NavController.navigateToMovies(navOptions: NavOptionsBuilder.() -> Unit) = - navigate(MOVIES_ROUTE, navOptions) - -fun NavGraphBuilder.moviesScreen( - onMovieClicked: (movie: String) -> Unit -) { - composable(route = MOVIES_ROUTE) { - MoviesScreen(onMovieClicked) - } -} - - diff --git a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt b/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt deleted file mode 100644 index f6ce7a7f8..000000000 --- a/dataconnect/app/src/main/java/com/google/firebase/example/dataconnect/feature/profile/Navigation.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.google.firebase.example.dataconnect.feature.profile - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptionsBuilder -import androidx.navigation.compose.composable - -const val PROFILE_ROUTE = "profile_route" - -fun NavController.navigateToProfile(navOptions: NavOptionsBuilder.() -> Unit) = - navigate(PROFILE_ROUTE, navOptions) - -fun NavGraphBuilder.profileScreen( - -) { - composable(route = PROFILE_ROUTE) { - ProfileScreen() - } -} - - 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 index 7b7ee77d2..c020552b6 100644 --- 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 @@ -29,6 +29,10 @@ 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( 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 index 9430d067e..1620a2959 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -27,14 +28,12 @@ class ProfileViewModel( private val authStateListener: AuthStateListener init { - authStateListener = object : AuthStateListener { - override fun onAuthStateChanged(auth: FirebaseAuth) { - val currentUser = auth.currentUser - if (currentUser != null) { - displayUser(currentUser.uid) - } else { - _uiState.value = ProfileUIState.AuthState - } + authStateListener = AuthStateListener { + val currentUser = auth.currentUser + if (currentUser != null) { + displayUser(currentUser.uid) + } else { + _uiState.value = ProfileUIState.AuthState } } auth.addAuthStateListener(authStateListener) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d1ea661b2..b4330e046 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ agp = "8.6.0" coilCompose = "2.6.0" firebaseAuth = "23.0.0" -firebaseDataConnect = "16.0.0-alpha03" +firebaseDataConnect = "16.0.0-alpha05" kotlin = "2.0.20" coreKtx = "1.13.1" junit = "4.13.2" @@ -12,12 +12,12 @@ lifecycle = "2.8.1" activityCompose = "1.9.0" composeBom = "2023.08.00" googleServices = "4.4.2" -composeNavigation = "2.7.7" +composeNavigation = "2.8.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-compose-android = { module = "androidx.lifecycle:lifecycle-runtime-compose-android", version.ref = "lifecycle" } -androidx-lifecycle-viewmodel-android = { module = "androidx.lifecycle:lifecycle-viewmodel-android", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } firebase-auth = { module = "com.google.firebase:firebase-auth", version.ref = "firebaseAuth" } firebase-dataconnect = { module = "com.google.firebase:firebase-dataconnect", version.ref = "firebaseDataConnect" } From 3e0b914a8f5320059b0133e986137a02bc825cd4 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 30 Sep 2024 18:37:00 +0100 Subject: [PATCH 47/63] refactor: delete extra uiState --- .../feature/actordetail/ActorDetailScreen.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 index 58f770dd7..0441dbdc6 100644 --- 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 @@ -63,10 +63,9 @@ fun ActorDetailScreen( when (uiState) { is ActorDetailUIState.Error -> ErrorCard(uiState.errorMessage) - ActorDetailUIState.Loading -> LoadingScreen() + is ActorDetailUIState.Loading -> LoadingScreen() is ActorDetailUIState.Success -> { - val ui = uiState Scaffold { innerPadding -> val scrollState = rememberScrollState() Column( @@ -75,13 +74,13 @@ fun ActorDetailScreen( .verticalScroll(scrollState) ) { ActorInformation( - actor = ui.actor, - isActorFavorite = ui.isFavorite, + actor = uiState.actor, + isActorFavorite = uiState.isFavorite, onFavoriteToggled = onFavoriteToggled ) MoviesList( listTitle = stringResource(R.string.title_main_roles), - movies = ui.actor?.mainActors?.mapNotNull { + movies = uiState.actor?.mainActors?.mapNotNull { Movie(it.id.toString(), it.imageUrl, it.title) }, onMovieClicked = onMovieClicked @@ -89,7 +88,7 @@ fun ActorDetailScreen( Spacer(modifier = Modifier.height(8.dp)) MoviesList( listTitle = stringResource(R.string.title_supporting_actors), - movies = ui.actor?.supportingActors?.mapNotNull { + movies = uiState.actor?.supportingActors?.mapNotNull { Movie(it.id.toString(), it.imageUrl, it.title) }, onMovieClicked = onMovieClicked From 496af823ea1de9a2ba9afd65fa81485479e78d57 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 30 Sep 2024 18:40:22 +0100 Subject: [PATCH 48/63] refactor favoriteActor for readability --- .../dataconnect/feature/actordetail/ActorDetailViewModel.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 6cadc7571..4c1471c6e 100644 --- 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 @@ -43,10 +43,12 @@ class ActorDetailViewModel( _uiState.value = if (user == null) { ActorDetailUIState.Success(actor, isUserSignedIn = false) } else { - val isFavorite = moviesConnector.getIfFavoritedActor.execute( + val favoriteActor = moviesConnector.getIfFavoritedActor.execute( id = user.uid, actorId = UUID.fromString(actorId) - ).data.favoriteActor != null + ).data.favoriteActor + + val isFavorite = favoriteActor != null ActorDetailUIState.Success( actor, From 287adb178554575cec695a78f164c311783680b6 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 30 Sep 2024 18:50:50 +0100 Subject: [PATCH 49/63] refactor: make error messages nullable and add default error message --- .../dataconnect/feature/actordetail/ActorDetailUIState.kt | 2 +- .../feature/actordetail/ActorDetailViewModel.kt | 4 ++-- .../dataconnect/feature/genredetail/GenreDetailUIState.kt | 2 +- .../feature/genredetail/GenreDetailViewModel.kt | 2 +- .../dataconnect/feature/moviedetail/MovieDetailUIState.kt | 2 +- .../feature/moviedetail/MovieDetailViewModel.kt | 8 ++++---- .../example/dataconnect/feature/movies/MoviesUIState.kt | 2 +- .../example/dataconnect/feature/profile/ProfileUIState.kt | 2 +- .../dataconnect/feature/profile/ProfileViewModel.kt | 6 +++--- .../example/dataconnect/ui/components/ErrorCard.kt | 6 ++++-- dataconnect/app/src/main/res/values/strings.xml | 1 + 11 files changed, 20 insertions(+), 17 deletions(-) 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 index 089ddb1b7..7f8ca2ff5 100644 --- 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 @@ -7,7 +7,7 @@ import com.google.firebase.dataconnect.movies.GetMovieByIdQuery sealed class ActorDetailUIState { data object Loading: ActorDetailUIState() - data class Error(val errorMessage: String): ActorDetailUIState() + data class Error(val errorMessage: String?): ActorDetailUIState() data class Success( // Actor is null if it can't be found on the DB 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 index 4c1471c6e..0ea3b4a2f 100644 --- 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 @@ -57,7 +57,7 @@ class ActorDetailViewModel( ) } } catch (e: Exception) { - _uiState.value = ActorDetailUIState.Error(e.message ?: "") + _uiState.value = ActorDetailUIState.Error(e.message) } } } @@ -76,7 +76,7 @@ class ActorDetailViewModel( // Re-run the query to fetch the actor details fetchActor() } catch (e: Exception) { - _uiState.value = ActorDetailUIState.Error(e.message ?: "") + _uiState.value = ActorDetailUIState.Error(e.message) } } } 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 index fefb51f1d..442776c3c 100644 --- 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 @@ -6,7 +6,7 @@ sealed class GenreDetailUIState { data object Loading: GenreDetailUIState() - data class Error(val errorMessage: String): GenreDetailUIState() + data class Error(val errorMessage: String?): GenreDetailUIState() data class Success( val genreName: String, 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 index ec507e6c5..2d327d101 100644 --- 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 @@ -37,7 +37,7 @@ class GenreDetailViewModel( mostRecent = mostRecent ) } catch (e: Exception) { - _uiState.value = GenreDetailUIState.Error(e.message ?: "") + _uiState.value = GenreDetailUIState.Error(e.message) } } } 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 index d1e3bf071..1f6e04028 100644 --- 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 @@ -6,7 +6,7 @@ import com.google.firebase.dataconnect.movies.GetMovieByIdQuery sealed class MovieDetailUIState { data object Loading: MovieDetailUIState() - data class Error(val errorMessage: String): MovieDetailUIState() + data class Error(val errorMessage: String?): MovieDetailUIState() data class Success( // Movie is null if it can't be found on the DB 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 index 1212c7761..fc19a4c36 100644 --- 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 @@ -63,7 +63,7 @@ class MovieDetailViewModel( ) } } catch (e: Exception) { - _uiState.value = MovieDetailUIState.Error(e.message ?: "") + _uiState.value = MovieDetailUIState.Error(e.message) } } } @@ -84,7 +84,7 @@ class MovieDetailViewModel( // Re-run the query to fetch movie fetchMovie() } catch (e: Exception) { - _uiState.value = MovieDetailUIState.Error(e.message ?: "") + _uiState.value = MovieDetailUIState.Error(e.message) } } } @@ -105,7 +105,7 @@ class MovieDetailViewModel( // Re-run the query to fetch movie fetchMovie() } catch (e: Exception) { - _uiState.value = MovieDetailUIState.Error(e.message ?: "") + _uiState.value = MovieDetailUIState.Error(e.message) } } } @@ -124,7 +124,7 @@ class MovieDetailViewModel( // Re-run the query to fetch movie fetchMovie() } catch (e: Exception) { - _uiState.value = MovieDetailUIState.Error(e.message ?: "") + _uiState.value = MovieDetailUIState.Error(e.message) } } } 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 index 92b8f70f3..3f428b2e5 100644 --- 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 @@ -7,7 +7,7 @@ sealed class MoviesUIState { data object Loading: MoviesUIState() - data class Error(val errorMessage: String): MoviesUIState() + data class Error(val errorMessage: String?): MoviesUIState() data class Success( val top10movies: List<MoviesTop10Query.Data.MoviesItem>, 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 index 9977d7b77..660c62abe 100644 --- 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 @@ -5,7 +5,7 @@ import com.google.firebase.dataconnect.movies.GetUserByIdQuery sealed class ProfileUIState { data object Loading: ProfileUIState() - data class Error(val errorMessage: String): ProfileUIState() + data class Error(val errorMessage: String?): ProfileUIState() data object AuthState: 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 index 1620a2959..22a85ea90 100644 --- 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 @@ -54,7 +54,7 @@ class ProfileViewModel( )?.await() moviesConnector.upsertUser.execute(username = displayName) } catch (e: Exception) { - _uiState.value = ProfileUIState.Error(e.message ?: "") + _uiState.value = ProfileUIState.Error(e.message) e.printStackTrace() } } @@ -65,7 +65,7 @@ class ProfileViewModel( try { auth.signInWithEmailAndPassword(email, password).await() } catch (e: Exception) { - _uiState.value = ProfileUIState.Error(e.message ?: "") + _uiState.value = ProfileUIState.Error(e.message) } } } @@ -89,7 +89,7 @@ class ProfileViewModel( ) Log.d("DisplayUser", "$user") } catch (e: Exception) { - _uiState.value = ProfileUIState.Error(e.message ?: "") + _uiState.value = ProfileUIState.Error(e.message) } } } 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 index 08cb86dea..f8c127124 100644 --- 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 @@ -8,12 +8,14 @@ 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 + errorMessage: String? ) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), @@ -21,7 +23,7 @@ fun ErrorCard( .fillMaxWidth() ) { Text( - text = errorMessage, + text = errorMessage ?: stringResource(R.string.unknown_error), modifier = Modifier.padding(16.dp) .fillMaxWidth() ) diff --git a/dataconnect/app/src/main/res/values/strings.xml b/dataconnect/app/src/main/res/values/strings.xml index d58d70af7..15c39cb40 100644 --- a/dataconnect/app/src/main/res/values/strings.xml +++ b/dataconnect/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ <resources> <string name="app_name">Firebase Data Connect</string> + <string name="unknown_error">An unknown error occurred</string> <!-- NavBar labels --> <string name="label_movies">Movies</string> From 01e072ab5fee04a5d550f1f532379e8eaa3197e8 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Mon, 30 Sep 2024 19:28:39 +0100 Subject: [PATCH 50/63] refactor: ensure a user is logged in before marking as favorite --- .../feature/actordetail/ActorDetailViewModel.kt | 4 +++- .../feature/moviedetail/MovieDetailViewModel.kt | 10 ++++++++-- .../dataconnect/feature/moviedetail/UserReviews.kt | 6 ++++-- 3 files changed, 15 insertions(+), 5 deletions(-) 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 index 0ea3b4a2f..99df5ca7e 100644 --- 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 @@ -63,13 +63,15 @@ class ActorDetailViewModel( } 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 = firebaseAuth.currentUser?.uid ?: "", + userId = uid, actorId = UUID.fromString(actorId) ) } 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 index fc19a4c36..90bfd2dfa 100644 --- 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 @@ -69,6 +69,8 @@ class MovieDetailViewModel( } 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) { @@ -77,7 +79,7 @@ class MovieDetailViewModel( // TODO(thatfiredev): investigate whether this is a schema error // userId probably shouldn't be here. moviesConnector.deleteFavoritedMovie.execute( - userId = firebaseAuth.currentUser?.uid ?: "", + userId = uid, movieId = UUID.fromString(movieId) ) } @@ -90,6 +92,8 @@ class MovieDetailViewModel( } 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) { @@ -98,7 +102,7 @@ class MovieDetailViewModel( // TODO(thatfiredev): investigate whether this is a schema error // userId probably shouldn't be here. moviesConnector.deleteWatchedMovie.execute( - userId = firebaseAuth.currentUser?.uid ?: "", + userId = uid, movieId = UUID.fromString(movieId) ) } @@ -111,6 +115,8 @@ class MovieDetailViewModel( } 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( 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 index 0b677d48d..1ae372360 100644 --- 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 @@ -63,8 +63,10 @@ fun UserReviews( Button( onClick = { - onReviewSubmitted(rating, reviewText) - reviewText = "" + if (!reviewText.isNullOrEmpty()) { + onReviewSubmitted(rating, reviewText) + reviewText = "" + } } ) { Text(stringResource(R.string.button_submit_review)) From 607729416fa0a0f17c03d7e41c9fe051221c4637 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Tue, 1 Oct 2024 15:10:18 +0100 Subject: [PATCH 51/63] update GenreDetailScreen --- .../dataconnect/feature/genredetail/GenreDetailScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index c7e9e0e4b..79fddb640 100644 --- 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 @@ -36,7 +36,7 @@ fun GenreDetailScreen( uiState: GenreDetailUIState ) { when (uiState) { - GenreDetailUIState.Loading -> LoadingScreen() + is GenreDetailUIState.Loading -> LoadingScreen() is GenreDetailUIState.Error -> ErrorCard(uiState.errorMessage) From 4a4401b4ba75d803de321367ed11aa564227d56a Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 19:03:36 +0100 Subject: [PATCH 52/63] chore: use v1beta --- dataconnect/dataconnect/dataconnect.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dataconnect/dataconnect/dataconnect.yaml b/dataconnect/dataconnect/dataconnect.yaml index 1eea139ad..8673bd536 100644 --- a/dataconnect/dataconnect/dataconnect.yaml +++ b/dataconnect/dataconnect/dataconnect.yaml @@ -1,4 +1,4 @@ -specVersion: "v1alpha" +specVersion: "v1beta" serviceId: "dataconnect" location: "us-central1" schema: From 172e457bfb9e959779ac17da6dbc562e25a6d015 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 19:32:40 +0100 Subject: [PATCH 53/63] ci: remove Data Connect from build --- .github/workflows/android.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 55dd574bc..3e97884a2 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -20,6 +20,9 @@ jobs: run: python scripts/checksnippets.py - name: Copy mock google_services.json run: ./copy_mock_google_services_json.sh + # TODO(thatfiredev): remove this once github.com/firebase/quickstart-android/issues/1672 is fixed + - name: Remove Data Connect from the CI build + run: sed -i "" "s/\":dataconnect:app\",//g" settings.gradle.kts - name: Build with Gradle (Pull Request) run: ./build_pull_request.sh if: github.event_name == 'pull_request' From 68b25951d4602f5dc60da63163fbe346e181ee4d Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 19:33:02 +0100 Subject: [PATCH 54/63] chore: upgrade to 16.0.0-beta01 --- dataconnect/app/build.gradle.kts | 1 + gradle/libs.versions.toml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/dataconnect/app/build.gradle.kts b/dataconnect/app/build.gradle.kts index 7484eb7c2..3d24730fa 100644 --- a/dataconnect/app/build.gradle.kts +++ b/dataconnect/app/build.gradle.kts @@ -73,6 +73,7 @@ dependencies { // Firebase dependencies implementation(libs.firebase.auth) implementation(libs.firebase.dataconnect) + implementation(libs.kotlinx.serialization.core) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b4330e046..eedcab13d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,12 +2,13 @@ agp = "8.6.0" coilCompose = "2.6.0" firebaseAuth = "23.0.0" -firebaseDataConnect = "16.0.0-alpha05" +firebaseDataConnect = "16.0.0-beta01" kotlin = "2.0.20" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.1.5" espressoCore = "3.5.1" +kotlinxSerializationCore = "1.6.3" lifecycle = "2.8.1" activityCompose = "1.9.0" composeBom = "2023.08.00" @@ -35,6 +36,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation"} +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 8dc1a40856e5cbb6dc8edd9fa112a73dd0ebca37 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 19:35:03 +0100 Subject: [PATCH 55/63] ci: remove -i parameter --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 3e97884a2..2389fff00 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -22,7 +22,7 @@ jobs: run: ./copy_mock_google_services_json.sh # TODO(thatfiredev): remove this once github.com/firebase/quickstart-android/issues/1672 is fixed - name: Remove Data Connect from the CI build - run: sed -i "" "s/\":dataconnect:app\",//g" settings.gradle.kts + run: sed "s/\":dataconnect:app\",//g" settings.gradle.kts - name: Build with Gradle (Pull Request) run: ./build_pull_request.sh if: github.event_name == 'pull_request' From 782010bc7816e18ce3838c892c928008240cf33c Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 19:37:19 +0100 Subject: [PATCH 56/63] ci: output file --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2389fff00..37a3a0e07 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -22,7 +22,7 @@ jobs: run: ./copy_mock_google_services_json.sh # TODO(thatfiredev): remove this once github.com/firebase/quickstart-android/issues/1672 is fixed - name: Remove Data Connect from the CI build - run: sed "s/\":dataconnect:app\",//g" settings.gradle.kts + run: sed "s/\":dataconnect:app\",//g" settings.gradle.kts >> settings.gradle.kts - name: Build with Gradle (Pull Request) run: ./build_pull_request.sh if: github.event_name == 'pull_request' From 851252f6f4565ab43f28466d3b4d6cf66a754262 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 19:40:52 +0100 Subject: [PATCH 57/63] ci: overwrite instead of append --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 37a3a0e07..525aa157a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -22,7 +22,7 @@ jobs: run: ./copy_mock_google_services_json.sh # TODO(thatfiredev): remove this once github.com/firebase/quickstart-android/issues/1672 is fixed - name: Remove Data Connect from the CI build - run: sed "s/\":dataconnect:app\",//g" settings.gradle.kts >> settings.gradle.kts + run: sed "s/\":dataconnect:app\",//g" settings.gradle.kts > settings.gradle.kts - name: Build with Gradle (Pull Request) run: ./build_pull_request.sh if: github.event_name == 'pull_request' From 922c940c6eb774f37fef58e144499d96dcafe8fd Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 19:42:04 +0100 Subject: [PATCH 58/63] chore: agp 8.7.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eedcab13d..6f0f2f789 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.6.0" +agp = "8.7.0" coilCompose = "2.6.0" firebaseAuth = "23.0.0" firebaseDataConnect = "16.0.0-beta01" From 85f1fb9616d3d4f5b4dd4b45f6804b2d66d40f12 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 20:50:33 +0100 Subject: [PATCH 59/63] testing --- .github/workflows/android.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 525aa157a..504c9ad5e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -23,6 +23,8 @@ jobs: # TODO(thatfiredev): remove this once github.com/firebase/quickstart-android/issues/1672 is fixed - name: Remove Data Connect from the CI build run: sed "s/\":dataconnect:app\",//g" settings.gradle.kts > settings.gradle.kts + - name: Testing + run: cat settings.gradle.kts - name: Build with Gradle (Pull Request) run: ./build_pull_request.sh if: github.event_name == 'pull_request' From a2e39b703e5d85dc5eeae65191155a62fbc60f50 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 20:53:03 +0100 Subject: [PATCH 60/63] revert ci changes --- .github/workflows/android.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 504c9ad5e..55dd574bc 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -20,11 +20,6 @@ jobs: run: python scripts/checksnippets.py - name: Copy mock google_services.json run: ./copy_mock_google_services_json.sh - # TODO(thatfiredev): remove this once github.com/firebase/quickstart-android/issues/1672 is fixed - - name: Remove Data Connect from the CI build - run: sed "s/\":dataconnect:app\",//g" settings.gradle.kts > settings.gradle.kts - - name: Testing - run: cat settings.gradle.kts - name: Build with Gradle (Pull Request) run: ./build_pull_request.sh if: github.event_name == 'pull_request' From d7fcb9367498f77c476da3f57aae3a8b883b371c Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 21:02:34 +0100 Subject: [PATCH 61/63] ci: add python script to remove fdc --- .github/workflows/android.yml | 3 +++ scripts/ci_remove_fdc.py | 8 ++++++++ 2 files changed, 11 insertions(+) create mode 100644 scripts/ci_remove_fdc.py diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 55dd574bc..8a635beff 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/scripts/ci_remove_fdc.py b/scripts/ci_remove_fdc.py new file mode 100644 index 000000000..c425af615 --- /dev/null +++ b/scripts/ci_remove_fdc.py @@ -0,0 +1,8 @@ +# TODO(thatfiredev): remove this once github.com/firebase/quickstart-android/issues/1672 is fixed +with open('settings.gradle.kts', 'r') as file: + filedata = file.read() + +filedata = filedata.replace('":dataconnect:app",', '') + +with open('settings.gradle.kts', 'w') as file: + file.write(filedata) From d4249df07fc818a6f8d62003d5962aba52251f62 Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 21:22:13 +0100 Subject: [PATCH 62/63] docs: update README.md --- dataconnect/README.md | 56 ++++++++----------- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/dataconnect/README.md b/dataconnect/README.md index 761f54539..3d5b03513 100644 --- a/dataconnect/README.md +++ b/dataconnect/README.md @@ -11,6 +11,11 @@ For more information about Firebase Data Connect visit [the docs](https://fireba 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. @@ -35,28 +40,26 @@ check out the [official documentation](https://firebase.google.com/docs/data-con 4. Allow some time for the Cloud SQL instance to be provisioned. After it's provisioned, the instance can be managed in the [Cloud Console](https://console.cloud.google.com/sql). -5. If you haven’t already, add an Android app to your Firebase project, with the android package name `com.google.firebase.example.dataconnect`. Download and then add the Firebase Android configuration file (`google-services.json`) to your app: - 1. Click **Download google-services.json** to obtain your Firebase Android config file. - 2. Move your config file into the `quickstart-android/dataconnect/app` directory. - -### 2. Set Up Firebase CLI - -Ensure the Firebase CLI is installed and up to date: +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. -```bash -npm install -g firebase-tools -``` - -### 3. Cloning the repository -This repository contains the quickstart to get started with the functionalities of Data Connect. +### 2. Cloning the repository 1. Clone this repository to your local machine: ```sh git clone https://github.com/firebase/quickstart-android.git ``` -2. (Private Preview only) Checkout the `fdc-quickstart` branch (`git checkout fdc-quickstart`) - and open the project in Android Studio. +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. Deploy the service to Firebase and generate SDKs @@ -74,24 +77,13 @@ This repository contains the quickstart to get started with the functionalities firebase dataconnect:sdk:generate ``` -### 5. Populating the database -1. Run `1_movie_insert.gql`, `2_actor_insert.gql`, `3_movie_actor_insert.gql`, and `4_user_favorites_review_insert.gql` files in the `./dataconnect` directory in order using the VS code extension, +### 5. 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. ### 6. Running the app Press the Run button in Android Studio to run the sample app on your device. - -## 🚧 Work in Progress - -This app is still missing some features which will be added before Public Preview: - -- [ ] Search -- [ ] Movie review - - [x] Add a new review - - [ ] Update a review - - [ ] Delete a review -- [x] Actors - - [x] Show actor profile - - [x] Mark actor as favorite -- [ ] Error handling - Some errors may cause the app to crash, especially if there's no user logged in. diff --git a/dataconnect/gradle/wrapper/gradle-wrapper.properties b/dataconnect/gradle/wrapper/gradle-wrapper.properties index 367d4dffd..ddf39bde2 100644 --- a/dataconnect/gradle/wrapper/gradle-wrapper.properties +++ b/dataconnect/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed May 08 19:29:05 BST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 83c7a3d68a865dc063fbc2369ac414e5444aa23d Mon Sep 17 00:00:00 2001 From: rosariopf <rosariopf@google.com> Date: Wed, 2 Oct 2024 21:31:23 +0100 Subject: [PATCH 63/63] docs: remove deploy step --- dataconnect/README.md | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/dataconnect/README.md b/dataconnect/README.md index 3d5b03513..73160a9df 100644 --- a/dataconnect/README.md +++ b/dataconnect/README.md @@ -61,29 +61,13 @@ check out the [official documentation](https://firebase.google.com/docs/data-con 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. Deploy the service to Firebase and generate SDKs - -1. Open the `quickstart-android/dataconnect/dataconnect` directory and deploy the schema with - the following command: - ```bash - firebase deploy - ``` -2. Once the deploy is complete, you should be able to see the movie schema in the - [Data Connect section](https://console.firebase.google.com/u/0/project/_/dataconnect) - of the Firebase console. - -3. Generate the Kotlin SDK by running: - ```bash - firebase dataconnect:sdk:generate - ``` - -### 5. Populate the database +### 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. -### 6. Running the app +### 5. Running the app Press the Run button in Android Studio to run the sample app on your device.