diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 00000000..4c20f244 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "skillbridge-46ee3" + } +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..049e9714 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +# What I did + + +# How I did it + + +# How to verify it + + +# Demo video + + +# Pre-merge checklist +The changes I introduced: +- [ ] work correctly +- [ ] do not break other functionalities +- [ ] work correctly on Android +- [ ] are fully tested (or have tests added) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82ffdc09..99d87c5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,16 @@ jobs: run: | chmod +x ./gradlew + - name: Decode google-services.json + env: + GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }} + run: | + if [ -n "$GOOGLE_SERVICES" ]; then + echo "$GOOGLE_SERVICES" | base64 --decode > ./app/google-services.json + else + echo "::warning::GOOGLE_SERVICES secret not set. google-services.json will not be created." + fi + # Check formatting - name: KTFmt Check run: | @@ -112,4 +122,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew sonar --parallel --build-cache \ No newline at end of file + run: ./gradlew sonar --parallel --build-cache diff --git a/.github/workflows/generate-apk.yml b/.github/workflows/generate-apk.yml new file mode 100644 index 00000000..2f7aed3b --- /dev/null +++ b/.github/workflows/generate-apk.yml @@ -0,0 +1,69 @@ +name: Generate APK (manual) + +on: + # Manual trigger only – will show "Run workflow" button in Actions tab + workflow_dispatch: + inputs: + build_type: + description: "Which build type to assemble (debug or release)" + required: true + default: "debug" + +jobs: + build: + name: Build ${{ github.event.inputs.build_type }} APK + runs-on: ubuntu-latest + + steps: + # 1 Checkout your code + - name: Checkout repository + uses: actions/checkout@v4 + + # 2 Set up Java (AGP 8.x → needs JDK 17) + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + cache: gradle + + # 3 Set up Android SDK (single-line package list to avoid parsing issues) + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + with: + packages: platform-tools platforms;android-34 build-tools;34.0.0 + + # 4 Accept all Android SDK licenses + - name: Accept Android SDK licenses + run: yes | sdkmanager --licenses || true + + # 5 Create local.properties (so Gradle can locate SDK) + - name: Configure local.properties + run: | + echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties + + # 6 Restore google-services.json from GitHub secret + - name: Restore google-services.json + run: | + echo "${{ secrets.GOOGLE_SERVICES }}" | base64 --decode > app/google-services.json + + + # 7 Make gradlew executable (sometimes loses permission) + - name: Grant Gradle wrapper permissions + run: chmod +x ./gradlew + + # 8 Build APK + - name: Build ${{ github.event.inputs.build_type }} APK + run: | + if [ "${{ github.event.inputs.build_type }}" = "release" ]; then + ./gradlew :app:assembleRelease --no-daemon --stacktrace + else + ./gradlew :app:assembleDebug --no-daemon --stacktrace + fi + + # 9 Upload APK artifact so you can download it from GitHub Actions UI + - name: Upload APK artifact + uses: actions/upload-artifact@v4 + with: + name: app-${{ github.event.inputs.build_type }}-apk + path: app/build/outputs/apk/**/${{ github.event.inputs.build_type }}/*.apk diff --git a/.scannerwork/.sonar_lock b/.scannerwork/.sonar_lock new file mode 100644 index 00000000..e69de29b diff --git a/CREDENTIAL_MANAGER_INTEGRATION.md b/CREDENTIAL_MANAGER_INTEGRATION.md new file mode 100644 index 00000000..abbbdd00 --- /dev/null +++ b/CREDENTIAL_MANAGER_INTEGRATION.md @@ -0,0 +1,141 @@ +# Credential Manager Integration Summary + +## What Changed + +I've successfully updated your authentication system to use **Android Credential Manager API** - Google's modern, recommended approach for handling authentication credentials. + +## Benefits of Credential Manager + +1. **Unified API** - Single interface for passwords, passkeys, and federated sign-in +2. **Better UX** - Native Android credential picker UI +3. **Security** - Built-in protection against phishing and credential theft +4. **Future-proof** - Supports upcoming passkeys and biometric authentication +5. **Auto-fill Integration** - Seamless integration with Android's password managers + +## Implementation Details + +### New Dependencies Added + +In `libs.versions.toml`: +```toml +credentialManager = "1.2.2" +googleIdCredential = "1.1.1" +``` + +In `build.gradle.kts`: +```kotlin +implementation(libs.androidx.credentials) +implementation(libs.androidx.credentials.play.services) +implementation(libs.googleid) +``` + +### Files Modified/Created + +1. **CredentialAuthHelper.kt** (NEW) + - Manages Credential Manager for password autofill + - Provides GoogleSignInClient for Google authentication + - Converts credentials to Firebase auth tokens + +2. **AuthenticationViewModel.kt** (UPDATED) + - Now uses CredentialAuthHelper instead of GoogleSignInHelper + - Added `getSavedCredential()` - retrieves saved passwords from Credential Manager + - Uses `getGoogleSignInClient()` for Google Sign-In flow + - Handles activity results for Google Sign-In + +3. **MainActivity.kt** (UPDATED) + - Uses `rememberLauncherForActivityResult` for Google Sign-In + - Simplified LoginApp setup with activity result handling + +4. **GoogleSignInHelper.kt** (REPLACED) + - Old file is no longer needed + - Functionality merged into CredentialAuthHelper + +## How It Works + +### Password Authentication with Credential Manager + +```kotlin +// User can retrieve saved credentials +viewModel.getSavedCredential() // Auto-fills email/password from saved credentials + +// Regular sign-in still works +viewModel.signIn() // Signs in with email/password +``` + +The Credential Manager will: +- Show a native Android picker with saved credentials +- Auto-fill the login form +- Offer to save new credentials after successful login + +### Google Sign-In + +The implementation uses a **hybrid approach**: +- **Credential Manager** for password credentials (modern API) +- **Google Sign-In SDK** for Google authentication (more reliable and simpler) + +The flow: +1. User clicks "Sign in with Google" +2. Activity result launcher opens Google Sign-In UI +3. User selects Google account +4. ViewModel processes the result and signs into Firebase + +## Key Features + +✅ **Password Autofill** - Credential Manager provides saved passwords +✅ **Google Sign-In** - Seamless Google authentication flow +✅ **Email/Password** - Traditional email/password authentication +✅ **Password Reset** - Send password reset emails +✅ **Role Selection** - Choose between Learner and Tutor +✅ **MVVM Architecture** - Clean separation of concerns +✅ **Firebase Integration** - Works with Firebase Auth and emulators + +## Usage Example + +```kotlin +@Composable +fun LoginApp() { + val viewModel = AuthenticationViewModel(context) + + // Register Google Sign-In launcher + val googleSignInLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + viewModel.handleGoogleSignInResult(result) + } + + // Optional: Try to load saved credentials on start + LaunchedEffect(Unit) { + viewModel.getSavedCredential() + } + + LoginScreen( + viewModel = viewModel, + onGoogleSignIn = { + val signInIntent = viewModel.getGoogleSignInClient().signInIntent + googleSignInLauncher.launch(signInIntent) + }) +} +``` + +## Testing + +The authentication system is ready to test: +- **Email/Password**: Enter credentials and click Sign In +- **Google Sign-In**: Click the Google button to launch Google account picker +- **Password Autofill**: Android will offer to save/retrieve credentials +- **Firebase Emulator**: Works with your existing emulator setup (10.0.2.2:9099) + +## Future Enhancements + +The Credential Manager API is ready for: +- **Passkeys** - Passwordless authentication (coming soon) +- **Biometric Auth** - Fingerprint/face authentication +- **Cross-device Credentials** - Sync credentials across devices +- **Third-party Password Managers** - Integration with 1Password, LastPass, etc. + +## Notes + +- The old `GoogleSignInHelper.kt` file can be deleted +- Minor warning about context leak is acceptable for ViewModels with application context +- The `getSavedCredential()` function is available but not currently used in the UI (you can add a button for it later) + diff --git a/README.md b/README.md new file mode 100644 index 00000000..8101ab65 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# 🎓 SkillBridge + +**SkillBridge** is a peer-to-peer learning marketplace that connects students who want to learn with other students who can teach. + +Our mission is to make learning **affordable, flexible, and community-driven** by leveraging the skills already available within the student community. + +Many students struggle to afford professional tutoring, while others want to earn money or gain experience by teaching what they know. + +With **SkillBridge**: +- Learners get affordable help in both academics and hobbies. +- Tutors earn extra income and experience by sharing their skills. +- The student community becomes a self-sustaining support network. + +--- + +## 🎯 Target Users +- **Ava**: a first-year student looking for affordable math tutoring. +- **Liam**: a third-year student who wants to earn money by offering piano lessons. + +--- + +## 🚀 Features +- 🔐 Secure sign-up & login via email or university SSO with **Firebase Authentication** +- 👩‍🏫 Role-based profiles: **Learner, Tutor, or Both** +- 📍 Location-based search using **GPS** to find and sort nearby tutors on a map +- 📝 Booking system for lessons and scheduling +- ⭐ Ratings and reviews for tutors +- 💾 **Offline mode**: access to profiles, saved tutors, and booked lessons without internet + +--- + +## 🏗️ Tech Stack +- **Frontend**: Mobile app (Kotlin) +- **Backend**: Google Firebase (Cloud Firestore, Authentication, Cloud Functions) +- **Device Features**: GPS/location services, local caching for offline support + +--- + +## 📡 Offline Mode +- ✅ Available offline: profile, saved tutors, booked lessons +- 🔄 Online required: new tutor listings, updated ratings, personalized recommendations + + + +## 🔒 Security +- Accounts managed with **Firebase Authentication** +- Role-based permissions (Learner / Tutor) +- Data stored securely in **Cloud Firestore** with strict access rules + + +## 🎨 Design (Figma) +We use **Figma** to create mockups and track design work. + +- 🔗 [SkillSwap Mockup on Figma](https://www.figma.com/design/KLu1v4Q1ahcIgpufrbxQCV/SkillBridge-mockup?node-id=0-1&t=MaZllQ2pNaWYwCoW-1) +- ✅ All team members have **edit access**. +- 👩‍💻 **Dev Mode** is enabled so developers can inspect styles and assets. +- 🌍 File is set to **public view** so course staff can access it. + diff --git a/app/.gitignore b/app/.gitignore index 374aced3..b376feee 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -2,4 +2,6 @@ /build /app/build -local.properties \ No newline at end of file +local.properties + +google-services.json \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80ee25fc..d2c903a1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,23 @@ plugins { alias(libs.plugins.ktfmt) alias(libs.plugins.sonar) id("jacoco") + id("com.google.gms.google-services") +} + +// Force JaCoCo version to support Java 21 +configurations.all { + resolutionStrategy { + force("org.jacoco:org.jacoco.core:0.8.11") + force("org.jacoco:org.jacoco.agent:0.8.11") + force("org.jacoco:org.jacoco.report:0.8.11") + force("com.google.protobuf:protobuf-javalite:3.21.12") + } +} + +configurations.matching { + it.name.contains("androidTest", ignoreCase = true) +}.all { + exclude(group = "com.google.protobuf", module = "protobuf-lite") } android { @@ -23,23 +40,40 @@ android { } } + signingConfigs { + getByName("debug") { + keyAlias = "androiddebugkey" + keyPassword = "android" + storeFile = file("debug.keystore") + storePassword = "android" + } + create("release") { + keyAlias = "androiddebugkey" + keyPassword = "android" + storeFile = file("debug.keystore") + storePassword = "android" + } + } + buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + signingConfig = signingConfigs.getByName("release") } debug { enableUnitTestCoverage = true enableAndroidTestCoverage = true + signingConfig = signingConfigs.getByName("debug") } } testCoverage { - jacocoVersion = "0.8.8" + jacocoVersion = "0.8.11" } buildFeatures { @@ -47,16 +81,16 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = "1.4.2" + kotlinCompilerExtensionVersion = "1.5.1" } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } packaging { @@ -93,12 +127,13 @@ android { sonar { properties { - property("sonar.projectKey", "gf_android-sample") - property("sonar.projectName", "Android-Sample") - property("sonar.organization", "gabrielfleischer") + property("sonar.projectKey", "SkillBridgeee_SkillBridgeee") + property("sonar.projectName", "SkillBridgeee") + property("sonar.organization", "skilbridge") property("sonar.host.url", "https://sonarcloud.io") + property("sonar.gradle.skipCompile", "true") // Comma-separated paths to the various directories containing the *.xml JUnit report files. Each path may be absolute or relative to the project base directory. - property("sonar.junit.reportPaths", "${project.layout.buildDirectory.get()}/test-results/testDebugunitTest/") + property("sonar.junit.reportPaths", "${project.layout.buildDirectory.get()}/test-results/testDebugUnitTest/") // Paths to xml files with Android Lint issues. If the main flavor is changed, this file will have to be changed too. property("sonar.androidLint.reportPaths", "${project.layout.buildDirectory.get()}/reports/lint-results-debug.xml") // Paths to JaCoCo XML coverage report files. @@ -111,7 +146,6 @@ fun DependencyHandlerScope.globalTestImplementation(dep: Any) { androidTestImplementation(dep) testImplementation(dep) } - dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) @@ -122,6 +156,39 @@ dependencies { globalTestImplementation(libs.androidx.junit) globalTestImplementation(libs.androidx.espresso.core) + // Testing dependencies for Mockito and coroutines + testImplementation(libs.mockito) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockk) + testImplementation(libs.coroutines.test) + testImplementation(libs.arch.core.testing) + + implementation(libs.okhttp) + + // Firebase + implementation(libs.firebase.database.ktx) + implementation(libs.firebase.firestore) + implementation(libs.firebase.ui.auth) + implementation(libs.firebase.auth.ktx) + implementation(libs.firebase.auth) + + // Firebase Testing dependencies + testImplementation("com.google.firebase:firebase-auth:22.3.0") + testImplementation("org.robolectric:robolectric:4.11.1") + testImplementation("androidx.test:core:1.5.0") + + implementation("com.google.protobuf:protobuf-javalite:3.21.12") + testImplementation("com.google.protobuf:protobuf-javalite:3.21.12") + androidTestImplementation("com.google.protobuf:protobuf-javalite:3.21.12") + + // Google Play Services for Google Sign-In + implementation(libs.play.services.auth) + + // Credential Manager + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.play.services) + implementation(libs.googleid) + // ------------- Jetpack Compose ------------------ val composeBom = platform(libs.compose.bom) implementation(composeBom) @@ -148,6 +215,9 @@ dependencies { // ---------- Robolectric ------------ testImplementation(libs.robolectric) + + implementation("androidx.navigation:navigation-compose:2.8.0") + } tasks.withType { @@ -158,6 +228,10 @@ tasks.withType { } } +jacoco { + toolVersion = "0.8.11" +} + tasks.register("jacocoTestReport", JacocoReport::class) { mustRunAfter("testDebugUnitTest", "connectedDebugAndroidTest") diff --git a/app/debug.keystore b/app/debug.keystore new file mode 100644 index 00000000..e4db0397 Binary files /dev/null and b/app/debug.keystore differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb434..75819d07 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,18 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# Firebase UI Auth uses deprecated Credentials API (Smart Lock for Passwords) +# These classes are no longer available but FirebaseUI can work without them +-dontwarn com.google.android.gms.auth.api.credentials.Credential$Builder +-dontwarn com.google.android.gms.auth.api.credentials.Credential +-dontwarn com.google.android.gms.auth.api.credentials.CredentialRequest$Builder +-dontwarn com.google.android.gms.auth.api.credentials.CredentialRequest +-dontwarn com.google.android.gms.auth.api.credentials.CredentialRequestResponse +-dontwarn com.google.android.gms.auth.api.credentials.Credentials +-dontwarn com.google.android.gms.auth.api.credentials.CredentialsClient +-dontwarn com.google.android.gms.auth.api.credentials.CredentialsOptions$Builder +-dontwarn com.google.android.gms.auth.api.credentials.CredentialsOptions +-dontwarn com.google.android.gms.auth.api.credentials.HintRequest$Builder +-dontwarn com.google.android.gms.auth.api.credentials.HintRequest diff --git a/app/src/androidTest/java/com/android/sample/End2EndTest.kt b/app/src/androidTest/java/com/android/sample/End2EndTest.kt new file mode 100644 index 00000000..6f64864d --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/End2EndTest.kt @@ -0,0 +1,233 @@ +package com.android.sample + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import com.android.sample.model.booking.BookingRepositoryProvider +import com.android.sample.model.listing.ListingRepositoryProvider +import com.android.sample.model.rating.RatingRepositoryProvider +import com.android.sample.model.user.ProfileRepositoryProvider +import com.android.sample.ui.bookings.MyBookingsPageTestTag +import com.android.sample.ui.components.BottomNavBarTestTags +import com.android.sample.ui.components.TopAppBarTestTags +import com.android.sample.ui.login.SignInScreenTestTags +import com.android.sample.ui.navigation.RouteStackManager +import com.android.sample.ui.profile.MyProfileScreenTestTag +import com.android.sample.ui.subject.SubjectListTestTags +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class End2EndTest { + + @get:Rule val composeTestRule = createAndroidComposeRule() + + @Before + fun initRepositories() { + RouteStackManager.clear() + val ctx = InstrumentationRegistry.getInstrumentation().targetContext + try { + ProfileRepositoryProvider.init(ctx) + ListingRepositoryProvider.init(ctx) + BookingRepositoryProvider.init( + ctx) // prevents IllegalStateException in ViewModel construction + RatingRepositoryProvider.init(ctx) + } catch (e: Exception) { + // Initialization may fail in some CI/emulator setups; log and continue + println("Repository init failed: ${e.message}") + } + } + + @Test + fun userLogsInAsLearnerAndGoesToMainPage() { + // In the login screen, click the GitHub login button to simulate login + composeTestRule.onNodeWithTag(SignInScreenTestTags.AUTH_GITHUB).performClick() + composeTestRule.waitForIdle() + + // Verify that we are now on the main page by checking for a main page element + composeTestRule.onNodeWithTag(HomeScreenTestTags.WELCOME_SECTION).assertExists() + composeTestRule.onNodeWithTag(HomeScreenTestTags.FAB_ADD).assertExists() + + // Verify the TopAppBar is present at the top and displays the right things + composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).assertExists() + val node = composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).fetchSemanticsNode() + + val text = node.config[SemanticsProperties.Text].joinToString("") + val textTest = text.equals("Home") + assert(textTest) + + composeTestRule.onNodeWithTag(TopAppBarTestTags.NAVIGATE_BACK).assertDoesNotExist() + + // Verify the BottomAppBar is present at the bottom and displays the right things + composeTestRule.onNodeWithTag(BottomNavBarTestTags.BOTTOM_NAV_BAR).assertExists() + + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_HOME).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_BOOKINGS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_SKILLS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_PROFILE).assertExists() + } + + /*@Test + fun userLogsInAndViewsTutorProfile() { + // In the login screen, click the GitHub login button to simulate login + composeTestRule.onNodeWithTag(SignInScreenTestTags.AUTH_GITHUB).performClick() + composeTestRule.waitForIdle() + + // Verify that we are now on the main page by checking for a main page element + composeTestRule.onNodeWithTag(HomeScreenTestTags.WELCOME_SECTION).assertExists() + composeTestRule.onNodeWithTag(HomeScreenTestTags.FAB_ADD).assertExists() + + // Verify the TopAppBar is present at the top and displays the right things + composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).assertExists() + + composeTestRule.onNodeWithTag(TopAppBarTestTags.NAVIGATE_BACK).assertDoesNotExist() + + // Verify the BottomAppBar is present at the bottom and displays the right things + composeTestRule.onNodeWithTag(BottomNavBarTestTags.BOTTOM_NAV_BAR).assertExists() + + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_HOME).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_BOOKINGS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_SKILLS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_PROFILE).assertExists() + + // User navigates to Profile tab + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_PROFILE).performClick() + composeTestRule.waitForIdle() + + // Verify the BottomAppBar is present at the bottom and displays the right things + composeTestRule.onNodeWithTag(BottomNavBarTestTags.BOTTOM_NAV_BAR).assertExists() + + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_HOME).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_BOOKINGS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_SKILLS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_PROFILE).assertExists() + + // Verify the TopAppBar is present at the top and displays the right things + composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).assertExists() + val node = composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).fetchSemanticsNode() + + val text = node.config[SemanticsProperties.Text].joinToString("") + val textTest = text.equals("Profile") + assert(textTest) + + composeTestRule.onNodeWithTag(TopAppBarTestTags.NAVIGATE_BACK).assertExists() + + // Verify we are on the profile page by checking for a profile page element + composeTestRule.onNodeWithTag(MyProfileScreenTestTag.PROFILE_ICON).assertExists() + composeTestRule.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_EMAIL).assertExists() + composeTestRule.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_DESC).assertExists() + composeTestRule.onNodeWithTag(MyProfileScreenTestTag.CARD_TITLE).assertExists() + composeTestRule.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_LOCATION).assertExists() + composeTestRule.onNodeWithTag(MyProfileScreenTestTag.NAME_DISPLAY).assertExists() + composeTestRule.onNodeWithTag(MyProfileScreenTestTag.SAVE_BUTTON).assertExists() + composeTestRule.onNodeWithTag(MyProfileScreenTestTag.ROLE_BADGE).assertExists() + + // Go back to home + composeTestRule.onNodeWithTag(TopAppBarTestTags.NAVIGATE_BACK).performClick() + composeTestRule.waitForIdle() + + // Verify that we are now on the main page by checking for a main page element + composeTestRule.onNodeWithTag(HomeScreenTestTags.WELCOME_SECTION).assertExists() + composeTestRule.onNodeWithTag(HomeScreenTestTags.FAB_ADD).assertExists() + }*/ + + @Test + fun userLogsInAsTutorAndGoesToSkills() { + + composeTestRule.onNodeWithTag(SignInScreenTestTags.ROLE_TUTOR).performClick() + composeTestRule.onNodeWithTag(SignInScreenTestTags.AUTH_GITHUB).performClick() + composeTestRule.waitForIdle() + + // Verify that we are now on the main page by checking for a main page element + composeTestRule.onNodeWithTag(HomeScreenTestTags.WELCOME_SECTION).assertExists() + composeTestRule.onNodeWithTag(HomeScreenTestTags.FAB_ADD).assertExists() + + // Verify the TopAppBar is present at the top and displays the right things + composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).assertExists() + + composeTestRule.onNodeWithTag(TopAppBarTestTags.NAVIGATE_BACK).assertDoesNotExist() + + // Go to Skills tab + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_SKILLS).performClick() + composeTestRule.waitForIdle() + + // Verify the BottomAppBar is present at the bottom and displays the right things + composeTestRule.onNodeWithTag(BottomNavBarTestTags.BOTTOM_NAV_BAR).assertExists() + + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_HOME).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_BOOKINGS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_SKILLS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_PROFILE).assertExists() + + // Verify the TopAppBar is present at the top and displays the right things + + composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).assertExists() + val node = composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).fetchSemanticsNode() + + val text = node.config[SemanticsProperties.Text].joinToString("") + val textTest = text.equals("skills") + assert(textTest) + + composeTestRule.onNodeWithTag(TopAppBarTestTags.NAVIGATE_BACK).assertExists() + + // Verify we are on the skills page by checking for a skills page element + composeTestRule.onNodeWithTag(SubjectListTestTags.SEARCHBAR).assertExists() + + // Go back to home + composeTestRule.onNodeWithTag(TopAppBarTestTags.NAVIGATE_BACK).performClick() + composeTestRule.waitForIdle() + + // Verify that we are now on the main page by checking for a main page element + composeTestRule.onNodeWithTag(HomeScreenTestTags.WELCOME_SECTION).assertExists() + composeTestRule.onNodeWithTag(HomeScreenTestTags.FAB_ADD).assertExists() + } + + @Test + fun userLogsInAsTutorAndViewsBookings() { + + composeTestRule.onNodeWithTag(SignInScreenTestTags.ROLE_TUTOR).performClick() + composeTestRule.onNodeWithTag(SignInScreenTestTags.AUTH_GITHUB).performClick() + composeTestRule.waitForIdle() + + // Verify that we are now on the main page by checking for a main page element + composeTestRule.onNodeWithTag(HomeScreenTestTags.WELCOME_SECTION).assertExists() + composeTestRule.onNodeWithTag(HomeScreenTestTags.FAB_ADD).assertExists() + + // Verify the TopAppBar is present at the top and displays the right things + composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).assertExists() + + composeTestRule.onNodeWithTag(TopAppBarTestTags.NAVIGATE_BACK).assertDoesNotExist() + + // Go to Bookings tab + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_BOOKINGS).performClick() + composeTestRule.waitForIdle() + + // Verify the BottomAppBar is present at the bottom and displays the right things + composeTestRule.onNodeWithTag(BottomNavBarTestTags.BOTTOM_NAV_BAR).assertExists() + + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_HOME).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_BOOKINGS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_SKILLS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_PROFILE).assertExists() + + // Verify the TopAppBar is present at the top and displays the right things + + composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).assertExists() + val node = composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).fetchSemanticsNode() + + val text = node.config[SemanticsProperties.Text].joinToString("") + val textTest = text.equals("My Bookings") + assert(textTest) + + composeTestRule.onNodeWithTag(TopAppBarTestTags.NAVIGATE_BACK).assertExists() + + // Verify we are on the bookings page by checking for a bookings page element + composeTestRule.onNodeWithTag(MyBookingsPageTestTag.EMPTY_BOOKINGS).assertExists() + + // Go back to home + composeTestRule.onNodeWithTag(TopAppBarTestTags.NAVIGATE_BACK).performClick() + composeTestRule.waitForIdle() + } +} diff --git a/app/src/androidTest/java/com/android/sample/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/android/sample/ExampleInstrumentedTest.kt deleted file mode 100644 index b64ab325..00000000 --- a/app/src/androidTest/java/com/android/sample/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.android.sample - -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.android.sample.screen.MainScreen -import com.kaspersky.kaspresso.testcases.api.testcase.TestCase -import io.github.kakaocup.compose.node.element.ComposeScreen -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class MainActivityTest : TestCase() { - - @get:Rule val composeTestRule = createAndroidComposeRule() - - @Test - fun test() = run { - step("Start Main Activity") { - ComposeScreen.onComposeScreen(composeTestRule) { - simpleText { - assertIsDisplayed() - assertTextEquals("Hello Android!") - } - } - } - } -} diff --git a/app/src/androidTest/java/com/android/sample/MainActivityTest.kt b/app/src/androidTest/java/com/android/sample/MainActivityTest.kt new file mode 100644 index 00000000..c9b38dc0 --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/MainActivityTest.kt @@ -0,0 +1,75 @@ +package com.android.sample + +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.sample.model.authentication.AuthenticationViewModel +import com.android.sample.model.booking.BookingRepositoryProvider +import com.android.sample.model.listing.ListingRepositoryProvider +import com.android.sample.model.rating.RatingRepositoryProvider +import com.android.sample.model.user.ProfileRepositoryProvider +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Before + fun initRepositories() { + val ctx = InstrumentationRegistry.getInstrumentation().targetContext + try { + ProfileRepositoryProvider.init(ctx) + ListingRepositoryProvider.init(ctx) + BookingRepositoryProvider.init(ctx) + RatingRepositoryProvider.init(ctx) + } catch (e: Exception) { + // Initialization may fail in some CI/emulator setups; log and continue + println("Repository init failed: ${e.message}") + } + } + + @Test + fun mainApp_composable_renders_without_crashing() { + composeTestRule.setContent { + MainApp( + authViewModel = + AuthenticationViewModel(InstrumentationRegistry.getInstrumentation().targetContext), + onGoogleSignIn = {}) + } + + // Verify that the main app structure is rendered + composeTestRule.onRoot().assertExists() + } + + @Test + fun mainApp_contains_navigation_components() { + composeTestRule.setContent { + MainApp( + authViewModel = + AuthenticationViewModel(InstrumentationRegistry.getInstrumentation().targetContext), + onGoogleSignIn = {}) + } + + // First navigate from login to main app by clicking GitHub + composeTestRule.onNodeWithText("GitHub").performClick() + composeTestRule.waitForIdle() + + // Now verify bottom navigation exists + composeTestRule.onNodeWithText("Skills").assertExists() + composeTestRule.onNodeWithText("Profile").assertExists() + composeTestRule.onNodeWithText("Bookings").assertExists() + + // Test for Home in bottom nav specifically + composeTestRule.onAllNodes(hasText("Home")).fetchSemanticsNodes().let { nodes -> + assert(nodes.isNotEmpty()) // Verify at least one "Home" exists + } + } +} diff --git a/app/src/androidTest/java/com/android/sample/components/BottomNavBarTest.kt b/app/src/androidTest/java/com/android/sample/components/BottomNavBarTest.kt new file mode 100644 index 00000000..53f96f4e --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/components/BottomNavBarTest.kt @@ -0,0 +1,131 @@ +package com.android.sample.components + +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.test.platform.app.InstrumentationRegistry +import com.android.sample.MainPageViewModel +import com.android.sample.MyViewModelFactory +import com.android.sample.model.authentication.AuthenticationViewModel +import com.android.sample.model.booking.BookingRepositoryProvider +import com.android.sample.model.listing.ListingRepositoryProvider +import com.android.sample.model.rating.RatingRepositoryProvider +import com.android.sample.model.user.ProfileRepositoryProvider +import com.android.sample.ui.bookings.MyBookingsViewModel +import com.android.sample.ui.components.BottomNavBar +import com.android.sample.ui.components.BottomNavBarTestTags +import com.android.sample.ui.navigation.AppNavGraph +import com.android.sample.ui.profile.MyProfileViewModel +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class BottomNavBarTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Before + fun initRepositories() { + val ctx = InstrumentationRegistry.getInstrumentation().targetContext + try { + ProfileRepositoryProvider.init(ctx) + ListingRepositoryProvider.init(ctx) + BookingRepositoryProvider.init( + ctx) // prevents IllegalStateException in ViewModel construction + RatingRepositoryProvider.init(ctx) + } catch (e: Exception) { + // Initialization may fail in some CI/emulator setups; log and continue + println("Repository init failed: ${e.message}") + } + } + + @Test + fun bottomNavBar_displays_all_navigation_items() { + composeTestRule.setContent { + val navController = rememberNavController() + BottomNavBar(navController = navController) + } + + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_HOME).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_BOOKINGS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_SKILLS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_PROFILE).assertExists() + } + + @Test + fun bottomNavBar_renders_without_crashing() { + composeTestRule.setContent { + val navController = rememberNavController() + BottomNavBar(navController = navController) + } + + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_HOME).assertExists() + } + + @Test + fun bottomNavBar_has_correct_number_of_items() { + composeTestRule.setContent { + val navController = rememberNavController() + BottomNavBar(navController = navController) + } + + // Should have exactly 4 navigation items + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_HOME).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_BOOKINGS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_SKILLS).assertExists() + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_PROFILE).assertExists() + } + + @Test + fun bottomNavBar_navigation_changes_destination() { + var currentDestination: String? = null + + composeTestRule.setContent { + val navController = rememberNavController() + val currentUserId = "test" + val factory = MyViewModelFactory(currentUserId) + + val bookingsViewModel: MyBookingsViewModel = viewModel(factory = factory) + val profileViewModel: MyProfileViewModel = viewModel(factory = factory) + val mainPageViewModel: MainPageViewModel = viewModel(factory = factory) + + // Track current destination + val navBackStackEntry by navController.currentBackStackEntryAsState() + currentDestination = navBackStackEntry?.destination?.route + + AppNavGraph( + navController = navController, + bookingsViewModel = bookingsViewModel, + profileViewModel = profileViewModel, + mainPageViewModel = mainPageViewModel, + authViewModel = + AuthenticationViewModel(InstrumentationRegistry.getInstrumentation().targetContext), + onGoogleSignIn = {}) + BottomNavBar(navController = navController) + } + + // Start at login, navigate to home first + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_HOME).performClick() + composeTestRule.waitForIdle() + assert(currentDestination == "home") + + // Test Skills navigation + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_SKILLS).performClick() + composeTestRule.waitForIdle() + assert(currentDestination == "skills") + + // Test Bookings navigation + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_BOOKINGS).performClick() + composeTestRule.waitForIdle() + assert(currentDestination == "bookings") + + // Test Profile navigation + composeTestRule.onNodeWithTag(BottomNavBarTestTags.NAV_PROFILE).performClick() + composeTestRule.waitForIdle() + assert(currentDestination == "profile/{profileId}") + } +} diff --git a/app/src/androidTest/java/com/android/sample/components/RatingStarsTest.kt b/app/src/androidTest/java/com/android/sample/components/RatingStarsTest.kt new file mode 100644 index 00000000..de516339 --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/components/RatingStarsTest.kt @@ -0,0 +1,36 @@ +package com.android.sample.components + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import com.android.sample.ui.components.RatingStars +import com.android.sample.ui.components.RatingStarsTestTags +import org.junit.Rule +import org.junit.Test + +class RatingStarsTest { + + @get:Rule val compose = createComposeRule() + + @Test + fun renders_correct_number_of_stars() { + compose.setContent { RatingStars(ratingOutOfFive = 3.0) } + + compose.onAllNodesWithTag(RatingStarsTestTags.FILLED_STAR).assertCountEquals(3) + compose.onAllNodesWithTag(RatingStarsTestTags.OUTLINED_STAR).assertCountEquals(2) + } + + @Test + fun clamps_below_zero_to_zero() { + compose.setContent { RatingStars(ratingOutOfFive = -2.0) } + compose.onAllNodesWithTag(RatingStarsTestTags.FILLED_STAR).assertCountEquals(0) + compose.onAllNodesWithTag(RatingStarsTestTags.OUTLINED_STAR).assertCountEquals(5) + } + + @Test + fun clamps_above_five_to_five() { + compose.setContent { RatingStars(ratingOutOfFive = 10.0) } + compose.onAllNodesWithTag(RatingStarsTestTags.FILLED_STAR).assertCountEquals(5) + compose.onAllNodesWithTag(RatingStarsTestTags.OUTLINED_STAR).assertCountEquals(0) + } +} diff --git a/app/src/androidTest/java/com/android/sample/components/SkillChipTest.kt b/app/src/androidTest/java/com/android/sample/components/SkillChipTest.kt new file mode 100644 index 00000000..cf67856e --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/components/SkillChipTest.kt @@ -0,0 +1,47 @@ +package com.android.sample.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import com.android.sample.model.skill.ExpertiseLevel +import com.android.sample.model.skill.MainSubject +import com.android.sample.model.skill.Skill +import com.android.sample.ui.components.SkillChip +import com.android.sample.ui.components.SkillChipTestTags +import org.junit.Rule +import org.junit.Test + +class SkillChipTest { + + @get:Rule val compose = createComposeRule() + + @Test + fun chip_is_displayed() { + val skill = Skill(MainSubject.MUSIC, "PIANO", 2.0, ExpertiseLevel.INTERMEDIATE) + compose.setContent { SkillChip(skill = skill) } + + compose.onNodeWithTag(SkillChipTestTags.CHIP, useUnmergedTree = true).assertIsDisplayed() + compose.onNodeWithTag(SkillChipTestTags.TEXT, useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun formats_integer_years_and_level_lowercase() { + val skill = Skill(MainSubject.MUSIC, "DATA_SCIENCE", 10.0, ExpertiseLevel.EXPERT) + compose.setContent { SkillChip(skill = skill) } + + compose + .onNodeWithTag(SkillChipTestTags.TEXT, useUnmergedTree = true) + .assertTextEquals("Data science: 10 years, expert") + } + + @Test + fun formats_decimal_years_and_capitalizes_name() { + val skill = Skill(MainSubject.MUSIC, "VOCAL_TRAINING", 1.5, ExpertiseLevel.BEGINNER) + compose.setContent { SkillChip(skill = skill) } + + compose + .onNodeWithTag(SkillChipTestTags.TEXT, useUnmergedTree = true) + .assertTextEquals("Vocal training: 1.5 years, beginner") + } +} diff --git a/app/src/androidTest/java/com/android/sample/components/TopAppBarTest.kt b/app/src/androidTest/java/com/android/sample/components/TopAppBarTest.kt new file mode 100644 index 00000000..a4b4653d --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/components/TopAppBarTest.kt @@ -0,0 +1,48 @@ +package com.android.sample.components + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.navigation.NavHostController +import androidx.test.core.app.ApplicationProvider +import com.android.sample.ui.components.TopAppBar +import com.android.sample.ui.components.TopAppBarTestTags +import org.junit.Rule +import org.junit.Test + +class TopAppBarTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun topAppBar_renders_without_crashing() { + composeTestRule.setContent { + TopAppBar(navController = NavHostController(ApplicationProvider.getApplicationContext())) + } + + // Basic test that the component renders + composeTestRule.onNodeWithTag(TopAppBarTestTags.TOP_APP_BAR).assertExists() + composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).assertExists() + } + + @Test + fun topAppBar_shows_default_title_when_no_route() { + composeTestRule.setContent { + TopAppBar(navController = NavHostController(ApplicationProvider.getApplicationContext())) + } + + // Should show default title when no route is set + composeTestRule.onNodeWithTag(TopAppBarTestTags.TOP_APP_BAR).assertExists() + composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).assertExists() + } + + @Test + fun topAppBar_displays_title() { + composeTestRule.setContent { + TopAppBar(navController = NavHostController(ApplicationProvider.getApplicationContext())) + } + + // Test for the expected title text directly + composeTestRule.onNodeWithTag(TopAppBarTestTags.TOP_APP_BAR).assertExists() + composeTestRule.onNodeWithTag(TopAppBarTestTags.DISPLAY_TITLE).assertExists() + } +} diff --git a/app/src/androidTest/java/com/android/sample/components/TutorCardTest.kt b/app/src/androidTest/java/com/android/sample/components/TutorCardTest.kt new file mode 100644 index 00000000..8bca43bc --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/components/TutorCardTest.kt @@ -0,0 +1,134 @@ +package com.android.sample.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.android.sample.model.map.Location +import com.android.sample.model.rating.RatingInfo +import com.android.sample.model.user.Profile +import com.android.sample.ui.components.TutorCard +import com.android.sample.ui.components.TutorCardTestTags +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +// Ai generated tests for the TutorCard composable +class TutorCardTest { + + @get:Rule val composeTestRule = createComposeRule() + + private fun fakeProfile( + name: String = "Alice Martin", + description: String = "Tutor 1", + rating: RatingInfo = RatingInfo(averageRating = 4.5, totalRatings = 23) + ) = + Profile( + userId = "tutor-1", + name = name, + email = "alice@epfl.ch", + location = Location(0.0, 0.0, "EPFL"), + description = description, + tutorRating = rating) + + @Test + fun card_showsNameSubtitlePriceAndButton() { + val p = fakeProfile() + + composeTestRule.setContent { + MaterialTheme { + TutorCard( + profile = p, + pricePerHour = "$25/hr", + onPrimaryAction = {}, + ) + } + } + + composeTestRule.onNodeWithTag(TutorCardTestTags.CARD).assertIsDisplayed() + composeTestRule.onNodeWithText("Alice Martin").assertIsDisplayed() + composeTestRule.onNodeWithText("Tutor 1").assertIsDisplayed() + composeTestRule.onNodeWithText("$25/hr").assertIsDisplayed() + composeTestRule.onNodeWithTag(TutorCardTestTags.ACTION_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithText("Book").assertIsDisplayed() + // rating count text e.g. "(23)" + composeTestRule.onNodeWithText("(23)").assertIsDisplayed() + } + + @Test + fun card_usesPlaceholderPriceWhenNull() { + val p = fakeProfile() + + composeTestRule.setContent { + MaterialTheme { + TutorCard( + profile = p, + pricePerHour = null, + onPrimaryAction = {}, + ) + } + } + + composeTestRule.onNodeWithText("—/hr").assertIsDisplayed() + } + + @Test + fun button_clickInvokesCallback() { + val p = fakeProfile() + var clicked = false + + composeTestRule.setContent { + MaterialTheme { + TutorCard( + profile = p, + onPrimaryAction = { clicked = true }, + ) + } + } + + composeTestRule.onNodeWithTag(TutorCardTestTags.ACTION_BUTTON).performClick() + composeTestRule.runOnIdle { assertTrue(clicked) } + } + + @Test + fun customTags_areApplied() { + val p = fakeProfile() + + val customCardTag = "CustomCardTag" + val customButtonTag = "CustomButtonTag" + + composeTestRule.setContent { + MaterialTheme { + TutorCard( + profile = p, + pricePerHour = "$10/hr", + onPrimaryAction = {}, + cardTestTag = customCardTag, + buttonTestTag = customButtonTag) + } + } + + composeTestRule.onNodeWithTag(customCardTag).assertIsDisplayed() + composeTestRule.onNodeWithTag(customButtonTag).assertIsDisplayed() + } + + @Test + fun customButtonLabel_isShown() { + val p = fakeProfile() + + composeTestRule.setContent { + MaterialTheme { + TutorCard( + profile = p, + pricePerHour = "$40/hr", + buttonLabel = "Contact", + onPrimaryAction = {}, + ) + } + } + + composeTestRule.onNodeWithText("Contact").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/com/android/sample/navigation/NavGraphTest.kt b/app/src/androidTest/java/com/android/sample/navigation/NavGraphTest.kt new file mode 100644 index 00000000..43e5ed82 --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/navigation/NavGraphTest.kt @@ -0,0 +1,168 @@ +package com.android.sample.navigation + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.android.sample.MainActivity +import com.android.sample.ui.navigation.NavRoutes +import com.android.sample.ui.navigation.RouteStackManager +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * AppNavGraphTest + * + * Instrumentation tests for verifying that AppNavGraph correctly maps routes to screens. These + * tests confirm that navigating between destinations renders the correct composables. + */ +class AppNavGraphTest { + + @get:Rule val composeTestRule = createAndroidComposeRule() + + @Before + fun setUp() { + RouteStackManager.clear() + } + + @Test + fun login_navigates_to_home() { + // Click GitHub login button to navigate to home + composeTestRule.onNodeWithText("GitHub").performClick() + composeTestRule.waitForIdle() + + // Should now be on home screen - check for home screen elements + composeTestRule.onNodeWithText("Ready to learn something new today?").assertExists() + composeTestRule.onNodeWithText("Explore skills").assertExists() + composeTestRule.onNodeWithText("Top-Rated Tutors").assertExists() + } + + @Test + fun navigating_to_skills_displays_skills_screen() { + // First login to get to main app + composeTestRule.onNodeWithText("GitHub").performClick() + composeTestRule.waitForIdle() + + // Navigate to skills + composeTestRule.onNodeWithText("Skills").performClick() + composeTestRule.waitForIdle() + + // Should display skills screen content + composeTestRule.onNodeWithText("Find a tutor about...").assertExists() + } + + @Test + fun navigating_to_profile_displays_profile_screen() { + // Login first + composeTestRule.onNodeWithText("GitHub").performClick() + composeTestRule.waitForIdle() + + // Navigate to profile + composeTestRule.onNodeWithText("Profile").performClick() + composeTestRule.waitForIdle() + + // Should display profile screen - check for profile screen elements + composeTestRule.onNodeWithText("Student").assertExists() + composeTestRule.onNodeWithText("Personal Details").assertExists() + composeTestRule.onNodeWithText("Save Profile Changes").assertExists() + } + + @Test + fun navigating_to_bookings_displays_bookings_screen() { + // Login first + composeTestRule.onNodeWithText("GitHub").performClick() + composeTestRule.waitForIdle() + + // Navigate to bookings + composeTestRule.onNodeWithText("Bookings").performClick() + composeTestRule.waitForIdle() + + // Should display bookings screen + composeTestRule.onNodeWithText("My Bookings").assertExists() + } + + @Test + fun navigating_to_new_skill_from_home() { + // Login first + composeTestRule.onNodeWithText("GitHub").performClick() + composeTestRule.waitForIdle() + + // Click the add skill button on home screen (FAB) + composeTestRule.onNodeWithContentDescription("Add").performClick() + composeTestRule.waitForIdle() + + // Should navigate to new skill screen + composeTestRule.onNodeWithText("Create Your Lessons !").assertExists() + } + + @Test + fun routeStackManager_updates_on_navigation() { + // Login + composeTestRule.onNodeWithText("GitHub").performClick() + composeTestRule.waitForIdle() + assert(RouteStackManager.getCurrentRoute() == NavRoutes.HOME) + + // Navigate to skills + composeTestRule.onNodeWithText("Skills").performClick() + composeTestRule.waitForIdle() + assert(RouteStackManager.getCurrentRoute() == NavRoutes.SKILLS) + + // Navigate to profile + composeTestRule.onNodeWithText("Profile").performClick() + composeTestRule.waitForIdle() + assert(RouteStackManager.getCurrentRoute() == NavRoutes.PROFILE) + } + + @Test + fun bottom_nav_resets_stack_correctly() { + // Login + composeTestRule.onNodeWithText("GitHub").performClick() + composeTestRule.waitForIdle() + + // Navigate to skills then profile + composeTestRule.onNodeWithText("Skills").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Profile").performClick() + composeTestRule.waitForIdle() + + // Navigate back to home via bottom nav + composeTestRule.onNodeWithText("Home").performClick() + composeTestRule.waitForIdle() + + // Should be on home screen - check for actual home content + composeTestRule.onNodeWithText("Ready to learn something new today?").assertExists() + composeTestRule.onNodeWithText("Explore skills").assertExists() + composeTestRule.onNodeWithText("Top-Rated Tutors").assertExists() + assert(RouteStackManager.getCurrentRoute() == NavRoutes.HOME) + } + + @Test + fun skills_screen_has_search_and_category() { + // Login and navigate to skills + composeTestRule.onNodeWithText("GitHub").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Skills").performClick() + composeTestRule.waitForIdle() + + // Verify skills screen components + composeTestRule.onNodeWithText("Find a tutor about...").assertExists() + composeTestRule.onNodeWithText("Category").assertExists() + } + + @Test + fun profile_screen_has_form_fields() { + // Login and navigate to profile + composeTestRule.onNodeWithText("GitHub").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("Profile").performClick() + composeTestRule.waitForIdle() + + // Verify profile form fields exist + composeTestRule.onNodeWithText("Name").assertExists() + composeTestRule.onNodeWithText("Email").assertExists() + composeTestRule.onNodeWithText("Location / Campus").assertExists() + composeTestRule.onNodeWithText("Description").assertExists() + } +} diff --git a/app/src/androidTest/java/com/android/sample/navigation/RouteStackManagerTests.kt b/app/src/androidTest/java/com/android/sample/navigation/RouteStackManagerTests.kt new file mode 100644 index 00000000..1ddc0fab --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/navigation/RouteStackManagerTests.kt @@ -0,0 +1,137 @@ +package com.android.sample.navigation + +import com.android.sample.ui.navigation.NavRoutes +import com.android.sample.ui.navigation.RouteStackManager +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** + * RouteStackManagerTest + * + * Unit tests for the RouteStackManager singleton. + * + * These tests verify: + * - Stack operations (add, pop, clear) + * - Prevention of consecutive duplicate routes + * - Maximum stack size enforcement + * - Main route detection logic + * - Correct retrieval of current and previous routes + */ +class RouteStackManagerTest { + + @Before + fun setup() { + RouteStackManager.clear() + } + + @After + fun tearDown() { + RouteStackManager.clear() + } + + @Test + fun addRoute_adds_new_route_to_stack() { + RouteStackManager.addRoute(NavRoutes.HOME) + assertEquals(listOf(NavRoutes.HOME), RouteStackManager.getAllRoutes()) + } + + @Test + fun addRoute_does_not_add_consecutive_duplicate_routes() { + RouteStackManager.addRoute(NavRoutes.HOME) + RouteStackManager.addRoute(NavRoutes.HOME) + RouteStackManager.addRoute(NavRoutes.HOME) + assertEquals(1, RouteStackManager.getAllRoutes().size) + } + + @Test + fun addRoute_allows_duplicate_routes_if_not_consecutive() { + RouteStackManager.addRoute(NavRoutes.HOME) + RouteStackManager.addRoute(NavRoutes.SKILLS) + RouteStackManager.addRoute(NavRoutes.HOME) + assertEquals( + listOf(NavRoutes.HOME, NavRoutes.SKILLS, NavRoutes.HOME), RouteStackManager.getAllRoutes()) + } + + @Test + fun popAndGetPrevious_returns_previous_route_and_removes_last() { + RouteStackManager.addRoute(NavRoutes.HOME) + RouteStackManager.addRoute(NavRoutes.SKILLS) + RouteStackManager.addRoute(NavRoutes.PROFILE) + + val previous = RouteStackManager.popAndGetPrevious() + + assertEquals(NavRoutes.SKILLS, previous) + assertEquals(listOf(NavRoutes.HOME, NavRoutes.SKILLS), RouteStackManager.getAllRoutes()) + } + + @Test + fun popAndGetPrevious_returns_null_when_stack_empty() { + assertNull(RouteStackManager.popAndGetPrevious()) + } + + @Test + fun popRoute_removes_and_returns_last_route() { + RouteStackManager.addRoute(NavRoutes.HOME) + RouteStackManager.addRoute(NavRoutes.PROFILE) + + val popped = RouteStackManager.popRoute() + + assertEquals(NavRoutes.PROFILE, popped) + assertEquals(listOf(NavRoutes.HOME), RouteStackManager.getAllRoutes()) + } + + @Test + fun getCurrentRoute_returns_last_route_in_stack() { + RouteStackManager.addRoute(NavRoutes.HOME) + RouteStackManager.addRoute(NavRoutes.SKILLS) + + assertEquals(NavRoutes.SKILLS, RouteStackManager.getCurrentRoute()) + } + + @Test + fun clear_removes_all_routes() { + RouteStackManager.addRoute(NavRoutes.HOME) + RouteStackManager.addRoute(NavRoutes.BOOKINGS) + + RouteStackManager.clear() + + assertTrue(RouteStackManager.getAllRoutes().isEmpty()) + } + + @Test + fun isMainRoute_returns_true_for_main_routes() { + listOf(NavRoutes.HOME, NavRoutes.PROFILE, NavRoutes.SKILLS, NavRoutes.BOOKINGS).forEach { route + -> + assertTrue("$route should be a main route", RouteStackManager.isMainRoute(route)) + } + } + + @Test + fun isMainRoute_returns_false_for_non_main_routes() { + assertFalse(RouteStackManager.isMainRoute("piano_skill")) + assertFalse(RouteStackManager.isMainRoute("proposal")) + assertFalse(RouteStackManager.isMainRoute(null)) + } + + @Test + fun addRoute_discards_oldest_when_stack_exceeds_limit() { + val maxSize = 20 + // Add more than 20 routes + repeat(maxSize + 5) { i -> RouteStackManager.addRoute("route_$i") } + + val routes = RouteStackManager.getAllRoutes() + assertEquals(maxSize, routes.size) + assertEquals("route_5", routes.first()) // first 5 were discarded + assertEquals("route_24", routes.last()) // last added + } + + @Test + fun popAndGetPrevious_does_not_crash_when_called_repeatedly_on_small_stack() { + RouteStackManager.addRoute(NavRoutes.HOME) + RouteStackManager.popAndGetPrevious() + RouteStackManager.popAndGetPrevious() // should not throw + assertTrue(RouteStackManager.getAllRoutes().isEmpty()) + } +} diff --git a/app/src/androidTest/java/com/android/sample/screen/LoginScreenTest.kt b/app/src/androidTest/java/com/android/sample/screen/LoginScreenTest.kt new file mode 100644 index 00000000..66e03bcb --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/screen/LoginScreenTest.kt @@ -0,0 +1,341 @@ +package com.android.sample.screen + +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.android.sample.model.authentication.AuthenticationViewModel +import com.android.sample.ui.login.LoginScreen +import com.android.sample.ui.login.SignInScreenTestTags +import org.junit.Rule +import org.junit.Test + +class LoginScreenTest { + @get:Rule val composeRule = createComposeRule() + + @Test + fun allMainSectionsAreDisplayed() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + + composeRule.onNodeWithTag(SignInScreenTestTags.TITLE).assertIsDisplayed() + composeRule.onNodeWithTag(SignInScreenTestTags.SUBTITLE).assertIsDisplayed() + composeRule.onNodeWithTag(SignInScreenTestTags.EMAIL_INPUT).assertIsDisplayed() + composeRule.onNodeWithTag(SignInScreenTestTags.PASSWORD_INPUT).assertIsDisplayed() + composeRule.onNodeWithTag(SignInScreenTestTags.SIGN_IN_BUTTON).assertIsDisplayed() + composeRule.onNodeWithTag(SignInScreenTestTags.AUTH_SECTION).assertIsDisplayed() + composeRule.onNodeWithTag(SignInScreenTestTags.AUTH_GOOGLE).assertIsDisplayed() + composeRule.onNodeWithTag(SignInScreenTestTags.AUTH_GITHUB).assertIsDisplayed() + composeRule.onNodeWithTag(SignInScreenTestTags.SIGNUP_LINK).assertIsDisplayed() + } + + @Test + fun roleSelectionWorks() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + + val learnerNode = composeRule.onNodeWithTag(SignInScreenTestTags.ROLE_LEARNER) + val tutorNode = composeRule.onNodeWithTag(SignInScreenTestTags.ROLE_TUTOR) + + learnerNode.assertIsDisplayed() + tutorNode.assertIsDisplayed() + + tutorNode.performClick() + tutorNode.assertIsDisplayed() + + learnerNode.performClick() + learnerNode.assertIsDisplayed() + } + + @Test + fun forgotPasswordLinkWorks() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + + val forgotPasswordNode = composeRule.onNodeWithTag(SignInScreenTestTags.FORGOT_PASSWORD) + + forgotPasswordNode.assertIsDisplayed() + + forgotPasswordNode.performClick() + forgotPasswordNode.assertIsDisplayed() + } + + @Test + fun emailAndPasswordInputsWorkCorrectly() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + val mail = "guillaume.lepin@epfl.ch" + val password = "truc1234567890" + + composeRule.onNodeWithTag(SignInScreenTestTags.SIGN_IN_BUTTON) + composeRule.onNodeWithTag(SignInScreenTestTags.EMAIL_INPUT).performTextInput(mail) + composeRule.onNodeWithTag(SignInScreenTestTags.PASSWORD_INPUT).performTextInput(password) + composeRule.onNodeWithTag(SignInScreenTestTags.SIGN_IN_BUTTON).assertIsEnabled().performClick() + } + + @Test + fun signInButtonIsClickable() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + + composeRule + .onNodeWithTag(SignInScreenTestTags.SIGN_IN_BUTTON) + .assertIsDisplayed() + .assertIsNotEnabled() + composeRule.onNodeWithTag(SignInScreenTestTags.SIGN_IN_BUTTON).assertTextEquals("Sign In") + } + + @Test + fun titleIsCorrect() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + composeRule.onNodeWithTag(SignInScreenTestTags.TITLE).assertTextEquals("SkillBridge") + } + + @Test + fun subtitleIsCorrect() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + composeRule + .onNodeWithTag(SignInScreenTestTags.SUBTITLE) + .assertTextEquals("Welcome back! Please sign in.") + } + + @Test + fun learnerButtonTextIsCorrectAndIsClickable() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + composeRule.onNodeWithTag(SignInScreenTestTags.ROLE_LEARNER).assertIsDisplayed().performClick() + composeRule.onNodeWithTag(SignInScreenTestTags.ROLE_LEARNER).assertTextEquals("I'm a Learner") + } + + @Test + fun tutorButtonTextIsCorrectAndIsClickable() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + composeRule.onNodeWithTag(SignInScreenTestTags.ROLE_TUTOR).assertIsDisplayed().performClick() + composeRule.onNodeWithTag(SignInScreenTestTags.ROLE_TUTOR).assertTextEquals("I'm a Tutor") + } + + @Test + fun forgotPasswordTextIsCorrectAndIsClickable() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + composeRule + .onNodeWithTag(SignInScreenTestTags.FORGOT_PASSWORD) + .assertIsDisplayed() + .performClick() + composeRule + .onNodeWithTag(SignInScreenTestTags.FORGOT_PASSWORD) + .assertTextEquals("Forgot password?") + } + + @Test + fun signUpLinkTextIsCorrectAndIsClickable() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + composeRule.onNodeWithTag(SignInScreenTestTags.SIGNUP_LINK).assertIsDisplayed().performClick() + composeRule.onNodeWithTag(SignInScreenTestTags.SIGNUP_LINK).assertTextEquals("Sign Up") + } + + @Test + fun authSectionTextIsCorrect() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + composeRule + .onNodeWithTag(SignInScreenTestTags.AUTH_SECTION) + .assertTextEquals("or continue with") + } + + @Test + fun authGoogleButtonIsDisplayed() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + composeRule.onNodeWithTag(SignInScreenTestTags.AUTH_GOOGLE).assertIsDisplayed() + composeRule.onNodeWithTag(SignInScreenTestTags.AUTH_GOOGLE).assertTextEquals("Google") + composeRule.onNodeWithTag(SignInScreenTestTags.AUTH_GOOGLE).performClick() + } + + @Test + fun authGitHubButtonIsDisplayed() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + composeRule.onNodeWithTag(SignInScreenTestTags.AUTH_GITHUB).assertIsDisplayed() + composeRule.onNodeWithTag(SignInScreenTestTags.AUTH_GITHUB).assertTextEquals("GitHub") + composeRule.onNodeWithTag(SignInScreenTestTags.AUTH_GITHUB).performClick() + } + + @Test + fun signInButtonEnablesWhenBothEmailAndPasswordProvided() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + + // Initially disabled + composeRule.onNodeWithTag(SignInScreenTestTags.SIGN_IN_BUTTON).assertIsNotEnabled() + + // Still disabled with only email + composeRule.onNodeWithTag(SignInScreenTestTags.EMAIL_INPUT).performTextInput("test@example.com") + composeRule.onNodeWithTag(SignInScreenTestTags.SIGN_IN_BUTTON).assertIsNotEnabled() + + // Enabled with both email and password + composeRule.onNodeWithTag(SignInScreenTestTags.PASSWORD_INPUT).performTextInput("password123") + composeRule.onNodeWithTag(SignInScreenTestTags.SIGN_IN_BUTTON).assertIsEnabled() + } + + @Test + fun errorMessageDisplayedWhenAuthenticationFails() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + + // Simulate an error state + viewModel.setError("Invalid email or password") + + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + + // Check that error message is displayed + composeRule.onNodeWithText("Invalid email or password").assertIsDisplayed() + } + + @Test + fun googleSignInCallbackTriggered() { + var googleSignInCalled = false + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + LoginScreen(viewModel = viewModel, onGoogleSignIn = { googleSignInCalled = true }) + } + + composeRule.onNodeWithTag(SignInScreenTestTags.AUTH_GOOGLE).performClick() + + assert(googleSignInCalled) + } + + @Test + fun successMessageDisplayedAfterAuthentication() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + + // Simulate successful authentication + viewModel.showSuccessMessage(true) + + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + + // Check that success message components are displayed + composeRule.onNodeWithText("Authentication Successful!").assertIsDisplayed() + composeRule.onNodeWithText("Sign Out").assertIsDisplayed() + } + + @Test + fun signOutButtonWorksInSuccessState() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + + // Simulate successful authentication + viewModel.showSuccessMessage(true) + + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + + // Click sign out button + composeRule.onNodeWithText("Sign Out").performClick() + + // Should return to login form (success message should be hidden) + composeRule.onNodeWithTag(SignInScreenTestTags.TITLE).assertIsDisplayed() + } + + @Test + fun passwordResetTriggeredWhenForgotPasswordClicked() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + + // Pre-fill email for password reset + viewModel.updateEmail("test@example.com") + + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + + // Click forgot password + composeRule.onNodeWithTag(SignInScreenTestTags.FORGOT_PASSWORD).performClick() + + // The password reset function should be called (verified by no crash) + composeRule.onNodeWithTag(SignInScreenTestTags.FORGOT_PASSWORD).assertIsDisplayed() + } + + @Test + fun loadingStateShowsProgressIndicator() { + composeRule.setContent { + val context = LocalContext.current + val viewModel = AuthenticationViewModel(context) + + // Set up valid form data + viewModel.updateEmail("test@example.com") + viewModel.updatePassword("password123") + + LoginScreen(viewModel = viewModel, onGoogleSignIn = { /* Test placeholder */}) + } + + // Enter credentials and click sign in to trigger loading + composeRule.onNodeWithTag(SignInScreenTestTags.EMAIL_INPUT).performTextInput("test@example.com") + composeRule.onNodeWithTag(SignInScreenTestTags.PASSWORD_INPUT).performTextInput("password123") + + // Button should be enabled with valid inputs + composeRule.onNodeWithTag(SignInScreenTestTags.SIGN_IN_BUTTON).assertIsEnabled() + } +} diff --git a/app/src/androidTest/java/com/android/sample/screen/MainScreen.kt b/app/src/androidTest/java/com/android/sample/screen/MainScreen.kt deleted file mode 100644 index 0b01136f..00000000 --- a/app/src/androidTest/java/com/android/sample/screen/MainScreen.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.android.sample.screen - -import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import com.android.sample.resources.C -import io.github.kakaocup.compose.node.element.ComposeScreen -import io.github.kakaocup.compose.node.element.KNode - -class MainScreen(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen( - semanticsProvider = semanticsProvider, - viewBuilderAction = { hasTestTag(C.Tag.main_screen_container) }) { - - val simpleText: KNode = child { hasTestTag(C.Tag.greeting) } -} diff --git a/app/src/androidTest/java/com/android/sample/screen/MyBookingsScreenUiTest.kt b/app/src/androidTest/java/com/android/sample/screen/MyBookingsScreenUiTest.kt new file mode 100644 index 00000000..daf7b60d --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/screen/MyBookingsScreenUiTest.kt @@ -0,0 +1,287 @@ +package com.android.sample.screen + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.navigation.compose.rememberNavController +import com.android.sample.model.booking.Booking +import com.android.sample.model.booking.BookingRepository +import com.android.sample.model.booking.BookingStatus +import com.android.sample.model.listing.Listing +import com.android.sample.model.listing.ListingRepository +import com.android.sample.model.listing.Proposal +import com.android.sample.model.map.Location +import com.android.sample.model.rating.Rating +import com.android.sample.model.rating.RatingRepository +import com.android.sample.model.rating.RatingType +import com.android.sample.model.rating.StarRating +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.ui.bookings.MyBookingsPageTestTag +import com.android.sample.ui.bookings.MyBookingsScreen +import com.android.sample.ui.bookings.MyBookingsViewModel +import com.android.sample.ui.theme.SampleAppTheme +import java.util.* +import org.junit.Rule +import org.junit.Test + +class MyBookingsScreenUiTest { + + @get:Rule val composeRule = createAndroidComposeRule() + + /** VM wired to use demo=true so the screen shows 2 cards deterministically. */ + private fun vmWithDemo(): MyBookingsViewModel = + MyBookingsViewModel( + // 2 deterministic bookings (L1/t1 = 1h @ $30; L2/t2 = 1h30 @ $25) + bookingRepo = + object : BookingRepository { + override fun getNewUid() = "X" + + override suspend fun getAllBookings() = emptyList() + + override suspend fun getBooking(bookingId: String) = error("not used") + + override suspend fun getBookingsByTutor(tutorId: String) = emptyList() + + override suspend fun getBookingsByUserId(userId: String) = + listOf( + Booking( + bookingId = "b-1", + associatedListingId = "L1", + listingCreatorId = "t1", + bookerId = userId, + sessionStart = Date(), + sessionEnd = Date(System.currentTimeMillis() + 60 * 60 * 1000), // 1h + price = 30.0), + Booking( + bookingId = "b-2", + associatedListingId = "L2", + listingCreatorId = "t2", + bookerId = userId, + sessionStart = Date(), + sessionEnd = Date(System.currentTimeMillis() + 90 * 60 * 1000), // 1h30 + price = 25.0)) + + override suspend fun getBookingsByStudent(studentId: String) = emptyList() + + override suspend fun getBookingsByListing(listingId: String) = emptyList() + + override suspend fun addBooking(booking: Booking) {} + + override suspend fun updateBooking(bookingId: String, booking: Booking) {} + + override suspend fun deleteBooking(bookingId: String) {} + + override suspend fun updateBookingStatus( + bookingId: String, + status: BookingStatus + ) {} + + override suspend fun confirmBooking(bookingId: String) {} + + override suspend fun completeBooking(bookingId: String) {} + + override suspend fun cancelBooking(bookingId: String) {} + }, + userId = "s1", + + // Listing echoes the requested id and maps creator to t1/t2 + listingRepo = + object : ListingRepository { + override fun getNewUid() = "L" + + override suspend fun getAllListings() = emptyList() + + override suspend fun getProposals() = emptyList() + + override suspend fun getRequests() = + emptyList() + + override suspend fun getListing(listingId: String): Listing = + Proposal( + listingId = listingId, + creatorUserId = + when (listingId) { + "L1" -> "t1" + "L2" -> "t2" + else -> "t1" + }, + // Let defaults for Skill() be used to keep subject stable + description = "demo $listingId", + location = Location(), + hourlyRate = if (listingId == "L1") 30.0 else 25.0) + + override suspend fun getListingsByUser(userId: String) = emptyList() + + override suspend fun addProposal(proposal: Proposal) {} + + override suspend fun addRequest( + request: com.android.sample.model.listing.Request + ) {} + + override suspend fun updateListing(listingId: String, listing: Listing) {} + + override suspend fun deleteListing(listingId: String) {} + + override suspend fun deactivateListing(listingId: String) {} + + override suspend fun searchBySkill(skill: com.android.sample.model.skill.Skill) = + emptyList() + + override suspend fun searchByLocation(location: Location, radiusKm: Double) = + emptyList() + }, + + // Profiles for both tutors + profileRepo = + object : ProfileRepository { + override fun getNewUid() = "P" + + override suspend fun getProfile(userId: String) = + when (userId) { + "t1" -> Profile(userId = "t1", name = "Alice Martin", email = "a@a.com") + "t2" -> Profile(userId = "t2", name = "Lucas Dupont", email = "l@a.com") + else -> Profile(userId = userId, name = "Unknown", email = "u@a.com") + } + + override suspend fun addProfile(profile: Profile) {} + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles() = emptyList() + + override suspend fun searchProfilesByLocation( + location: Location, + radiusKm: Double + ) = emptyList() + + override suspend fun getProfileById(userId: String) = getProfile(userId) + + override suspend fun getSkillsForUser(userId: String) = + emptyList() + }, + + // Ratings: L1 averages to 5★, L2 to 4★ + ratingRepo = + object : RatingRepository { + override fun getNewUid() = "R" + + override suspend fun getAllRatings() = emptyList() + + override suspend fun getRating(ratingId: String) = error("not used") + + override suspend fun getRatingsByFromUser(fromUserId: String) = emptyList() + + override suspend fun getRatingsByToUser(toUserId: String) = emptyList() + + override suspend fun getRatingsOfListing(listingId: String): List = + when (listingId) { + "L1" -> + listOf( + Rating("r1", "s1", "t1", StarRating.FIVE, "", RatingType.TUTOR), + Rating("r2", "s2", "t1", StarRating.FIVE, "", RatingType.TUTOR), + Rating("r3", "s3", "t1", StarRating.FIVE, "", RatingType.TUTOR)) + "L2" -> + listOf( + Rating("r4", "s4", "t2", StarRating.FOUR, "", RatingType.TUTOR), + Rating("r5", "s5", "t2", StarRating.FOUR, "", RatingType.TUTOR)) + else -> emptyList() + } + + override suspend fun addRating(rating: Rating) {} + + override suspend fun updateRating(ratingId: String, rating: Rating) {} + + override suspend fun deleteRating(ratingId: String) {} + + override suspend fun getTutorRatingsOfUser(userId: String) = emptyList() + + override suspend fun getStudentRatingsOfUser(userId: String) = emptyList() + }, + locale = Locale.US) + + @Test + fun full_screen_demo_renders_two_cards() { + val vm = vmWithDemo() + composeRule.setContent { + SampleAppTheme { + val nav = rememberNavController() + MyBookingsScreen(viewModel = vm, navController = nav) + } + } + // wait for composition to settle enough to find nodes + composeRule.waitUntil(5_000) { + composeRule + .onAllNodesWithTag(MyBookingsPageTestTag.BOOKING_CARD) + .fetchSemanticsNodes() + .size == 2 + } + composeRule.onAllNodesWithTag(MyBookingsPageTestTag.BOOKING_CARD).assertCountEquals(2) + composeRule.onAllNodesWithTag(MyBookingsPageTestTag.BOOKING_DETAILS_BUTTON).assertCountEquals(2) + } + + @Test + fun bookings_list_empty_renders_zero_cards() { + // Render BookingsList directly with an empty list + composeRule.setContent { + SampleAppTheme { + val nav = rememberNavController() + com.android.sample.ui.bookings.BookingsList(bookings = emptyList(), navController = nav) + } + } + composeRule.onAllNodesWithTag(MyBookingsPageTestTag.BOOKING_CARD).assertCountEquals(0) + } + + @Test + fun rating_rows_visible_from_demo_cards() { + val vm = vmWithDemo() + composeRule.setContent { + SampleAppTheme { + val nav = rememberNavController() + MyBookingsScreen(viewModel = vm, navController = nav) + } + } + // First demo card is 5★; second demo card is 4★ in your VM demo content. + composeRule.onNodeWithText("★★★★★").assertIsDisplayed() + composeRule.onNodeWithText("★★★★☆").assertIsDisplayed() + } + + @Test + fun price_duration_line_uses_space_dash_space_format() { + val vm = vmWithDemo() + composeRule.setContent { + SampleAppTheme { + val nav = rememberNavController() + MyBookingsScreen(viewModel = vm, navController = nav) + } + } + // From demo card 1: "$30.0/hr - 1hr" + composeRule.onNodeWithText("$30.0/hr - 1hr").assertIsDisplayed() + } + + @Test + fun empty_state_shows_message_and_tag() { + composeRule.setContent { + SampleAppTheme { + val nav = rememberNavController() + // Render the list with no items to trigger the empty state + com.android.sample.ui.bookings.BookingsList(bookings = emptyList(), navController = nav) + } + } + + // No cards are rendered + composeRule.onAllNodesWithTag(MyBookingsPageTestTag.BOOKING_CARD).assertCountEquals(0) + + // The empty-state container is visible + composeRule.onNodeWithTag(MyBookingsPageTestTag.EMPTY_BOOKINGS).assertIsDisplayed() + + // The helper text is visible + composeRule.onNodeWithText("No bookings available").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/com/android/sample/screen/MyProfileScreenTest.kt b/app/src/androidTest/java/com/android/sample/screen/MyProfileScreenTest.kt new file mode 100644 index 00000000..eded2877 --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/screen/MyProfileScreenTest.kt @@ -0,0 +1,217 @@ +package com.android.sample.screen + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.performTextInput +import com.android.sample.model.map.Location +import com.android.sample.model.skill.ExpertiseLevel +import com.android.sample.model.skill.MainSubject +import com.android.sample.model.skill.Skill +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.ui.profile.MyProfileScreen +import com.android.sample.ui.profile.MyProfileScreenTestTag +import com.android.sample.ui.profile.MyProfileViewModel +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class MyProfileScreenTest { + + @get:Rule val compose = createComposeRule() + + private val sampleProfile = + Profile( + userId = "demo", + name = "Kendrick Lamar", + email = "kendrick@gmail.com", + description = "Performer and mentor", + location = Location(name = "EPFL", longitude = 0.0, latitude = 0.0)) + + private val sampleSkills = + listOf( + Skill(MainSubject.MUSIC, "SINGING", 10.0, ExpertiseLevel.EXPERT), + Skill(MainSubject.MUSIC, "DANCING", 5.0, ExpertiseLevel.INTERMEDIATE), + Skill(MainSubject.MUSIC, "GUITAR", 7.0, ExpertiseLevel.BEGINNER), + ) + + /** Fake repository for testing ViewModel logic */ + private class FakeRepo() : ProfileRepository { + + private val profiles = mutableMapOf() + private val skillsByUser = mutableMapOf>() + + fun seed(profile: Profile, skills: List) { + profiles[profile.userId] = profile + skillsByUser[profile.userId] = skills + } + + override fun getNewUid() = "fake" + + override suspend fun getProfile(userId: String): Profile = + profiles[userId] ?: error("No profile $userId") + + override suspend fun getProfileById(userId: String) = getProfile(userId) + + override suspend fun addProfile(profile: Profile) { + profiles[profile.userId] = profile + } + + override suspend fun updateProfile(userId: String, profile: Profile) { + profiles[userId] = profile + } + + override suspend fun deleteProfile(userId: String) { + profiles.remove(userId) + skillsByUser.remove(userId) + } + + override suspend fun getAllProfiles(): List = profiles.values.toList() + + override suspend fun searchProfilesByLocation(location: Location, radiusKm: Double) = + emptyList() + + override suspend fun getSkillsForUser(userId: String): List = + skillsByUser[userId] ?: emptyList() + } + + private lateinit var viewModel: MyProfileViewModel + + @Before + fun setup() { + val repo = FakeRepo().apply { seed(sampleProfile, sampleSkills) } + viewModel = MyProfileViewModel(repo) + + compose.setContent { MyProfileScreen(profileViewModel = viewModel, profileId = "demo") } + + compose.waitUntil(5_000) { + compose + .onAllNodesWithTag(MyProfileScreenTestTag.NAME_DISPLAY, useUnmergedTree = true) + .fetchSemanticsNodes() + .isNotEmpty() + } + } + + // --- TESTS --- + + @Test + fun profileInfo_isDisplayedCorrectly() { + compose.onNodeWithTag(MyProfileScreenTestTag.PROFILE_ICON).assertIsDisplayed() + compose + .onNodeWithTag(MyProfileScreenTestTag.NAME_DISPLAY) + .assertIsDisplayed() + .assertTextContains("Kendrick Lamar") + compose.onNodeWithTag(MyProfileScreenTestTag.ROLE_BADGE).assertTextEquals("Student") + } + + // ---------------------------------------------------------- + // NAME FIELD TESTS + // ---------------------------------------------------------- + @Test + fun nameField_displaysCorrectInitialValue() { + compose + .onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_NAME) + .assertTextContains("Kendrick Lamar") + } + + @Test + fun nameField_canBeEdited() { + val newName = "K Dot" + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_NAME).performTextClearance() + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_NAME).performTextInput(newName) + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_NAME).assertTextContains(newName) + } + + @Test + fun nameField_showsError_whenEmpty() { + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_NAME).performTextClearance() + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_NAME).performTextInput("") + compose + .onNodeWithTag(MyProfileScreenTestTag.ERROR_MSG, useUnmergedTree = true) + .assertIsDisplayed() + } + // ---------------------------------------------------------- + // EMAIL FIELD TESTS + // ---------------------------------------------------------- + @Test + fun emailField_displaysCorrectInitialValue() { + compose + .onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_EMAIL) + .assertTextContains("kendrick@gmail.com") + } + + @Test + fun emailField_canBeEdited() { + val newEmail = "kdot@gmail.com" + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_EMAIL).performTextClearance() + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_EMAIL).performTextInput(newEmail) + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_EMAIL).assertTextContains(newEmail) + } + + @Test + fun emailField_showsError_whenInvalid() { + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_EMAIL).performTextClearance() + compose + .onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_EMAIL) + .performTextInput("invalidEmail") + compose + .onNodeWithTag(MyProfileScreenTestTag.ERROR_MSG, useUnmergedTree = true) + .assertIsDisplayed() + } + + // ---------------------------------------------------------- + // LOCATION FIELD TESTS + // ---------------------------------------------------------- + @Test + fun locationField_displaysCorrectInitialValue() { + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_LOCATION).assertTextContains("EPFL") + } + + @Test + fun locationField_canBeEdited() { + val newLocation = "Harvard University" + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_LOCATION).performTextClearance() + compose + .onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_LOCATION) + .performTextInput(newLocation) + compose + .onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_LOCATION) + .assertTextContains(newLocation) + } + + @Test + fun locationField_showsError_whenEmpty() { + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_LOCATION).performTextClearance() + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_LOCATION).performTextInput("") + compose + .onNodeWithTag(MyProfileScreenTestTag.ERROR_MSG, useUnmergedTree = true) + .assertIsDisplayed() + } + + // ---------------------------------------------------------- + // DESCRIPTION FIELD TESTS + // ---------------------------------------------------------- + @Test + fun descriptionField_displaysCorrectInitialValue() { + compose + .onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_DESC) + .assertTextContains("Performer and mentor") + } + + @Test + fun descriptionField_canBeEdited() { + val newDesc = "Artist and teacher" + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_DESC).performTextClearance() + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_DESC).performTextInput(newDesc) + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_DESC).assertTextContains(newDesc) + } + + @Test + fun descriptionField_showsError_whenEmpty() { + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_DESC).performTextClearance() + compose.onNodeWithTag(MyProfileScreenTestTag.INPUT_PROFILE_DESC).performTextInput("") + compose + .onNodeWithTag(MyProfileScreenTestTag.ERROR_MSG, useUnmergedTree = true) + .assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/com/android/sample/screen/SignUpScreenTest.kt b/app/src/androidTest/java/com/android/sample/screen/SignUpScreenTest.kt new file mode 100644 index 00000000..37c52b2b --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/screen/SignUpScreenTest.kt @@ -0,0 +1,200 @@ +/* +package com.android.sample.ui.signup + +import SignUpScreen +import SignUpViewModel +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.android.sample.model.skill.Skill +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import kotlinx.coroutines.delay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test + +// ---------- helpers ---------- +private fun waitForTag(rule: ComposeContentTestRule, tag: String, timeoutMs: Long = 15_000) { + rule.waitUntil(timeoutMs) { + rule.onAllNodes(hasTestTag(tag), useUnmergedTree = false).fetchSemanticsNodes().isNotEmpty() + } +} + +private fun ComposeContentTestRule.nodeByTag(tag: String) = + onNodeWithTag(tag, useUnmergedTree = false) +// ---------- fakes ---------- +private class UiRepo : ProfileRepository { + val added = mutableListOf() + private var uid = 1 + + override fun getNewUid(): String = "ui-$uid".also { uid++ } + + override suspend fun getProfile(userId: String): Profile = added.first { it.userId == userId } + + override suspend fun addProfile(profile: Profile) { + added += profile + } + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles(): List = added + + override suspend fun searchProfilesByLocation( + location: com.android.sample.model.map.Location, + radiusKm: Double + ): List = emptyList() + + override suspend fun getProfileById(userId: String): Profile = added.first { it.userId == userId } + + override suspend fun getSkillsForUser(userId: String): List { + TODO("Not yet implemented") + } +} + +private class SlowRepoUi : ProfileRepository { + override fun getNewUid(): String = "slow" + + override suspend fun getProfile(userId: String): Profile = error("unused") + + override suspend fun addProfile(profile: Profile) { + delay(250) + } + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles(): List = emptyList() + + override suspend fun searchProfilesByLocation( + location: com.android.sample.model.map.Location, + radiusKm: Double + ): List = emptyList() + + override suspend fun getProfileById(userId: String): Profile { + TODO("Not yet implemented") + } + + override suspend fun getSkillsForUser(userId: String): List { + TODO("Not yet implemented") + } +} + +private class SlowFailRepo : ProfileRepository { + override fun getNewUid(): String = "bad" + + override suspend fun getProfile(userId: String): Profile = error("unused") + + override suspend fun addProfile(profile: Profile) { + delay(120) + error("nope") + } + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles(): List = emptyList() + + override suspend fun searchProfilesByLocation( + location: com.android.sample.model.map.Location, + radiusKm: Double + ): List = emptyList() + + override suspend fun getProfileById(userId: String): Profile { + TODO("Not yet implemented") + } + + override suspend fun getSkillsForUser(userId: String): List { + TODO("Not yet implemented") + } +} + +// ---------- tests ---------- +class SignUpScreenTest { + + @get:Rule val composeRule = createAndroidComposeRule() + + @Test + fun all_fields_render_and_role_toggle() { + val vm = SignUpViewModel(UiRepo()) + composeRule.setContent { com.android.sample.ui.theme.SampleAppTheme { SignUpScreen(vm = vm) } } + composeRule.waitForIdle() + + waitForTag(composeRule, SignUpScreenTestTags.NAME) + + composeRule.nodeByTag(SignUpScreenTestTags.TITLE).assertIsDisplayed() + composeRule.nodeByTag(SignUpScreenTestTags.SUBTITLE).assertIsDisplayed() + + composeRule.nodeByTag(SignUpScreenTestTags.NAME).assertIsDisplayed() + composeRule.nodeByTag(SignUpScreenTestTags.SURNAME).assertIsDisplayed() + composeRule.nodeByTag(SignUpScreenTestTags.ADDRESS).assertIsDisplayed() + composeRule.nodeByTag(SignUpScreenTestTags.LEVEL_OF_EDUCATION).assertIsDisplayed() + composeRule.nodeByTag(SignUpScreenTestTags.DESCRIPTION).assertIsDisplayed() + composeRule.nodeByTag(SignUpScreenTestTags.EMAIL).assertIsDisplayed() + composeRule.nodeByTag(SignUpScreenTestTags.PASSWORD).assertIsDisplayed() + + composeRule.nodeByTag(SignUpScreenTestTags.TUTOR).performClick() + assertEquals(Role.TUTOR, vm.state.value.role) + composeRule.nodeByTag(SignUpScreenTestTags.LEARNER).performClick() + assertEquals(Role.LEARNER, vm.state.value.role) + } + + @Test + fun failing_submit_reenables_button_and_sets_error() { + val vm = SignUpViewModel(SlowFailRepo()) + composeRule.setContent { com.android.sample.ui.theme.SampleAppTheme { SignUpScreen(vm = vm) } } + composeRule.waitForIdle() + + waitForTag(composeRule, SignUpScreenTestTags.NAME) + + composeRule.nodeByTag(SignUpScreenTestTags.NAME).performTextInput("Alan") + composeRule.nodeByTag(SignUpScreenTestTags.SURNAME).performTextInput("Turing") + composeRule.nodeByTag(SignUpScreenTestTags.ADDRESS).performTextInput("Street 2") + composeRule.nodeByTag(SignUpScreenTestTags.LEVEL_OF_EDUCATION).performTextInput("Math") + composeRule.nodeByTag(SignUpScreenTestTags.EMAIL).performTextInput("alan@code.org") + composeRule.nodeByTag(SignUpScreenTestTags.PASSWORD).performTextInput("abcdef12") + + composeRule.nodeByTag(SignUpScreenTestTags.SIGN_UP).assertIsEnabled() + composeRule.nodeByTag(SignUpScreenTestTags.SIGN_UP).performClick() + + composeRule.waitUntil(12_000) { !vm.state.value.submitting && vm.state.value.error != null } + assertNotNull(vm.state.value.error) + composeRule.nodeByTag(SignUpScreenTestTags.SIGN_UP).assertIsEnabled() + } + + @Test + fun uppercase_email_is_accepted_and_trimmed() { + val repo = UiRepo() + val vm = SignUpViewModel(repo) + composeRule.setContent { com.android.sample.ui.theme.SampleAppTheme { SignUpScreen(vm = vm) } } + composeRule.waitForIdle() + + waitForTag(composeRule, SignUpScreenTestTags.NAME) + + composeRule.nodeByTag(SignUpScreenTestTags.NAME).performTextInput("Élise") + composeRule.nodeByTag(SignUpScreenTestTags.SURNAME).performTextInput("Müller") + composeRule.nodeByTag(SignUpScreenTestTags.ADDRESS).performTextInput("S1") + composeRule.nodeByTag(SignUpScreenTestTags.LEVEL_OF_EDUCATION).performTextInput("CS") + composeRule.nodeByTag(SignUpScreenTestTags.EMAIL).performTextInput(" USER@MAIL.Example.ORG ") + composeRule.nodeByTag(SignUpScreenTestTags.PASSWORD).performTextInput("passw0rd") + + composeRule.nodeByTag(SignUpScreenTestTags.SIGN_UP).assertIsEnabled() + composeRule.nodeByTag(SignUpScreenTestTags.SIGN_UP).performClick() + + composeRule.waitUntil(12_000) { vm.state.value.submitSuccess } + assertEquals(1, repo.added.size) + assertEquals("Élise Müller", repo.added[0].name) + } +} +*/ diff --git a/app/src/androidTest/java/com/android/sample/screen/SubjectListScreenTest.kt b/app/src/androidTest/java/com/android/sample/screen/SubjectListScreenTest.kt new file mode 100644 index 00000000..1b6890b4 --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/screen/SubjectListScreenTest.kt @@ -0,0 +1,190 @@ +package com.android.sample.screen + +import androidx.activity.ComponentActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.sample.model.map.Location +import com.android.sample.model.rating.RatingInfo +import com.android.sample.model.skill.MainSubject +import com.android.sample.model.skill.Skill +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.ui.subject.SubjectListScreen +import com.android.sample.ui.subject.SubjectListTestTags +import com.android.sample.ui.subject.SubjectListViewModel +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.delay +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +// Ai generated tests for the SubjectListScreen composable +@RunWith(AndroidJUnit4::class) +class SubjectListScreenTest { + + @get:Rule val composeRule = createAndroidComposeRule() + + /** ---- Fake data + repo ------------------------------------------------ */ + private val p1 = profile("1", "Liam P.", "Guitar Lessons", 4.9, 23) + private val p2 = profile("2", "David B.", "Sing Lessons", 4.8, 12) + private val p3 = profile("3", "Stevie W.", "Piano Lessons", 4.7, 15) + private val p4 = profile("4", "Nora Q.", "Violin Lessons", 4.5, 8) + private val p5 = profile("5", "Maya R.", "Drum Lessons", 4.2, 5) + + // Simple skills so category filtering can work if we need it later + private val allSkills = + mapOf( + "1" to listOf(skill("GUITARE")), + "2" to listOf(skill("SING")), + "3" to listOf(skill("PIANO")), + "4" to listOf(skill("VIOLIN")), + "5" to listOf(skill("DRUMS")), + ) + + private fun makeViewModel(): SubjectListViewModel { + val repo = + object : ProfileRepository { + override fun getNewUid(): String = "unused" + + override suspend fun getProfile(userId: String): Profile = error("unused") + + override suspend fun addProfile(profile: Profile) {} + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles(): List { + // small async to exercise loading state + delay(10) + return listOf(p1, p2, p3, p4, p5) + } + + override suspend fun searchProfilesByLocation( + location: Location, + radiusKm: Double + ): List = emptyList() + + override suspend fun getProfileById(userId: String): Profile = error("unused") + + override suspend fun getSkillsForUser(userId: String): List = + allSkills[userId].orEmpty() + } + return SubjectListViewModel(repository = repo) + } + + /** ---- Helpers --------------------------------------------------------- */ + private fun profile(id: String, name: String, description: String, rating: Double, total: Int) = + Profile( + userId = id, + name = name, + description = description, + tutorRating = RatingInfo(averageRating = rating, totalRatings = total)) + + private fun skill(s: String) = Skill(mainSubject = MainSubject.MUSIC, skill = s) + + private fun setContent(onBook: (Profile) -> Unit = {}) { + val vm = makeViewModel() + composeRule.setContent { MaterialTheme { SubjectListScreen(vm, onBook) } } + + // Wait until the single list renders at least one TutorCard + composeRule.waitUntil(5_000) { + composeRule + .onAllNodes( + hasTestTag(SubjectListTestTags.TUTOR_CARD) and + hasAnyAncestor(hasTestTag(SubjectListTestTags.TUTOR_LIST))) + .fetchSemanticsNodes() + .isNotEmpty() + } + } + + /** ---- Tests ----------------------------------------------------------- */ + @Test + fun showsSearchbarAndCategorySelector() { + setContent() + + composeRule.onNodeWithTag(SubjectListTestTags.SEARCHBAR).assertIsDisplayed() + composeRule.onNodeWithTag(SubjectListTestTags.CATEGORY_SELECTOR).assertIsDisplayed() + } + + @Test + fun rendersSingleList_ofTutorCards() { + setContent() + + val list = composeRule.onNodeWithTag(SubjectListTestTags.TUTOR_LIST) + + // Scroll to each expected name and assert it’s displayed + list.performScrollToNode(hasText("Liam P.")) + composeRule.onNodeWithText("Liam P.", useUnmergedTree = true).assertIsDisplayed() + + list.performScrollToNode(hasText("David B.")) + composeRule.onNodeWithText("David B.", useUnmergedTree = true).assertIsDisplayed() + + list.performScrollToNode(hasText("Stevie W.")) + composeRule.onNodeWithText("Stevie W.", useUnmergedTree = true).assertIsDisplayed() + + list.performScrollToNode(hasText("Nora Q.")) + composeRule.onNodeWithText("Nora Q.", useUnmergedTree = true).assertIsDisplayed() + + list.performScrollToNode(hasText("Maya R.")) + composeRule.onNodeWithText("Maya R.", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun clickingBook_callsCallback() { + val clicked = AtomicBoolean(false) + setContent(onBook = { clicked.set(true) }) + + // Click first Book button in the list + composeRule.onAllNodesWithTag(SubjectListTestTags.TUTOR_BOOK_BUTTON).onFirst().performClick() + + assert(clicked.get()) + } + + @Test + fun searchFiltersList_visually() { + setContent() + + composeRule.onNodeWithTag(SubjectListTestTags.SEARCHBAR).performTextInput("Nora") + + // Wait until filtered result appears + composeRule.waitUntil(3_000) { + composeRule.onAllNodes(hasText("Nora Q.")).fetchSemanticsNodes().isNotEmpty() + } + + // Only one tutor card remains in the main list + composeRule + .onAllNodes( + hasTestTag(SubjectListTestTags.TUTOR_CARD) and + hasAnyAncestor(hasTestTag(SubjectListTestTags.TUTOR_LIST))) + .assertCountEquals(1) + + // “Maya R.” no longer exists in the main list subtree + composeRule + .onAllNodes( + hasText("Maya R.") and hasAnyAncestor(hasTestTag(SubjectListTestTags.TUTOR_LIST))) + .assertCountEquals(0) + } + + @Test + fun showsLoading_thenContent() { + setContent() + + // Assert that ultimately the content shows and no error text + composeRule.onNodeWithTag(SubjectListTestTags.TUTOR_LIST).assertIsDisplayed() + composeRule.onNodeWithText("Unknown error").assertDoesNotExist() + } +} diff --git a/app/src/androidTest/java/com/android/sample/screen/TutorProfileScreenTest.kt b/app/src/androidTest/java/com/android/sample/screen/TutorProfileScreenTest.kt new file mode 100644 index 00000000..7ba04140 --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/screen/TutorProfileScreenTest.kt @@ -0,0 +1,151 @@ +package com.android.sample.screen + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToNode +import androidx.navigation.compose.rememberNavController +import com.android.sample.model.map.Location +import com.android.sample.model.rating.RatingInfo +import com.android.sample.model.skill.ExpertiseLevel +import com.android.sample.model.skill.MainSubject +import com.android.sample.model.skill.Skill +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.ui.tutor.TutorPageTestTags +import com.android.sample.ui.tutor.TutorProfileScreen +import com.android.sample.ui.tutor.TutorProfileViewModel +import org.junit.Rule +import org.junit.Test + +class TutorProfileScreenTest { + + @get:Rule val compose = createComposeRule() + + private val sampleProfile = + Profile( + userId = "demo", + name = "Kendrick Lamar", + email = "kendrick@gmail.com", + description = "Performer and mentor", + tutorRating = RatingInfo(averageRating = 5.0, totalRatings = 23), + studentRating = RatingInfo(averageRating = 4.9, totalRatings = 12), + ) + + private val sampleSkills = + listOf( + Skill(MainSubject.MUSIC, "SINGING", 10.0, ExpertiseLevel.EXPERT), + Skill(MainSubject.MUSIC, "DANCING", 5.0, ExpertiseLevel.INTERMEDIATE), + Skill(MainSubject.MUSIC, "GUITAR", 7.0, ExpertiseLevel.BEGINNER), + ) + + /** Test double that satisfies the full TutorRepository contract. */ + // inside TutorProfileScreenTest + private class ImmediateRepo( + private val sampleProfile: Profile, + private val sampleSkills: List + ) : ProfileRepository { + + private val profiles = mutableMapOf() + private val skillsByUser = mutableMapOf>() + + fun seed(profile: Profile, skills: List) { + profiles[profile.userId] = profile + skillsByUser[profile.userId] = skills + } + + override fun getNewUid() = "fake" + + override suspend fun getProfile(userId: String): Profile = + profiles[userId] ?: error("No profile $userId") + + override suspend fun getProfileById(userId: String) = getProfile(userId) + + override suspend fun addProfile(profile: Profile) { + profiles[profile.userId] = profile + } + + override suspend fun updateProfile(userId: String, profile: Profile) { + profiles[userId] = profile + } + + override suspend fun deleteProfile(userId: String) { + profiles.remove(userId) + skillsByUser.remove(userId) + } + + override suspend fun getAllProfiles(): List = profiles.values.toList() + + override suspend fun searchProfilesByLocation(location: Location, radiusKm: Double) = + emptyList() + + override suspend fun getSkillsForUser(userId: String): List = + skillsByUser[userId] ?: emptyList() + } + + private fun launch() { + val repo = + ImmediateRepo(sampleProfile, sampleSkills).apply { + seed(sampleProfile, sampleSkills) // <-- ensure "demo" is present + } + val vm = TutorProfileViewModel(repo) + compose.setContent { + val nav = rememberNavController() + TutorProfileScreen(tutorId = "demo", vm = vm, navController = nav) + } + compose.waitUntil(5_000) { + compose + .onAllNodesWithTag(TutorPageTestTags.NAME, useUnmergedTree = true) + .fetchSemanticsNodes() + .isNotEmpty() + } + } + + @Test + fun core_elements_areDisplayed() { + launch() + compose.onNodeWithTag(TutorPageTestTags.PFP).assertIsDisplayed() + compose.onNodeWithTag(TutorPageTestTags.NAME).assertIsDisplayed() + compose.onNodeWithTag(TutorPageTestTags.RATING).assertIsDisplayed() + compose.onNodeWithTag(TutorPageTestTags.SKILLS_SECTION).assertIsDisplayed() + compose.onNodeWithTag(TutorPageTestTags.CONTACT_SECTION).assertIsDisplayed() + } + + @Test + fun name_and_ratingCount_areCorrect() { + launch() + compose.onNodeWithTag(TutorPageTestTags.NAME).assertTextContains("Kendrick Lamar") + compose.onNodeWithText("(23)").assertIsDisplayed() + } + + @Test + fun skills_render_all_items() { + launch() + compose + .onAllNodesWithTag(TutorPageTestTags.SKILL, useUnmergedTree = true) + .assertCountEquals(sampleSkills.size) + } + + @Test + fun contact_section_shows_email_and_handle() { + launch() + + // Scroll the LazyColumn so the contact section becomes visible + compose + .onNode(hasScrollAction()) + .performScrollToNode(hasTestTag(TutorPageTestTags.CONTACT_SECTION)) + + // Now assert visibility and text content + compose + .onNodeWithTag(TutorPageTestTags.CONTACT_SECTION, useUnmergedTree = true) + .assertIsDisplayed() + compose.onNodeWithText("kendrick@gmail.com").assertIsDisplayed() + compose.onNodeWithText("@KendrickLamar").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/com/android/sample/utils/AppTest.kt b/app/src/androidTest/java/com/android/sample/utils/AppTest.kt new file mode 100644 index 00000000..260f06f3 --- /dev/null +++ b/app/src/androidTest/java/com/android/sample/utils/AppTest.kt @@ -0,0 +1,20 @@ +package com.android.sample.utils + +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import org.junit.After +import org.junit.Before + +abstract class AppTest() { + + @Before open fun setUp() {} + + @After open fun tearDown() {} + + fun ComposeTestRule.enterText(testTag: String, text: String) { + onNodeWithTag(testTag).performTextClearance() + onNodeWithTag(testTag).performTextInput(text) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 91198768..758b641f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + + + + + + + + + + authViewModel.handleGoogleSignInResult(result) } + setContent { - SampleAppTheme { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize().semantics { testTag = C.Tag.main_screen_container }, - color = MaterialTheme.colorScheme.background) { - Greeting("Android") - } - } + MainApp( + authViewModel = authViewModel, onGoogleSignIn = { googleSignInHelper.signInWithGoogle() }) } } } -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text(text = "Hello $name!", modifier = modifier.semantics { testTag = C.Tag.greeting }) +class MyViewModelFactory(private val userId: String) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return when (modelClass) { + MyBookingsViewModel::class.java -> { + MyBookingsViewModel(userId = userId) as T + } + MyProfileViewModel::class.java -> { + MyProfileViewModel() as T + } + MainPageViewModel::class.java -> { + MainPageViewModel() as T + } + else -> throw IllegalArgumentException("Unknown ViewModel class") + } + } } -@Preview(showBackground = true) +/** I used this to test which is why there are non used imports up there */ + +/** + * @Composable fun LoginApp() { val context = LocalContext.current val viewModel: + * AuthenticationViewModel = remember { AuthenticationViewModel(context) } + * + * // Register activity result launcher for Google Sign-In val googleSignInLauncher = + * rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult()) { + * result -> viewModel.handleGoogleSignInResult(result) } + * + * LoginScreen( viewModel = viewModel, onGoogleSignIn = { val signInIntent = + * viewModel.getGoogleSignInClient().signInIntent googleSignInLauncher.launch(signInIntent) }) } + */ @Composable -fun GreetingPreview() { - SampleAppTheme { Greeting("Android") } +fun MainApp(authViewModel: AuthenticationViewModel, onGoogleSignIn: () -> Unit) { + val navController = rememberNavController() + val authResult by authViewModel.authResult.collectAsStateWithLifecycle() + + // Navigate to HOME when authentication is successful + LaunchedEffect(authResult) { + if (authResult is AuthResult.Success) { + navController.navigate(NavRoutes.HOME) { popUpTo(NavRoutes.LOGIN) { inclusive = true } } + } + } + + // To track the current route + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + // Use hardcoded user ID from ProfileRepositoryLocal + val currentUserId = "test" // This matches profileFake1 in your ProfileRepositoryLocal + val factory = MyViewModelFactory(currentUserId) + + val bookingsViewModel: MyBookingsViewModel = viewModel(factory = factory) + val profileViewModel: MyProfileViewModel = viewModel(factory = factory) + val mainPageViewModel: MainPageViewModel = viewModel(factory = factory) + + // Define main screens that should show bottom nav + val mainScreenRoutes = + listOf(NavRoutes.HOME, NavRoutes.BOOKINGS, NavRoutes.PROFILE, NavRoutes.SKILLS) + + // Check if current route should show bottom nav + val showBottomNav = mainScreenRoutes.contains(currentRoute) + + Scaffold( + topBar = { TopAppBar(navController) }, + bottomBar = { + if (showBottomNav) { + BottomNavBar(navController) + } + }) { paddingValues -> + androidx.compose.foundation.layout.Box(modifier = Modifier.padding(paddingValues)) { + AppNavGraph( + navController = navController, + bookingsViewModel, + profileViewModel, + mainPageViewModel, + authViewModel = authViewModel, + onGoogleSignIn = onGoogleSignIn) + } + } } diff --git a/app/src/main/java/com/android/sample/MainPage.kt b/app/src/main/java/com/android/sample/MainPage.kt new file mode 100644 index 00000000..e863d15e --- /dev/null +++ b/app/src/main/java/com/android/sample/MainPage.kt @@ -0,0 +1,230 @@ +package com.android.sample + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.sample.model.skill.Skill +import com.android.sample.ui.theme.PrimaryColor +import com.android.sample.ui.theme.SecondaryColor +import kotlin.random.Random + +/** + * Provides test tag identifiers for the HomeScreen and its child composables. + * + * These tags are used to locate UI components during automated testing. + */ +object HomeScreenTestTags { + const val WELCOME_SECTION = "welcomeSection" + const val EXPLORE_SKILLS_SECTION = "exploreSkillsSection" + const val SKILL_CARD = "skillCard" + const val TOP_TUTOR_SECTION = "topTutorSection" + const val TUTOR_CARD = "tutorCard" + const val TUTOR_BOOK_BUTTON = "tutorBookButton" + const val TUTOR_LIST = "tutorList" + const val FAB_ADD = "fabAdd" +} + +/** + * The main HomeScreen composable for the SkillBridge app. + * + * Displays a scaffolded layout containing: + * - A Floating Action Button (FAB) + * - Greeting section + * - Skills exploration carousel + * - List of top-rated tutors + * + * Data is provided by the [MainPageViewModel]. + * + * @param mainPageViewModel The ViewModel providing UI state and event handlers. + */ +@Preview +@Composable +fun HomeScreen( + mainPageViewModel: MainPageViewModel = viewModel(), + onNavigateToNewSkill: (String) -> Unit = {} +) { + val uiState by mainPageViewModel.uiState.collectAsState() + val navigationEvent by mainPageViewModel.navigationEvent.collectAsState() + + LaunchedEffect(navigationEvent) { + navigationEvent?.let { profileId -> + onNavigateToNewSkill(profileId) + mainPageViewModel.onNavigationHandled() + } + } + + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { mainPageViewModel.onAddTutorClicked("test") }, // Hardcoded user ID for now + containerColor = PrimaryColor, + modifier = Modifier.testTag(HomeScreenTestTags.FAB_ADD)) { + Icon(Icons.Default.Add, contentDescription = "Add") + } + }) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues).fillMaxSize().background(Color.White)) { + Spacer(modifier = Modifier.height(10.dp)) + GreetingSection(uiState.welcomeMessage) + Spacer(modifier = Modifier.height(20.dp)) + ExploreSkills(uiState.skills) + Spacer(modifier = Modifier.height(20.dp)) + TutorsSection(uiState.tutors, onBookClick = mainPageViewModel::onBookTutorClicked) + } + } +} + +/** + * Displays a greeting message and a short subtitle encouraging user engagement. + * + * @param welcomeMessage The personalized greeting text shown to the user. + */ +@Composable +fun GreetingSection(welcomeMessage: String) { + Column( + modifier = Modifier.padding(horizontal = 10.dp).testTag(HomeScreenTestTags.WELCOME_SECTION)) { + Text(welcomeMessage, fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text("Ready to learn something new today?", color = Color.Gray, fontSize = 14.sp) + } +} + +/** + * Displays a horizontally scrollable row of skill cards. + * + * Each card represents a skill available for learning. + * + * @param skills The list of [Skill] items to display. + */ +@Composable +fun ExploreSkills(skills: List) { + Column( + modifier = + Modifier.padding(horizontal = 10.dp).testTag(HomeScreenTestTags.EXPLORE_SKILLS_SECTION)) { + Text("Explore skills", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(modifier = Modifier.height(12.dp)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items(skills) { SkillCard(skill = it) } + } + } +} + +/** + * Displays a single skill card with a randomly generated background color. + * + * @param skill The [Skill] object representing the skill to display. + */ +@Composable +fun SkillCard(skill: Skill) { + val randomColor = remember { + Color( + red = Random.nextFloat(), green = Random.nextFloat(), blue = Random.nextFloat(), alpha = 1f) + } + + Column( + modifier = + Modifier.background(randomColor, RoundedCornerShape(12.dp)) + .padding(16.dp) + .testTag(HomeScreenTestTags.SKILL_CARD), + horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(8.dp)) + Text(skill.skill, fontWeight = FontWeight.Bold, color = Color.Black) + } +} + +/** + * Displays a vertical list of top-rated tutors using a [LazyColumn]. + * + * Each item in the list is rendered using [TutorCard]. + * + * @param tutors The list of [TutorCardUi] objects to display. + * @param onBookClick The callback invoked when the "Book" button is clicked. + */ +@Composable +fun TutorsSection(tutors: List, onBookClick: (String) -> Unit) { + Column(modifier = Modifier.padding(horizontal = 10.dp)) { + Text( + text = "Top-Rated Tutors", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + modifier = Modifier.testTag(HomeScreenTestTags.TOP_TUTOR_SECTION)) + + Spacer(modifier = Modifier.height(10.dp)) + + LazyColumn( + modifier = Modifier.testTag(HomeScreenTestTags.TUTOR_LIST).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(tutors) { TutorCard(it, onBookClick) } + } + } +} + +/** + * Displays a tutor’s information card, including name, subject, hourly rate, and rating stars. + * + * The card includes a "Book" button that triggers [onBookClick]. + * + * @param tutor The [TutorCardUi] object containing tutor data. + * @param onBookClick The callback executed when the "Book" button is clicked. + */ +@Composable +fun TutorCard(tutor: TutorCardUi, onBookClick: (String) -> Unit) { + Card( + modifier = + Modifier.fillMaxWidth().padding(vertical = 5.dp).testTag(HomeScreenTestTags.TUTOR_CARD), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(4.dp)) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = CircleShape, color = Color.LightGray, modifier = Modifier.size(40.dp)) {} + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text(tutor.name, fontWeight = FontWeight.Bold) + Text(tutor.subject, color = SecondaryColor) + Row { + repeat(5) { i -> + val tint = if (i < tutor.ratingStars) Color.Black else Color.Gray + Icon( + Icons.Default.Star, + contentDescription = null, + tint = tint, + modifier = Modifier.size(16.dp)) + } + Text( + "(${tutor.ratingCount})", + fontSize = 12.sp, + modifier = Modifier.padding(start = 4.dp)) + } + } + + Column(horizontalAlignment = Alignment.End) { + Text( + "$${"%.2f".format(tutor.hourlyRate)} / hr", + color = SecondaryColor, + fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(6.dp)) + Button( + onClick = { onBookClick(tutor.name) }, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.testTag(HomeScreenTestTags.TUTOR_BOOK_BUTTON)) { + Text("Book") + } + } + } + } +} diff --git a/app/src/main/java/com/android/sample/MainPageViewModel.kt b/app/src/main/java/com/android/sample/MainPageViewModel.kt new file mode 100644 index 00000000..21152b31 --- /dev/null +++ b/app/src/main/java/com/android/sample/MainPageViewModel.kt @@ -0,0 +1,177 @@ +package com.android.sample + +import androidx.compose.runtime.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.sample.model.listing.Listing +import com.android.sample.model.listing.ListingRepositoryProvider +import com.android.sample.model.rating.RatingInfo +import com.android.sample.model.skill.Skill +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepositoryProvider +import kotlin.math.roundToInt +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Represents the complete UI state of the Home (Main) screen. + * + * @property welcomeMessage A greeting message for the current user. + * @property skills A list of skills retrieved from the local repository. + * @property tutors A list of tutor cards prepared for display. + */ +data class HomeUiState( + val welcomeMessage: String = "", + val skills: List = emptyList(), + val tutors: List = emptyList() +) + +/** + * UI representation of a tutor card displayed on the main page. + * + * @property name Tutor's display name. + * @property subject Subject or skill taught by the tutor. + * @property hourlyRate Tutor's hourly rate, formatted to two decimals. + * @property ratingStars Average star rating (rounded 0–5). + * @property ratingCount Total number of ratings for the tutor. + */ +data class TutorCardUi( + val name: String, + val subject: String, + val hourlyRate: Double, + val ratingStars: Int, + val ratingCount: Int +) + +/** + * ViewModel responsible for managing and preparing data for the Main Page (HomeScreen). + * + * It loads skills, listings, and tutor profiles from local repositories and exposes them as a + * unified [HomeUiState] via a [StateFlow]. It also handles user actions such as booking and adding + * tutors (currently as placeholders). + */ +class MainPageViewModel : ViewModel() { + + private val profileRepository = ProfileRepositoryProvider.repository + private val listingRepository = ListingRepositoryProvider.repository + + private val _navigationEvent = MutableStateFlow(null) + val navigationEvent: StateFlow = _navigationEvent.asStateFlow() + + private val _uiState = MutableStateFlow(HomeUiState()) + /** The publicly exposed immutable UI state observed by the composables. */ + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // Load all initial data when the ViewModel is created. + viewModelScope.launch { load() } + } + + /** + * Loads all data required for the main page. + * + * Fetches data from local repositories (skills, listings, and tutors) and builds a list of + * [TutorCardUi] safely using [buildTutorCardSafely]. Updates the [_uiState] with a formatted + * welcome message and the loaded data. + */ + suspend fun load() { + try { + val skills = emptyList() + val listings = listingRepository.getAllListings() + val tutors = profileRepository.getAllProfiles() + + val tutorCards = listings.mapNotNull { buildTutorCardSafely(it, tutors) } + val userName = "" + + _uiState.value = + HomeUiState( + welcomeMessage = "Welcome back, $userName!", skills = skills, tutors = tutorCards) + } catch (e: Exception) { + // Fallback in case of repository or mapping failure. + _uiState.value = HomeUiState(welcomeMessage = "Welcome back, Ava!") + } + } + + /** + * Safely builds a [TutorCardUi] object for the given [Listing] and tutor list. + * + * Any errors encountered during construction are caught, and null is returned to prevent one + * failing item from breaking the entire list rendering. + * + * @param listing The [Listing] representing a tutor's offering. + * @param tutors The list of available [Profile]s. + * @return A constructed [TutorCardUi], or null if the data is invalid. + */ + private fun buildTutorCardSafely(listing: Listing, tutors: List): TutorCardUi? { + return try { + val tutor = tutors.find { it.userId == listing.creatorUserId } ?: return null + + TutorCardUi( + name = tutor.name ?: "Unknown", + subject = listing.skill.skill, + hourlyRate = formatPrice(listing.hourlyRate), + ratingStars = computeAvgStars(tutor.tutorRating), + ratingCount = ratingCountFor(tutor.tutorRating)) + } catch (e: Exception) { + null + } + } + + /** + * Computes the average rating for a tutor and converts it to a rounded integer value. + * + * @param rating The [RatingInfo] containing average and total ratings. + * @return The rounded star rating, clamped between 0 and 5. + */ + private fun computeAvgStars(rating: RatingInfo): Int { + if (rating.totalRatings == 0) return 0 + val avg = rating.averageRating + return avg.roundToInt().coerceIn(0, 5) + } + + /** + * Retrieves the total number of ratings for a tutor. + * + * @param rating The [RatingInfo] object. + * @return The total number of ratings. + */ + private fun ratingCountFor(rating: RatingInfo): Int = rating.totalRatings + + /** + * Formats the hourly rate to two decimal places for consistent display. + * + * @param hourlyRate The raw hourly rate value. + * @return The formatted hourly rate as a [Double]. + */ + private fun formatPrice(hourlyRate: Double): Double { + return String.format("%.2f", hourlyRate).toDouble() + } + + /** + * Handles the "Book" button click event for a tutor. + * + * This function will be expanded in future versions to handle booking logic. + * + * @param tutorName The name of the tutor being booked. + */ + fun onBookTutorClicked(tutorName: String) { + viewModelScope.launch { + // TODO handle booking logic + } + } + + /** + * Handles the "Add Tutor" button click event. + * + * This function will be expanded in future versions to handle adding new tutors. + */ + fun onAddTutorClicked(profileId: String) { + viewModelScope.launch { _navigationEvent.value = profileId } + } + + fun onNavigationHandled() { + _navigationEvent.value = null + } +} diff --git a/app/src/main/java/com/android/sample/SecondActivity.kt b/app/src/main/java/com/android/sample/SecondActivity.kt index d6a23855..b9f880d1 100644 --- a/app/src/main/java/com/android/sample/SecondActivity.kt +++ b/app/src/main/java/com/android/sample/SecondActivity.kt @@ -6,12 +6,9 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag -import androidx.compose.ui.tooling.preview.Preview import com.android.sample.resources.C import com.android.sample.ui.theme.SampleAppTheme @@ -23,21 +20,8 @@ class SecondActivity : ComponentActivity() { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize().semantics { testTag = C.Tag.second_screen_container }, - color = MaterialTheme.colorScheme.background) { - GreetingRobo("Robolectric") - } + color = MaterialTheme.colorScheme.background) {} } } } } - -@Composable -fun GreetingRobo(name: String, modifier: Modifier = Modifier) { - Text(text = "Hello $name!", modifier = modifier.semantics { testTag = C.Tag.greeting_robo }) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview2() { - SampleAppTheme { GreetingRobo("Robolectric") } -} diff --git a/app/src/main/java/com/android/sample/model/authentication/AuthResult.kt b/app/src/main/java/com/android/sample/model/authentication/AuthResult.kt new file mode 100644 index 00000000..2e6f4ba0 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/authentication/AuthResult.kt @@ -0,0 +1,10 @@ +package com.android.sample.model.authentication + +import com.google.firebase.auth.FirebaseUser + +/** Sealed class representing the result of an authentication operation */ +sealed class AuthResult { + data class Success(val user: FirebaseUser) : AuthResult() + + data class Error(val message: String) : AuthResult() +} diff --git a/app/src/main/java/com/android/sample/model/authentication/AuthenticationRepository.kt b/app/src/main/java/com/android/sample/model/authentication/AuthenticationRepository.kt new file mode 100644 index 00000000..aaba7b74 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/authentication/AuthenticationRepository.kt @@ -0,0 +1,66 @@ +package com.android.sample.model.authentication + +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.tasks.await + +/** + * Repository for handling Firebase Authentication operations. Provides methods for email/password + * and Google Sign-In authentication. + */ +class AuthenticationRepository(private val auth: FirebaseAuth = FirebaseAuth.getInstance()) { + + /** + * Sign in with email and password + * + * @return Result containing FirebaseUser on success or Exception on failure + */ + suspend fun signInWithEmail(email: String, password: String): Result { + return try { + val result = auth.signInWithEmailAndPassword(email, password).await() + result.user?.let { Result.success(it) } + ?: Result.failure(Exception("Sign in failed: No user")) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Sign in with Google credential + * + * @return Result containing FirebaseUser on success or Exception on failure + */ + suspend fun signInWithCredential(credential: AuthCredential): Result { + return try { + val result = auth.signInWithCredential(credential).await() + result.user?.let { Result.success(it) } + ?: Result.failure(Exception("Sign in failed: No user")) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** Sign out the current user */ + fun signOut() { + auth.signOut() + } + + /** + * Get the current signed-in user + * + * @return FirebaseUser if signed in, null otherwise + */ + fun getCurrentUser(): FirebaseUser? { + return auth.currentUser + } + + /** + * Check if a user is currently signed in + * + * @return true if user is signed in, false otherwise + */ + fun isUserSignedIn(): Boolean { + return auth.currentUser != null + } +} diff --git a/app/src/main/java/com/android/sample/model/authentication/AuthenticationUiState.kt b/app/src/main/java/com/android/sample/model/authentication/AuthenticationUiState.kt new file mode 100644 index 00000000..332bc310 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/authentication/AuthenticationUiState.kt @@ -0,0 +1,15 @@ +package com.android.sample.model.authentication + +/** Data class representing the UI state for authentication screens */ +data class AuthenticationUiState( + val email: String = "", + val password: String = "", + val selectedRole: UserRole = UserRole.LEARNER, + val isLoading: Boolean = false, + val error: String? = null, + val message: String? = null, + val showSuccessMessage: Boolean = false +) { + val isSignInButtonEnabled: Boolean + get() = email.isNotBlank() && password.isNotBlank() && !isLoading +} diff --git a/app/src/main/java/com/android/sample/model/authentication/AuthenticationViewModel.kt b/app/src/main/java/com/android/sample/model/authentication/AuthenticationViewModel.kt new file mode 100644 index 00000000..9e5bb0de --- /dev/null +++ b/app/src/main/java/com/android/sample/model/authentication/AuthenticationViewModel.kt @@ -0,0 +1,159 @@ +@file:Suppress("DEPRECATION") + +package com.android.sample.model.authentication + +import android.content.Context +import androidx.activity.result.ActivityResult +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.common.api.ApiException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * ViewModel for managing authentication state and operations. Follows MVVM architecture pattern + * with Credential Manager API for passwords and Google Sign-In SDK for Google authentication. + */ +@Suppress("CONTEXT_RECEIVER_MEMBER_IS_DEPRECATED") +class AuthenticationViewModel( + @Suppress("StaticFieldLeak") private val context: Context, + private val repository: AuthenticationRepository = AuthenticationRepository(), + private val credentialHelper: CredentialAuthHelper = CredentialAuthHelper(context) +) : ViewModel() { + + private val _uiState = MutableStateFlow(AuthenticationUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _authResult = MutableStateFlow(null) + val authResult: StateFlow = _authResult.asStateFlow() + + /** Update the email field */ + fun updateEmail(email: String) { + _uiState.update { it.copy(email = email, error = null, message = null) } + } + + /** Update the password field */ + fun updatePassword(password: String) { + _uiState.update { it.copy(password = password, error = null, message = null) } + } + + /** Update the selected user role */ + fun updateSelectedRole(role: UserRole) { + _uiState.update { it.copy(selectedRole = role) } + } + + /** Sign in with email and password */ + fun signIn() { + val email = _uiState.value.email + val password = _uiState.value.password + + if (email.isBlank() || password.isBlank()) { + _uiState.update { it.copy(error = "Email and password cannot be empty") } + return + } + + _uiState.update { it.copy(isLoading = true, error = null) } + + viewModelScope.launch { + val result = repository.signInWithEmail(email, password) + result.fold( + onSuccess = { user -> + _authResult.value = AuthResult.Success(user) + _uiState.update { it.copy(isLoading = false, error = null) } + }, + onFailure = { exception -> + val errorMessage = exception.message ?: "Sign in failed" + _authResult.value = AuthResult.Error(errorMessage) + _uiState.update { it.copy(isLoading = false, error = errorMessage) } + }) + } + } + + /** Handle Google Sign-In result from activity */ + @Suppress("DEPRECATION") + fun handleGoogleSignInResult(result: ActivityResult) { + _uiState.update { it.copy(isLoading = true, error = null) } + + try { + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + val account = task.getResult(ApiException::class.java) + + account.idToken?.let { idToken -> + val firebaseCredential = credentialHelper.getFirebaseCredential(idToken) + + viewModelScope.launch { + val authResult = repository.signInWithCredential(firebaseCredential) + authResult.fold( + onSuccess = { user -> + _authResult.value = AuthResult.Success(user) + _uiState.update { it.copy(isLoading = false, error = null) } + }, + onFailure = { exception -> + val errorMessage = exception.message ?: "Google sign in failed" + _authResult.value = AuthResult.Error(errorMessage) + _uiState.update { it.copy(isLoading = false, error = errorMessage) } + }) + } + } + ?: run { + _authResult.value = AuthResult.Error("No ID token received") + _uiState.update { it.copy(isLoading = false, error = "No ID token received") } + } + } catch (e: ApiException) { + val errorMessage = "Google sign in failed: ${e.message}" + _authResult.value = AuthResult.Error(errorMessage) + _uiState.update { it.copy(isLoading = false, error = errorMessage) } + } + } + + /** Get GoogleSignInClient for initiating sign-in */ + fun getGoogleSignInClient() = credentialHelper.getGoogleSignInClient() + + /** Try to get saved password credential using Credential Manager */ + fun getSavedCredential() { + _uiState.update { it.copy(isLoading = true, error = null) } + + viewModelScope.launch { + val result = credentialHelper.getPasswordCredential() + result.fold( + onSuccess = { passwordCredential -> + // Auto-fill the email and password + _uiState.update { + it.copy( + email = passwordCredential.id, + password = passwordCredential.password, + isLoading = false, + message = "Credential loaded") + } + }, + onFailure = { exception -> + // Silently fail - no saved credentials is not an error + _uiState.update { it.copy(isLoading = false) } + }) + } + } + + /** Sign out the current user */ + fun signOut() { + repository.signOut() + credentialHelper.getGoogleSignInClient().signOut() + _authResult.value = null + _uiState.update { + AuthenticationUiState() // Reset to default state + } + } + + /** Set error message */ + fun setError(message: String) { + _uiState.update { it.copy(error = message, isLoading = false) } + } + + /** Show or hide success message */ + fun showSuccessMessage(show: Boolean) { + _uiState.update { it.copy(showSuccessMessage = show) } + } +} diff --git a/app/src/main/java/com/android/sample/model/authentication/CredentialAuthHelper.kt b/app/src/main/java/com/android/sample/model/authentication/CredentialAuthHelper.kt new file mode 100644 index 00000000..ca1fe83c --- /dev/null +++ b/app/src/main/java/com/android/sample/model/authentication/CredentialAuthHelper.kt @@ -0,0 +1,90 @@ +@file:Suppress("DEPRECATION") + +package com.android.sample.model.authentication + +import android.content.Context +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PasswordCredential +import androidx.credentials.exceptions.GetCredentialException +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.GoogleAuthProvider + +/** + * Helper class for managing authentication using Credential Manager API. Handles password + * credentials using modern Credential Manager. For Google Sign-In, provides a helper to get + * GoogleSignInClient. + */ +class CredentialAuthHelper(private val context: Context) { + + private val credentialManager by lazy { + try { + CredentialManager.create(context) + } catch (e: Exception) { + // Log error but don't crash - this can happen if Play Services isn't available + println("CredentialManager creation failed: ${e.message}") + null + } + } + + companion object { + const val WEB_CLIENT_ID = + "1061045584009-duiljd2t9ijc3u8vc9193a4ecpk2di5f.apps.googleusercontent.com" + } + + /** + * Get GoogleSignInClient for initiating Google Sign-In flow This uses the traditional Google + * Sign-In SDK which is simpler and more reliable + */ + fun getGoogleSignInClient(): GoogleSignInClient { + val gso = + GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(WEB_CLIENT_ID) + .requestEmail() + .build() + + return GoogleSignIn.getClient(context, gso) + } + + /** + * Get saved password credential using Credential Manager + * + * @return Result containing PasswordCredential or exception + */ + suspend fun getPasswordCredential(): Result { + return try { + val manager = + credentialManager ?: return Result.failure(Exception("CredentialManager not available")) + + val request = GetCredentialRequest.Builder().build() + + val result = manager.getCredential(request = request, context = context) + + handlePasswordResult(result) + } catch (e: GetCredentialException) { + Result.failure(Exception("No saved credentials found: ${e.message}", e)) + } catch (e: Exception) { + Result.failure(Exception("Unexpected error: ${e.message}", e)) + } + } + + /** Convert Google ID token to Firebase AuthCredential */ + fun getFirebaseCredential(idToken: String): AuthCredential { + return GoogleAuthProvider.getCredential(idToken, null) + } + + private fun handlePasswordResult(result: GetCredentialResponse): Result { + return when (val credential = result.credential) { + is PasswordCredential -> { + Result.success(credential) + } + else -> { + Result.failure(Exception("No password credential found")) + } + } + } +} diff --git a/app/src/main/java/com/android/sample/model/authentication/GoogleSignInHelper.kt b/app/src/main/java/com/android/sample/model/authentication/GoogleSignInHelper.kt new file mode 100644 index 00000000..02275a18 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/authentication/GoogleSignInHelper.kt @@ -0,0 +1,57 @@ +@file:Suppress("DEPRECATION") + +package com.android.sample.model.authentication + +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions + +/** + * Helper class for managing Google Sign-In flow. Handles the activity result launcher and Google + * Sign-In client configuration. + */ +class GoogleSignInHelper( + activity: ComponentActivity, + private val onSignInResult: (ActivityResult) -> Unit +) { + private val googleSignInClient: GoogleSignInClient + private val signInLauncher: ActivityResultLauncher + + init { + // Configure Google Sign-In - force account picker to show every time + val gso = + GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken( + "1061045584009-duiljd2t9ijc3u8vc9193a4ecpk2di5f.apps.googleusercontent.com") + .requestEmail() + .build() + + googleSignInClient = GoogleSignIn.getClient(activity, gso) + + // Register activity result launcher + signInLauncher = + activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result -> + onSignInResult(result) + } + } + + /** Launch Google Sign-In intent - signs out first to force account selection */ + fun signInWithGoogle() { + // Sign out first to ensure account picker is shown + googleSignInClient.signOut().addOnCompleteListener { + val signInIntent = googleSignInClient.signInIntent + signInLauncher.launch(signInIntent) + } + } + + /** This function will be used later when signout is implemented* */ + /** Sign out from Google */ + fun signOut() { + googleSignInClient.signOut() + } +} diff --git a/app/src/main/java/com/android/sample/model/authentication/UserRole.kt b/app/src/main/java/com/android/sample/model/authentication/UserRole.kt new file mode 100644 index 00000000..fc271dab --- /dev/null +++ b/app/src/main/java/com/android/sample/model/authentication/UserRole.kt @@ -0,0 +1,7 @@ +package com.android.sample.model.authentication + +/** Enum representing user roles in the application */ +enum class UserRole { + LEARNER, + TUTOR +} diff --git a/app/src/main/java/com/android/sample/model/booking/Booking.kt b/app/src/main/java/com/android/sample/model/booking/Booking.kt new file mode 100644 index 00000000..ff3bf6b5 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/booking/Booking.kt @@ -0,0 +1,33 @@ +package com.android.sample.model.booking + +import java.util.Date + +/** Enhanced booking with listing association */ +data class Booking( + val bookingId: String = "", + val associatedListingId: String = "", + val listingCreatorId: String = "", + val bookerId: String = "", + val sessionStart: Date = Date(), + val sessionEnd: Date = Date(), + val status: BookingStatus = BookingStatus.PENDING, + val price: Double = 0.0 +) { + // No-argument constructor for Firestore deserialization + constructor() : + this("", "", "", "", Date(), Date(System.currentTimeMillis() + 1), BookingStatus.PENDING, 0.0) + + /** Validates the booking data. Throws an [IllegalArgumentException] if the data is invalid. */ + fun validate() { + require(sessionStart.before(sessionEnd)) { "Session start must be before session end" } + require(listingCreatorId != bookerId) { "Provider and receiver must be different users" } + require(price >= 0) { "Price must be non-negative" } + } +} + +enum class BookingStatus { + PENDING, + CONFIRMED, + COMPLETED, + CANCELLED +} diff --git a/app/src/main/java/com/android/sample/model/booking/BookingRepository.kt b/app/src/main/java/com/android/sample/model/booking/BookingRepository.kt new file mode 100644 index 00000000..8346f5ef --- /dev/null +++ b/app/src/main/java/com/android/sample/model/booking/BookingRepository.kt @@ -0,0 +1,35 @@ +package com.android.sample.model.booking + +interface BookingRepository { + fun getNewUid(): String + + suspend fun getAllBookings(): List + + suspend fun getBooking(bookingId: String): Booking? + + suspend fun getBookingsByTutor(tutorId: String): List + + suspend fun getBookingsByUserId(userId: String): List + + suspend fun getBookingsByStudent(studentId: String): List + + suspend fun getBookingsByListing(listingId: String): List + + suspend fun addBooking(booking: Booking) + + suspend fun updateBooking(bookingId: String, booking: Booking) + + suspend fun deleteBooking(bookingId: String) + + /** Updates booking status */ + suspend fun updateBookingStatus(bookingId: String, status: BookingStatus) + + /** Confirms a pending booking */ + suspend fun confirmBooking(bookingId: String) + + /** Completes a booking */ + suspend fun completeBooking(bookingId: String) + + /** Cancels a booking */ + suspend fun cancelBooking(bookingId: String) +} diff --git a/app/src/main/java/com/android/sample/model/booking/BookingRepositoryProvider.kt b/app/src/main/java/com/android/sample/model/booking/BookingRepositoryProvider.kt new file mode 100644 index 00000000..a8eb3247 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/booking/BookingRepositoryProvider.kt @@ -0,0 +1,28 @@ +// kotlin +package com.android.sample.model.booking + +import android.content.Context +import com.google.firebase.FirebaseApp +import com.google.firebase.firestore.ktx.firestore +import com.google.firebase.ktx.Firebase + +object BookingRepositoryProvider { + @Volatile private var _repository: BookingRepository? = null + + val repository: BookingRepository + get() = + _repository + ?: error( + "BookingRepositoryProvider not initialized. Call init(...) first or setForTests(...) in tests.") + + fun init(context: Context, useEmulator: Boolean = false) { + if (FirebaseApp.getApps(context).isEmpty()) { + FirebaseApp.initializeApp(context) + } + _repository = FirestoreBookingRepository(Firebase.firestore) + } + + fun setForTests(repository: BookingRepository) { + _repository = repository + } +} diff --git a/app/src/main/java/com/android/sample/model/booking/FirestoreBookingRepository.kt b/app/src/main/java/com/android/sample/model/booking/FirestoreBookingRepository.kt new file mode 100644 index 00000000..cb79e47e --- /dev/null +++ b/app/src/main/java/com/android/sample/model/booking/FirestoreBookingRepository.kt @@ -0,0 +1,186 @@ +package com.android.sample.model.booking + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.Query +import java.util.UUID +import kotlinx.coroutines.tasks.await + +const val BOOKINGS_COLLECTION_PATH = "bookings" + +class FirestoreBookingRepository( + private val db: FirebaseFirestore, + private val auth: FirebaseAuth = FirebaseAuth.getInstance() +) : BookingRepository { + + // Helper property to get current user ID + private val currentUserId: String + get() = auth.currentUser?.uid ?: throw Exception("User not authenticated") + + override fun getNewUid(): String { + return UUID.randomUUID().toString() + } + + override suspend fun getAllBookings(): List { + try { + val snapshot = + db.collection(BOOKINGS_COLLECTION_PATH) + .whereEqualTo("bookerId", currentUserId) + .orderBy("sessionStart", Query.Direction.ASCENDING) + .get() + .await() + return snapshot.toObjects(Booking::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch bookings: ${e.message}") + } + } + + override suspend fun getBooking(bookingId: String): Booking? { + return try { + val document = db.collection(BOOKINGS_COLLECTION_PATH).document(bookingId).get().await() + + if (document.exists()) { + val booking = + document.toObject(Booking::class.java) + ?: throw Exception("Failed to parse Booking with ID $bookingId") + + // Verify user has access (either booker or listing creator) + if (booking.bookerId != currentUserId && booking.listingCreatorId != currentUserId) { + throw Exception("Access denied: This booking doesn't belong to current user") + } + booking + } else { + return null + } + } catch (e: Exception) { + throw Exception("Failed to get booking: ${e.message}") + } + } + + override suspend fun getBookingsByTutor(tutorId: String): List { + try { + val snapshot = + db.collection(BOOKINGS_COLLECTION_PATH) + .whereEqualTo("listingCreatorId", tutorId) + .orderBy("sessionStart", Query.Direction.ASCENDING) + .get() + .await() + return snapshot.toObjects(Booking::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch bookings by tutor: ${e.message}") + } + } + + override suspend fun getBookingsByUserId(userId: String): List { + try { + val snapshot = + db.collection(BOOKINGS_COLLECTION_PATH) + .whereEqualTo("bookerId", userId) + .orderBy("sessionStart", Query.Direction.ASCENDING) + .get() + .await() + return snapshot.toObjects(Booking::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch bookings by user: ${e.message}") + } + } + + override suspend fun getBookingsByStudent(studentId: String): List { + return getBookingsByUserId(studentId) + } + + override suspend fun getBookingsByListing(listingId: String): List { + try { + val snapshot = + db.collection(BOOKINGS_COLLECTION_PATH) + .whereEqualTo("associatedListingId", listingId) + .orderBy("sessionStart", Query.Direction.ASCENDING) + .get() + .await() + return snapshot.toObjects(Booking::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch bookings by listing: ${e.message}") + } + } + + override suspend fun addBooking(booking: Booking) { + try { + // Verify current user is the booker + if (booking.bookerId != currentUserId) { + throw Exception("Access denied: Can only create bookings for yourself") + } + + db.collection(BOOKINGS_COLLECTION_PATH).document(booking.bookingId).set(booking).await() + } catch (e: Exception) { + throw Exception("Failed to add booking: ${e.message}") + } + } + + override suspend fun updateBooking(bookingId: String, booking: Booking) { + try { + val documentRef = db.collection(BOOKINGS_COLLECTION_PATH).document(bookingId) + val documentSnapshot = documentRef.get().await() + + if (documentSnapshot.exists()) { + val existingBooking = documentSnapshot.toObject(Booking::class.java) + + // Verify user has access + if (existingBooking?.bookerId != currentUserId && + existingBooking?.listingCreatorId != currentUserId) { + throw Exception( + "Access denied: Cannot update booking that doesn't belong to current user") + } + + documentRef.set(booking).await() + } else { + throw Exception("Booking with ID $bookingId not found") + } + } catch (e: Exception) { + throw Exception("Failed to update booking: ${e.message}") + } + } + + override suspend fun deleteBooking(bookingId: String) { + try { + // val documentRef = db.collection(BOOKINGS_COLLECTION_PATH).document(bookingId) + // val documentSnapshot = documentRef.get().await() + + } catch (e: Exception) { + throw Exception("Failed to delete booking: ${e.message}") + } + } + + override suspend fun updateBookingStatus(bookingId: String, status: BookingStatus) { + try { + val documentRef = db.collection(BOOKINGS_COLLECTION_PATH).document(bookingId) + val documentSnapshot = documentRef.get().await() + + if (documentSnapshot.exists()) { + val booking = documentSnapshot.toObject(Booking::class.java) + + // Verify user has access + if (booking?.bookerId != currentUserId && booking?.listingCreatorId != currentUserId) { + throw Exception("Access denied: Cannot update booking status") + } + + documentRef.update("status", status).await() + } else { + throw Exception("Booking with ID $bookingId not found") + } + } catch (e: Exception) { + throw Exception("Failed to update booking status: ${e.message}") + } + } + + override suspend fun confirmBooking(bookingId: String) { + updateBookingStatus(bookingId, BookingStatus.CONFIRMED) + } + + override suspend fun completeBooking(bookingId: String) { + updateBookingStatus(bookingId, BookingStatus.COMPLETED) + } + + override suspend fun cancelBooking(bookingId: String) { + updateBookingStatus(bookingId, BookingStatus.CANCELLED) + } +} diff --git a/app/src/main/java/com/android/sample/model/communication/Message.kt b/app/src/main/java/com/android/sample/model/communication/Message.kt new file mode 100644 index 00000000..4f6522c9 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/communication/Message.kt @@ -0,0 +1,24 @@ +package com.android.sample.model.communication + +import java.util.Date + +/** Data class representing a message between users */ +data class Message( + val sentFrom: String = "", // UID of the sender + val sentTo: String = "", // UID of the receiver + val sentTime: Date = Date(), // Date and time when message was sent + val receiveTime: Date? = null, // Date and time when message was received + val readTime: Date? = null, // Date and time when message was read for the first time + val message: String = "" // The actual message content +) { + init { + require(sentFrom != sentTo) { "Sender and receiver cannot be the same user" } + receiveTime?.let { require(!sentTime.after(it)) { "Receive time cannot be before sent time" } } + readTime?.let { readTime -> + require(!sentTime.after(readTime)) { "Read time cannot be before sent time" } + receiveTime?.let { receiveTime -> + require(!receiveTime.after(readTime)) { "Read time cannot be before receive time" } + } + } + } +} diff --git a/app/src/main/java/com/android/sample/model/communication/MessageRepository.kt b/app/src/main/java/com/android/sample/model/communication/MessageRepository.kt new file mode 100644 index 00000000..a4e6797c --- /dev/null +++ b/app/src/main/java/com/android/sample/model/communication/MessageRepository.kt @@ -0,0 +1,30 @@ +package com.android.sample.model.communication + +interface MessageRepository { + fun getNewUid(): String + + suspend fun getAllMessages(): List + + suspend fun getMessage(messageId: String): Message + + suspend fun getMessagesBetweenUsers(userId1: String, userId2: String): List + + suspend fun getMessagesSentByUser(userId: String): List + + suspend fun getMessagesReceivedByUser(userId: String): List + + suspend fun addMessage(message: Message) + + suspend fun updateMessage(messageId: String, message: Message) + + suspend fun deleteMessage(messageId: String) + + /** Marks message as received */ + suspend fun markAsReceived(messageId: String, receiveTime: java.util.Date) + + /** Marks message as read */ + suspend fun markAsRead(messageId: String, readTime: java.util.Date) + + /** Gets unread messages for a user */ + suspend fun getUnreadMessages(userId: String): List +} diff --git a/app/src/main/java/com/android/sample/model/communication/Notification.kt b/app/src/main/java/com/android/sample/model/communication/Notification.kt new file mode 100644 index 00000000..5bcc06ae --- /dev/null +++ b/app/src/main/java/com/android/sample/model/communication/Notification.kt @@ -0,0 +1,24 @@ +package com.android.sample.model.communication + +/** Enum representing different types of notifications */ +enum class NotificationType { + BOOKING_REQUEST, + BOOKING_CONFIRMED, + BOOKING_CANCELLED, + MESSAGE_RECEIVED, + RATING_RECEIVED, + SYSTEM_UPDATE, + REMINDER +} + +/** Data class representing a notification */ +data class Notification( + val userId: String = "", // UID of the user receiving the notification + val notificationType: NotificationType = NotificationType.SYSTEM_UPDATE, + val notificationMessage: String = "" +) { + init { + require(userId.isNotBlank()) { "User ID cannot be blank" } + require(notificationMessage.isNotBlank()) { "Notification message cannot be blank" } + } +} diff --git a/app/src/main/java/com/android/sample/model/listing/FirestoreListingRepository.kt b/app/src/main/java/com/android/sample/model/listing/FirestoreListingRepository.kt new file mode 100644 index 00000000..a31e1af9 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/listing/FirestoreListingRepository.kt @@ -0,0 +1,175 @@ +package com.android.sample.model.listing + +import com.android.sample.model.map.Location +import com.android.sample.model.skill.Skill +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import java.util.UUID +import kotlinx.coroutines.tasks.await + +const val LISTINGS_COLLECTION_PATH = "listings" + +class FirestoreListingRepository( + private val db: FirebaseFirestore, + private val auth: FirebaseAuth = FirebaseAuth.getInstance() +) : ListingRepository { + + private val currentUserId: String + get() = auth.currentUser?.uid ?: throw Exception("User not authenticated") + + override fun getNewUid(): String { + return UUID.randomUUID().toString() + } + + override suspend fun getAllListings(): List { + return try { + val snapshot = db.collection(LISTINGS_COLLECTION_PATH).get().await() + snapshot.documents.mapNotNull { it.toListing() } + } catch (e: Exception) { + throw Exception("Failed to fetch all listings: ${e.message}") + } + } + + override suspend fun getProposals(): List { + return try { + val snapshot = + db.collection(LISTINGS_COLLECTION_PATH) + .whereEqualTo("type", ListingType.PROPOSAL.name) + .get() + .await() + snapshot.toObjects(Proposal::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch proposals: ${e.message}") + } + } + + override suspend fun getRequests(): List { + return try { + val snapshot = + db.collection(LISTINGS_COLLECTION_PATH) + .whereEqualTo("type", ListingType.REQUEST.name) + .get() + .await() + snapshot.toObjects(Request::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch requests: ${e.message}") + } + } + + override suspend fun getListing(listingId: String): Listing? { + return try { + val document = db.collection(LISTINGS_COLLECTION_PATH).document(listingId).get().await() + document.toListing() + } catch (e: Exception) { + // Return null if listing not found or another error occurs + null + } + } + + override suspend fun getListingsByUser(userId: String): List { + return try { + val snapshot = + db.collection(LISTINGS_COLLECTION_PATH) + .whereEqualTo("creatorUserId", userId) + .get() + .await() + snapshot.documents.mapNotNull { it.toListing() } + } catch (e: Exception) { + throw Exception("Failed to fetch listings for user $userId: ${e.message}") + } + } + + override suspend fun addProposal(proposal: Proposal) { + addListing(proposal) + } + + override suspend fun addRequest(request: Request) { + addListing(request) + } + + private suspend fun addListing(listing: Listing) { + try { + if (listing.creatorUserId != currentUserId) { + throw Exception("Access denied: You can only create listings for yourself.") + } + db.collection(LISTINGS_COLLECTION_PATH).document(listing.listingId).set(listing).await() + } catch (e: Exception) { + throw Exception("Failed to add listing: ${e.message}") + } + } + + override suspend fun updateListing(listingId: String, listing: Listing) { + try { + val docRef = db.collection(LISTINGS_COLLECTION_PATH).document(listingId) + val existingListing = getListing(listingId) ?: throw Exception("Listing not found.") + + if (existingListing.creatorUserId != currentUserId) { + throw Exception("Access denied: You can only update your own listings.") + } + docRef.set(listing).await() + } catch (e: Exception) { + throw Exception("Failed to update listing: ${e.message}") + } + } + + override suspend fun deleteListing(listingId: String) { + try { + val docRef = db.collection(LISTINGS_COLLECTION_PATH).document(listingId) + val existingListing = getListing(listingId) ?: throw Exception("Listing not found.") + + if (existingListing.creatorUserId != currentUserId) { + throw Exception("Access denied: You can only delete your own listings.") + } + docRef.delete().await() + } catch (e: Exception) { + throw Exception("Failed to delete listing: ${e.message}") + } + } + + override suspend fun deactivateListing(listingId: String) { + try { + val docRef = db.collection(LISTINGS_COLLECTION_PATH).document(listingId) + val existingListing = getListing(listingId) ?: throw Exception("Listing not found.") + + if (existingListing.creatorUserId != currentUserId) { + throw Exception("Access denied: You can only deactivate your own listings.") + } + docRef.update("isActive", false).await() + } catch (e: Exception) { + throw Exception("Failed to deactivate listing: ${e.message}") + } + } + + override suspend fun searchBySkill(skill: Skill): List { + return try { + val snapshot = + db.collection(LISTINGS_COLLECTION_PATH) + .whereEqualTo("skill.skill", skill.skill) + .get() + .await() + snapshot.documents.mapNotNull { it.toListing() } + } catch (e: Exception) { + throw Exception("Failed to search by skill: ${e.message}") + } + } + + override suspend fun searchByLocation(location: Location, radiusKm: Double): List { + // Firestore does not support native geo-queries. + // This requires a third-party service like Algolia or a complex implementation with Geohashes. + throw NotImplementedError("Geo-search is not implemented.") + } + + private fun DocumentSnapshot.toListing(): Listing? { + if (!exists()) return null + return try { + when (getString("type")?.let { ListingType.valueOf(it) }) { + ListingType.PROPOSAL -> toObject(Proposal::class.java) + ListingType.REQUEST -> toObject(Request::class.java) + null -> null // Or throw an exception for unknown types + } + } catch (e: IllegalArgumentException) { + null // Handle cases where the string in DB is not a valid enum + } + } +} diff --git a/app/src/main/java/com/android/sample/model/listing/Listing.kt b/app/src/main/java/com/android/sample/model/listing/Listing.kt new file mode 100644 index 00000000..d1b41ea2 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/listing/Listing.kt @@ -0,0 +1,49 @@ +package com.android.sample.model.listing + +import com.android.sample.model.map.Location +import com.android.sample.model.skill.Skill +import java.util.Date + +enum class ListingType { + PROPOSAL, + REQUEST +} + +/** Base class for proposals and requests */ +sealed class Listing { + abstract val listingId: String + abstract val creatorUserId: String + abstract val skill: Skill + abstract val description: String + abstract val location: Location + abstract val createdAt: Date + abstract val isActive: Boolean + abstract val hourlyRate: Double + abstract val type: ListingType +} + +/** Proposal - user offering to teach */ +data class Proposal( + override val listingId: String = "", + override val creatorUserId: String = "", + override val skill: Skill = Skill(), + override val description: String = "", + override val location: Location = Location(), + override val createdAt: Date = Date(), + override val isActive: Boolean = true, + override val hourlyRate: Double = 0.0, + override val type: ListingType = ListingType.PROPOSAL +) : Listing() {} + +/** Request - user looking for a tutor */ +data class Request( + override val listingId: String = "", + override val creatorUserId: String = "", + override val skill: Skill = Skill(), + override val description: String = "", + override val location: Location = Location(), + override val createdAt: Date = Date(), + override val isActive: Boolean = true, + override val hourlyRate: Double = 0.0, + override val type: ListingType = ListingType.REQUEST +) : Listing() {} diff --git a/app/src/main/java/com/android/sample/model/listing/ListingRepository.kt b/app/src/main/java/com/android/sample/model/listing/ListingRepository.kt new file mode 100644 index 00000000..d151db3e --- /dev/null +++ b/app/src/main/java/com/android/sample/model/listing/ListingRepository.kt @@ -0,0 +1,35 @@ +package com.android.sample.model.listing + +interface ListingRepository { + fun getNewUid(): String + + suspend fun getAllListings(): List+ + suspend fun getProposals(): List + + suspend fun getRequests(): List + + suspend fun getListing(listingId: String): Listing? + + suspend fun getListingsByUser(userId: String): List+ + suspend fun addProposal(proposal: Proposal) + + suspend fun addRequest(request: Request) + + suspend fun updateListing(listingId: String, listing: Listing) + + suspend fun deleteListing(listingId: String) + + /** Deactivates a listing */ + suspend fun deactivateListing(listingId: String) + + /** Searches listings by skill type */ + suspend fun searchBySkill(skill: com.android.sample.model.skill.Skill): List+ + /** Searches listings by location proximity */ + suspend fun searchByLocation( + location: com.android.sample.model.map.Location, + radiusKm: Double + ): List+} diff --git a/app/src/main/java/com/android/sample/model/listing/ListingRepositoryProvider.kt b/app/src/main/java/com/android/sample/model/listing/ListingRepositoryProvider.kt new file mode 100644 index 00000000..b377c699 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/listing/ListingRepositoryProvider.kt @@ -0,0 +1,28 @@ +package com.android.sample.model.listing + +import android.content.Context +import com.google.firebase.FirebaseApp +import com.google.firebase.firestore.ktx.firestore +import com.google.firebase.ktx.Firebase + +object ListingRepositoryProvider { + + @Volatile private var _repository: ListingRepository? = null + + val repository: ListingRepository + get() = + _repository + ?: error( + "ListingRepository not initialized. Call init(...) first or setForTests(...) in tests.") + + fun init(context: Context, useEmulator: Boolean = false) { + if (FirebaseApp.getApps(context).isEmpty()) { + FirebaseApp.initializeApp(context) + } + _repository = FirestoreListingRepository(Firebase.firestore) + } + + fun setForTests(repository: ListingRepository) { + _repository = repository + } +} diff --git a/app/src/main/java/com/android/sample/model/map/Location.kt b/app/src/main/java/com/android/sample/model/map/Location.kt new file mode 100644 index 00000000..91bb6a5c --- /dev/null +++ b/app/src/main/java/com/android/sample/model/map/Location.kt @@ -0,0 +1,3 @@ +package com.android.sample.model.map + +data class Location(val latitude: Double = 0.0, val longitude: Double = 0.0, val name: String = "") diff --git a/app/src/main/java/com/android/sample/model/rating/FirestoreRatingRepository.kt b/app/src/main/java/com/android/sample/model/rating/FirestoreRatingRepository.kt new file mode 100644 index 00000000..ace3f116 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/rating/FirestoreRatingRepository.kt @@ -0,0 +1,166 @@ +package com.android.sample.model.rating + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import java.util.UUID +import kotlinx.coroutines.tasks.await + +const val RATINGS_COLLECTION_PATH = "ratings" + +class FirestoreRatingRepository( + private val db: FirebaseFirestore, + private val auth: FirebaseAuth = FirebaseAuth.getInstance() +) : RatingRepository { + + private val currentUserId: String + get() = auth.currentUser?.uid ?: throw Exception("User not authenticated") + + override fun getNewUid(): String { + return UUID.randomUUID().toString() + } + + override suspend fun getAllRatings(): List { + try { + val snapshot = + db.collection(RATINGS_COLLECTION_PATH) + .whereEqualTo("fromUserId", currentUserId) + .get() + .await() + return snapshot.toObjects(Rating::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch ratings: ${e.message}") + } + } + + override suspend fun getRating(ratingId: String): Rating? { + try { + val document = db.collection(RATINGS_COLLECTION_PATH).document(ratingId).get().await() + if (!document.exists()) { + return null + } + val rating = + document.toObject(Rating::class.java) + ?: throw Exception("Failed to parse Rating with ID $ratingId") + + if (rating.fromUserId != currentUserId && rating.toUserId != currentUserId) { + throw Exception("Access denied: This rating is not related to the current user") + } + return rating + } catch (e: Exception) { + throw Exception("Failed to get rating: ${e.message}") + } + } + + override suspend fun getRatingsByFromUser(fromUserId: String): List { + try { + val snapshot = + db.collection(RATINGS_COLLECTION_PATH) + .whereEqualTo("fromUserId", fromUserId) + .get() + .await() + return snapshot.toObjects(Rating::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch ratings from user $fromUserId: ${e.message}") + } + } + + override suspend fun getRatingsByToUser(toUserId: String): List { + try { + val snapshot = + db.collection(RATINGS_COLLECTION_PATH).whereEqualTo("toUserId", toUserId).get().await() + return snapshot.toObjects(Rating::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch ratings for user $toUserId: ${e.message}") + } + } + + override suspend fun getRatingsOfListing(listingId: String): List { + try { + val snapshot = + db.collection(RATINGS_COLLECTION_PATH) + .whereEqualTo("ratingType", "LISTING") + .whereEqualTo("targetObjectId", listingId) + .get() + .await() + return snapshot.toObjects(Rating::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch ratings for listing $listingId: ${e.message}") + } + } + + override suspend fun addRating(rating: Rating) { + try { + if (rating.fromUserId != currentUserId) { + throw Exception("Access denied: You can only add ratings behalf of yourself.") + } + if (rating.toUserId == currentUserId) { + throw Exception("You cannot rate yourself.") + } + db.collection(RATINGS_COLLECTION_PATH).document(rating.ratingId).set(rating).await() + } catch (e: Exception) { + throw Exception("Failed to add rating: ${e.message}") + } + } + + override suspend fun updateRating(ratingId: String, rating: Rating) { + try { + val documentRef = db.collection(RATINGS_COLLECTION_PATH).document(ratingId) + val existingRating = getRating(ratingId) // Leverages existing access check + + if (existingRating != null) { + if (existingRating.fromUserId != currentUserId) { + throw Exception("Access denied: You can only update ratings you have created.") + } + } + + documentRef.set(rating).await() + } catch (e: Exception) { + throw Exception("Failed to update rating: ${e.message}") + } + } + + override suspend fun deleteRating(ratingId: String) { + try { + val documentRef = db.collection(RATINGS_COLLECTION_PATH).document(ratingId) + val rating = getRating(ratingId) // Leverages existing access check + + if (rating != null) { + if (rating.fromUserId != currentUserId) { + throw Exception("Access denied: You can only delete ratings you have created.") + } + } + + documentRef.delete().await() + } catch (e: Exception) { + throw Exception("Failed to delete rating: ${e.message}") + } + } + + override suspend fun getTutorRatingsOfUser(userId: String): List { + try { + val snapshot = + db.collection(RATINGS_COLLECTION_PATH) + .whereEqualTo("toUserId", userId) + .whereEqualTo("ratingType", "TUTOR") + .get() + .await() + return snapshot.toObjects(Rating::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch tutor ratings for user $userId: ${e.message}") + } + } + + override suspend fun getStudentRatingsOfUser(userId: String): List { + try { + val snapshot = + db.collection(RATINGS_COLLECTION_PATH) + .whereEqualTo("toUserId", userId) + .whereEqualTo("ratingType", "STUDENT") + .get() + .await() + return snapshot.toObjects(Rating::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch student ratings for user $userId: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/android/sample/model/rating/Rating.kt b/app/src/main/java/com/android/sample/model/rating/Rating.kt new file mode 100644 index 00000000..4f1dad39 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/rating/Rating.kt @@ -0,0 +1,66 @@ +package com.android.sample.model.rating + +import com.google.firebase.firestore.DocumentId + +/** + * Represents a rating given by one user to another, in a specific context (Tutor, Student, or + * Listing). + * + * @property ratingId The unique identifier for the rating. + * @property fromUserId The ID of the user who gave the rating. + * @property toUserId The ID of the user who received the rating. + * @property starRating The star rating value. + * @property comment An optional comment with the rating. + * @property ratingType The type of the rating (e.g., Tutor, Student). + * @property targetObjectId The ID of the object being rated (e.g., a listing ID or user ID). + */ +data class Rating( + @DocumentId val ratingId: String = "", + val fromUserId: String = "", + val toUserId: String = "", + val starRating: StarRating = StarRating.ONE, + val comment: String = "", + val ratingType: RatingType = RatingType.TUTOR, + val targetObjectId: String = "", +) { + /** Default constructor for Firestore deserialization. */ + constructor() : + this( + ratingId = "", + fromUserId = "", + toUserId = "", + starRating = StarRating.ONE, + comment = "", + ratingType = RatingType.TUTOR, + targetObjectId = "") + + /** Validates the rating data. Throws an [IllegalArgumentException] if the data is invalid. */ + fun validate() { + require(fromUserId.isNotBlank()) { "From user ID must not be blank" } + require(toUserId.isNotBlank()) { "To user ID must not be blank" } + require(fromUserId != toUserId) { "From user and to user must be different" } + require(targetObjectId.isNotBlank()) { "Target object ID must not be blank" } + } +} + +/** Represents the type of a rating. */ +enum class RatingType { + TUTOR, + STUDENT, + LISTING +} + +/** + * Holds aggregated rating information, such as the average rating and total number of ratings. + * + * @property averageRating The calculated average rating. Must be 0.0 or between 1.0 and 5.0. + * @property totalRatings The total count of ratings. Must be non-negative. + */ +data class RatingInfo(val averageRating: Double = 0.0, val totalRatings: Int = 0) { + init { + require(averageRating == 0.0 || averageRating in 1.0..5.0) { + "Average rating must be 0.0 or between 1.0 and 5.0" + } + require(totalRatings >= 0) { "Total ratings must be non-negative" } + } +} diff --git a/app/src/main/java/com/android/sample/model/rating/RatingRepository.kt b/app/src/main/java/com/android/sample/model/rating/RatingRepository.kt new file mode 100644 index 00000000..7f8df84e --- /dev/null +++ b/app/src/main/java/com/android/sample/model/rating/RatingRepository.kt @@ -0,0 +1,27 @@ +package com.android.sample.model.rating + +interface RatingRepository { + fun getNewUid(): String + + suspend fun getAllRatings(): List + + suspend fun getRating(ratingId: String): Rating? + + suspend fun getRatingsByFromUser(fromUserId: String): List + + suspend fun getRatingsByToUser(toUserId: String): List + + suspend fun getRatingsOfListing(listingId: String): List + + suspend fun addRating(rating: Rating) + + suspend fun updateRating(ratingId: String, rating: Rating) + + suspend fun deleteRating(ratingId: String) + + /** Gets all tutor ratings for listings owned by this user */ + suspend fun getTutorRatingsOfUser(userId: String): List + + /** Gets all student ratings received by this user */ + suspend fun getStudentRatingsOfUser(userId: String): List +} diff --git a/app/src/main/java/com/android/sample/model/rating/RatingRepositoryProvider.kt b/app/src/main/java/com/android/sample/model/rating/RatingRepositoryProvider.kt new file mode 100644 index 00000000..2efca6c7 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/rating/RatingRepositoryProvider.kt @@ -0,0 +1,28 @@ +// kotlin +package com.android.sample.model.rating + +import android.content.Context +import com.google.firebase.FirebaseApp +import com.google.firebase.firestore.ktx.firestore +import com.google.firebase.ktx.Firebase + +object RatingRepositoryProvider { + @Volatile private var _repository: RatingRepository? = null + + val repository: RatingRepository + get() = + _repository + ?: error( + "RatingRepository not initialized. Call init(...) first or setForTests(...) in tests.") + + fun init(context: Context, useEmulator: Boolean = false) { + if (FirebaseApp.getApps(context).isEmpty()) { + FirebaseApp.initializeApp(context) + } + _repository = FirestoreRatingRepository(Firebase.firestore) + } + + fun setForTests(repository: RatingRepository) { + _repository = repository + } +} diff --git a/app/src/main/java/com/android/sample/model/rating/StarRating.kt b/app/src/main/java/com/android/sample/model/rating/StarRating.kt new file mode 100644 index 00000000..a7cc14b7 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/rating/StarRating.kt @@ -0,0 +1,16 @@ +package com.android.sample.model.rating + +/** Enum representing possible star ratings (1-5) */ +enum class StarRating(val value: Int) { + ONE(1), + TWO(2), + THREE(3), + FOUR(4), + FIVE(5); + + companion object { + fun fromInt(value: Int): StarRating = + values().find { it.value == value } + ?: throw IllegalArgumentException("Invalid star rating: $value") + } +} diff --git a/app/src/main/java/com/android/sample/model/rating/StarRatingConverter.kt b/app/src/main/java/com/android/sample/model/rating/StarRatingConverter.kt new file mode 100644 index 00000000..d3a4c00b --- /dev/null +++ b/app/src/main/java/com/android/sample/model/rating/StarRatingConverter.kt @@ -0,0 +1,10 @@ +package com.android.sample.model.rating + +/** Converter for serializing/deserializing StarRating enum for Firestore */ +/** Was recommended to facilitate the repository work so i am leaving it here */ +/** If unnecessary after the repository work please delete */ +object StarRatingConverter { + @JvmStatic fun toInt(starRating: StarRating): Int = starRating.value + + @JvmStatic fun fromInt(value: Int): StarRating = StarRating.fromInt(value) +} diff --git a/app/src/main/java/com/android/sample/model/skill/Skill.kt b/app/src/main/java/com/android/sample/model/skill/Skill.kt new file mode 100644 index 00000000..bb08c9fe --- /dev/null +++ b/app/src/main/java/com/android/sample/model/skill/Skill.kt @@ -0,0 +1,150 @@ +package com.android.sample.model.skill + +/** Enum representing main subject categories */ +enum class MainSubject { + ACADEMICS, + SPORTS, + MUSIC, + ARTS, + TECHNOLOGY, + LANGUAGES, + CRAFTS +} + +/** Enum representing academic skills */ +enum class AcademicSkills { + MATHEMATICS, + PHYSICS, + CHEMISTRY, + BIOLOGY, + HISTORY, + GEOGRAPHY, + LITERATURE, + ECONOMICS, + PSYCHOLOGY, + PHILOSOPHY +} + +/** Enum representing sports skills */ +enum class SportsSkills { + FOOTBALL, + BASKETBALL, + TENNIS, + SWIMMING, + RUNNING, + SOCCER, + VOLLEYBALL, + BASEBALL, + GOLF, + CYCLING +} + +/** Enum representing music skills */ +enum class MusicSkills { + PIANO, + GUITAR, + VIOLIN, + DRUMS, + SINGING, + SAXOPHONE, + FLUTE, + TRUMPET, + CELLO, + BASS +} + +/** Enum representing arts skills */ +enum class ArtsSkills { + PAINTING, + DRAWING, + SCULPTURE, + PHOTOGRAPHY, + DIGITAL_ART, + POTTERY, + GRAPHIC_DESIGN, + ILLUSTRATION, + CALLIGRAPHY, + ANIMATION +} + +/** Enum representing technology skills */ +enum class TechnologySkills { + PROGRAMMING, + WEB_DEVELOPMENT, + MOBILE_DEVELOPMENT, + DATA_SCIENCE, + CYBERSECURITY, + AI_MACHINE_LEARNING, + DATABASE_MANAGEMENT, + CLOUD_COMPUTING, + NETWORKING, + GAME_DEVELOPMENT +} + +/** Enum representing language skills */ +enum class LanguageSkills { + ENGLISH, + SPANISH, + FRENCH, + GERMAN, + ITALIAN, + CHINESE, + JAPANESE, + KOREAN, + ARABIC, + PORTUGUESE +} + +/** Enum representing craft skills */ +enum class CraftSkills { + KNITTING, + SEWING, + WOODWORKING, + JEWELRY_MAKING, + COOKING, + BAKING, + GARDENING, + CARPENTRY, + EMBROIDERY, + ORIGAMI +} + +/** Enum representing expertise levels */ +enum class ExpertiseLevel { + BEGINNER, + INTERMEDIATE, + ADVANCED, + EXPERT, + MASTER +} + +/** Data class representing a skill */ +data class Skill( + val mainSubject: MainSubject = MainSubject.ACADEMICS, + val skill: String = "", // Specific skill name (use enum.name when creating) + val skillTime: Double = 0.0, // Time spent on this skill (in years) + val expertise: ExpertiseLevel = ExpertiseLevel.BEGINNER +) { + init { + require(skillTime >= 0.0) { "Skill time must be non-negative" } + } +} + +/** Helper functions to get skills for each main subject */ +object SkillsHelper { + fun getSkillsForSubject(mainSubject: MainSubject): Array> { + return when (mainSubject) { + MainSubject.ACADEMICS -> AcademicSkills.values() + MainSubject.SPORTS -> SportsSkills.values() + MainSubject.MUSIC -> MusicSkills.values() + MainSubject.ARTS -> ArtsSkills.values() + MainSubject.TECHNOLOGY -> TechnologySkills.values() + MainSubject.LANGUAGES -> LanguageSkills.values() + MainSubject.CRAFTS -> CraftSkills.values() + } + } + + fun getSkillNames(mainSubject: MainSubject): List { + return getSkillsForSubject(mainSubject).map { it.name } + } +} diff --git a/app/src/main/java/com/android/sample/model/tutor/FakeProfileRepository.kt b/app/src/main/java/com/android/sample/model/tutor/FakeProfileRepository.kt new file mode 100644 index 00000000..08772987 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/tutor/FakeProfileRepository.kt @@ -0,0 +1,61 @@ +package com.android.sample.model.tutor + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import com.android.sample.model.map.Location +import com.android.sample.model.rating.RatingInfo +import com.android.sample.model.user.Profile +import kotlin.collections.addAll + +class FakeProfileRepository { + + private val _tutors: SnapshotStateList = mutableStateListOf() + + val tutors: List + get() = _tutors + + private val _fakeUser: Profile = + Profile( + userId = "1", + name = "Ava S.", + email = "ava@gmail.com", + levelOfEducation = "", + location = Location(latitude = 0.0, longitude = 0.0), + hourlyRate = "", + description = "", + tutorRating = RatingInfo(4.8, 25), + studentRating = RatingInfo(5.0, 10)) + val fakeUser: Profile + get() = _fakeUser + + init { + loadMockData() + } + + /** Loads fake tutor listings (mock data) */ + private fun loadMockData() { + _tutors.addAll( + listOf( + Profile( + userId = "12", + name = "Liam P.", + email = "none1@gmail.com", + levelOfEducation = "", + description = "", + location = Location(latitude = 0.0, longitude = 0.0)), + Profile( + userId = "13", + name = "Maria G.", + email = "none2@gmail.com", + levelOfEducation = "", + description = "", + location = Location(latitude = 0.0, longitude = 0.0)), + Profile( + userId = "14", + name = "David C.", + email = "none3@gmail.com", + levelOfEducation = "", + description = "", + location = Location(latitude = 0.0, longitude = 0.0)))) + } +} diff --git a/app/src/main/java/com/android/sample/model/user/FakeProfileRepository.kt b/app/src/main/java/com/android/sample/model/user/FakeProfileRepository.kt new file mode 100644 index 00000000..454ce4bd --- /dev/null +++ b/app/src/main/java/com/android/sample/model/user/FakeProfileRepository.kt @@ -0,0 +1,64 @@ +package com.android.sample.model.user + +import com.android.sample.model.map.Location +import com.android.sample.model.skill.Skill +import kotlin.math.* + +// Simple in-memory fake repository for tests / previews. +class FakeProfileRepository : ProfileRepository { + private val data = mutableMapOf() + private var counter = 0 + + override fun getNewUid(): String = + synchronized(this) { + counter += 1 + "u$counter" + } + + override suspend fun getProfile(userId: String): Profile = + data[userId] ?: throw NoSuchElementException("Profile not found: $userId") + + override suspend fun addProfile(profile: Profile) { + val id = if (profile.userId.isBlank()) getNewUid() else profile.userId + synchronized(this) { data[id] = profile.copy(userId = id) } + } + + override suspend fun updateProfile(userId: String, profile: Profile) { + synchronized(this) { data[userId] = profile.copy(userId = userId) } + } + + override suspend fun deleteProfile(userId: String) { + synchronized(this) { data.remove(userId) } + } + + override suspend fun getAllProfiles(): List = synchronized(this) { data.values.toList() } + + override suspend fun searchProfilesByLocation( + location: Location, + radiusKm: Double + ): List { + if (radiusKm <= 0.0) return getAllProfiles() + return synchronized(this) { + data.values.filter { distanceKm(it.location, location) <= radiusKm } + } + } + + override suspend fun getProfileById(userId: String): Profile { + TODO("Not yet implemented") + } + + override suspend fun getSkillsForUser(userId: String): List { + TODO("Not yet implemented") + } + + private fun distanceKm(a: Location, b: Location): Double { + // Use the actual coordinate property names on Location (latitude / longitude) + val R = 6371.0 + val dLat = Math.toRadians(a.latitude - b.latitude) + val dLon = Math.toRadians(a.longitude - b.longitude) + val lat1 = Math.toRadians(a.latitude) + val lat2 = Math.toRadians(b.latitude) + val hav = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2) + return 2 * R * asin(sqrt(hav)) + } +} diff --git a/app/src/main/java/com/android/sample/model/user/FirestoreProfileRepository.kt b/app/src/main/java/com/android/sample/model/user/FirestoreProfileRepository.kt new file mode 100644 index 00000000..6ad245d4 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/user/FirestoreProfileRepository.kt @@ -0,0 +1,106 @@ +package com.android.sample.model.user + +import com.android.sample.model.map.Location +import com.android.sample.model.skill.Skill +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import java.util.UUID +import kotlinx.coroutines.tasks.await + +const val PROFILES_COLLECTION_PATH = "profiles" + +class FirestoreProfileRepository( + private val db: FirebaseFirestore, + private val auth: FirebaseAuth = FirebaseAuth.getInstance() +) : ProfileRepository { + + private val currentUserId: String + get() = auth.currentUser?.uid ?: throw Exception("User not authenticated") + + override fun getNewUid(): String { + return UUID.randomUUID().toString() + } + + override suspend fun getProfile(userId: String): Profile? { + return try { + val document = db.collection(PROFILES_COLLECTION_PATH).document(userId).get().await() + if (!document.exists()) { + return null + } + document.toObject(Profile::class.java) + } catch (e: Exception) { + throw Exception("Failed to get profile for user $userId: ${e.message}") + } + } + + override suspend fun addProfile(profile: Profile) { + try { + if (profile.userId != currentUserId) { + throw Exception("Access denied: You can only create a profile for yourself.") + } + db.collection(PROFILES_COLLECTION_PATH).document(profile.userId).set(profile).await() + } catch (e: Exception) { + throw Exception("Failed to add profile: ${e.message}") + } + } + + override suspend fun updateProfile(userId: String, profile: Profile) { + try { + if (userId != currentUserId) { + throw Exception("Access denied: You can only update your own profile.") + } + db.collection(PROFILES_COLLECTION_PATH).document(userId).set(profile).await() + } catch (e: Exception) { + throw Exception("Failed to update profile for user $userId: ${e.message}") + } + } + + override suspend fun deleteProfile(userId: String) { + try { + if (userId != currentUserId) { + throw Exception("Access denied: You can only delete your own profile.") + } + db.collection(PROFILES_COLLECTION_PATH).document(userId).delete().await() + } catch (e: Exception) { + throw Exception("Failed to delete profile for user $userId: ${e.message}") + } + } + + override suspend fun getAllProfiles(): List { + try { + val snapshot = db.collection(PROFILES_COLLECTION_PATH).get().await() + return snapshot.toObjects(Profile::class.java) + } catch (e: Exception) { + throw Exception("Failed to fetch all profiles: ${e.message}") + } + } + + override suspend fun searchProfilesByLocation( + location: Location, + radiusKm: Double + ): List { + // Note: Firestore does not support complex geo-queries out of the box. + // This would require a more complex setup with geohashing or a third-party service like + // Algolia. + throw NotImplementedError("Geo-search is not implemented.") + } + + override suspend fun getProfileById(userId: String): Profile? { + return getProfile(userId) + } + + override suspend fun getSkillsForUser(userId: String): List { + // This assumes skills are stored in a sub-collection named 'skills' under each profile. + try { + val snapshot = + db.collection(PROFILES_COLLECTION_PATH) + .document(userId) + .collection("skills") + .get() + .await() + return snapshot.toObjects(Skill::class.java) + } catch (e: Exception) { + throw Exception("Failed to get skills for user $userId: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/android/sample/model/user/Profile.kt b/app/src/main/java/com/android/sample/model/user/Profile.kt new file mode 100644 index 00000000..c802b7de --- /dev/null +++ b/app/src/main/java/com/android/sample/model/user/Profile.kt @@ -0,0 +1,16 @@ +package com.android.sample.model.user + +import com.android.sample.model.map.Location +import com.android.sample.model.rating.RatingInfo + +data class Profile( + val userId: String = "", + val name: String? = "", + val email: String = "", + val levelOfEducation: String = "", + val location: Location = Location(), + val hourlyRate: String = "", + val description: String = "", + val tutorRating: RatingInfo = RatingInfo(), + val studentRating: RatingInfo = RatingInfo(), +) diff --git a/app/src/main/java/com/android/sample/model/user/ProfileRepository.kt b/app/src/main/java/com/android/sample/model/user/ProfileRepository.kt new file mode 100644 index 00000000..c246d33f --- /dev/null +++ b/app/src/main/java/com/android/sample/model/user/ProfileRepository.kt @@ -0,0 +1,26 @@ +package com.android.sample.model.user + +import com.android.sample.model.skill.Skill + +interface ProfileRepository { + fun getNewUid(): String + + suspend fun getProfile(userId: String): Profile? + + suspend fun addProfile(profile: Profile) + + suspend fun updateProfile(userId: String, profile: Profile) + + suspend fun deleteProfile(userId: String) + + suspend fun getAllProfiles(): List + + suspend fun searchProfilesByLocation( + location: com.android.sample.model.map.Location, + radiusKm: Double + ): List + + suspend fun getProfileById(userId: String): Profile? + + suspend fun getSkillsForUser(userId: String): List +} diff --git a/app/src/main/java/com/android/sample/model/user/ProfileRepositoryProvider.kt b/app/src/main/java/com/android/sample/model/user/ProfileRepositoryProvider.kt new file mode 100644 index 00000000..99a9fa48 --- /dev/null +++ b/app/src/main/java/com/android/sample/model/user/ProfileRepositoryProvider.kt @@ -0,0 +1,27 @@ +// kotlin +package com.android.sample.model.user + +import android.content.Context +import com.google.firebase.FirebaseApp +import com.google.firebase.firestore.ktx.firestore +import com.google.firebase.ktx.Firebase + +object ProfileRepositoryProvider { + @Volatile private var _repository: ProfileRepository? = null + + val repository: ProfileRepository + get() = + _repository + ?: error("Profile not initialized. Call init(...) first or setForTests(...) in tests.") + + fun init(context: Context, useEmulator: Boolean = false) { + if (FirebaseApp.getApps(context).isEmpty()) { + FirebaseApp.initializeApp(context) + } + _repository = FirestoreProfileRepository(Firebase.firestore) + } + + fun setForTests(repository: ProfileRepository) { + _repository = repository + } +} diff --git a/app/src/main/java/com/android/sample/ui/bookings/MyBookingsScreen.kt b/app/src/main/java/com/android/sample/ui/bookings/MyBookingsScreen.kt new file mode 100644 index 00000000..8ccfd9f1 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/bookings/MyBookingsScreen.kt @@ -0,0 +1,155 @@ +// Kotlin +package com.android.sample.ui.bookings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.android.sample.ui.theme.BrandBlue +import com.android.sample.ui.theme.CardBg +import com.android.sample.ui.theme.ChipBorder + +object MyBookingsPageTestTag { + const val BOOKING_CARD = "bookingCard" + const val BOOKING_DETAILS_BUTTON = "bookingDetailsButton" + const val NAV_HOME = "navHome" + const val NAV_BOOKINGS = "navBookings" + const val NAV_MESSAGES = "navMessages" + const val NAV_PROFILE = "navProfile" + const val EMPTY_BOOKINGS = "emptyBookings" +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MyBookingsScreen( + viewModel: MyBookingsViewModel, + navController: NavHostController, + onOpenDetails: ((BookingCardUi) -> Unit)? = null, + onOpenTutor: ((BookingCardUi) -> Unit)? = null, + modifier: Modifier = Modifier +) { + Scaffold { inner -> + val bookings by viewModel.uiState.collectAsState(initial = emptyList()) + BookingsList( + bookings = bookings, + navController = navController, + onOpenDetails = onOpenDetails, + onOpenTutor = onOpenTutor, + modifier = modifier.padding(inner)) + } +} + +@Composable +fun BookingsList( + bookings: List, + navController: NavHostController, + onOpenDetails: ((BookingCardUi) -> Unit)? = null, + onOpenTutor: ((BookingCardUi) -> Unit)? = null, + modifier: Modifier = Modifier +) { + if (bookings.isEmpty()) { + Box( + modifier = + modifier.fillMaxSize().padding(16.dp).testTag(MyBookingsPageTestTag.EMPTY_BOOKINGS), + contentAlignment = Alignment.Center) { + Text(text = "No bookings available") + } + return + } + + LazyColumn( + modifier = modifier.fillMaxSize().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(bookings, key = { it.id }) { booking -> + BookingCard( + booking = booking, + onOpenDetails = { + onOpenDetails?.invoke(it) ?: navController.navigate("lesson/${it.id}") + }, + onOpenTutor = { + onOpenTutor?.invoke(it) ?: navController.navigate("tutor/${it.tutorId}") + }) + } + } +} + +@Composable +private fun BookingCard( + booking: BookingCardUi, + onOpenDetails: (BookingCardUi) -> Unit, + onOpenTutor: (BookingCardUi) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth().testTag(MyBookingsPageTestTag.BOOKING_CARD), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = CardBg)) { + Row(modifier = Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier.size(36.dp) + .background(Color.White, CircleShape) + .border(2.dp, ChipBorder, CircleShape), + contentAlignment = Alignment.Center) { + val first = booking.tutorName?.firstOrNull()?.uppercaseChar() ?: '—' + Text(first.toString(), fontWeight = FontWeight.Bold) + } + + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + "a", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.clickable { onOpenTutor(booking) }) + Spacer(Modifier.height(2.dp)) + Text(booking.subject, color = BrandBlue) + Spacer(Modifier.height(6.dp)) + Text( + "${booking.pricePerHourLabel} - ${booking.durationLabel}", + color = BrandBlue, + fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text(booking.dateLabel) + Spacer(Modifier.height(6.dp)) + RatingRow(stars = booking.ratingStars, count = booking.ratingCount) + } + + Column(horizontalAlignment = Alignment.End) { + Spacer(Modifier.height(8.dp)) + Button( + onClick = { onOpenDetails(booking) }, + modifier = Modifier.testTag(MyBookingsPageTestTag.BOOKING_DETAILS_BUTTON), + colors = + ButtonDefaults.buttonColors( + containerColor = BrandBlue, contentColor = Color.White)) { + Text("details") + } + } + } + } +} + +@Composable +private fun RatingRow(stars: Int, count: Int) { + val full = "★".repeat(stars.coerceIn(0, 5)) + val empty = "☆".repeat((5 - stars).coerceIn(0, 5)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(full + empty) + Spacer(Modifier.width(6.dp)) + Text("($count)") + } +} diff --git a/app/src/main/java/com/android/sample/ui/bookings/MyBookingsViewModel.kt b/app/src/main/java/com/android/sample/ui/bookings/MyBookingsViewModel.kt new file mode 100644 index 00000000..ecbbe342 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/bookings/MyBookingsViewModel.kt @@ -0,0 +1,143 @@ +package com.android.sample.ui.bookings + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.sample.model.booking.Booking +import com.android.sample.model.booking.BookingRepository +import com.android.sample.model.booking.BookingRepositoryProvider +import com.android.sample.model.listing.Listing +import com.android.sample.model.listing.ListingRepository +import com.android.sample.model.listing.ListingRepositoryProvider +import com.android.sample.model.rating.Rating +import com.android.sample.model.rating.RatingRepository +import com.android.sample.model.rating.RatingRepositoryProvider +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.model.user.ProfileRepositoryProvider +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.roundToInt +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class BookingCardUi( + val id: String, + val tutorId: String, + val tutorName: String?, + val subject: String, + val pricePerHourLabel: String, + val durationLabel: String, + val dateLabel: String, + val ratingStars: Int = 0, + val ratingCount: Int = 0 +) + +/** + * Minimal VM: + * - uiState is just the final list of cards + * - init calls load() + * - load() loops bookings and pulls listing/profile/rating to build each card + */ +class MyBookingsViewModel( + private val bookingRepo: BookingRepository = BookingRepositoryProvider.repository, + private val userId: String, + private val listingRepo: ListingRepository = ListingRepositoryProvider.repository, + private val profileRepo: ProfileRepository = ProfileRepositoryProvider.repository, + private val ratingRepo: RatingRepository = RatingRepositoryProvider.repository, + private val locale: Locale = Locale.getDefault(), +) : ViewModel() { + + private val _uiState = MutableStateFlow>(emptyList()) + val uiState: StateFlow> = _uiState.asStateFlow() + + private val dateFmt = SimpleDateFormat("dd/MM/yyyy", locale) + + init { + load() + } + + fun load() { + viewModelScope.launch { + val result = mutableListOf() + try { + val bookings = bookingRepo.getBookingsByUserId(userId) + for (b in bookings) { + val card = buildCardSafely(b) + if (card != null) result += card + } + _uiState.value = result + } catch (e: Throwable) { + Log.e("MyBookingsViewModel", "Error loading bookings for $userId", e) + _uiState.value = emptyList() + } + } + } + + private suspend fun buildCardSafely(b: Booking): BookingCardUi? { + return try { + val listing = listingRepo.getListing(b.associatedListingId) + val profile = profileRepo.getProfile(b.listingCreatorId) + val ratings = ratingRepo.getRatingsOfListing(b.associatedListingId) + buildCard(b, listing, profile, ratings) + } catch (e: Throwable) { + Log.e("MyBookingsViewModel", "Skipping booking ${b.bookingId}", e) + null + } + } + + private fun buildCard( + b: Booking, + listing: Listing?, + profile: Profile?, + ratings: List + ): BookingCardUi { + val tutorName = profile?.name + val subject = listing?.skill?.mainSubject.toString() + val pricePerHourLabel = String.format(locale, "$%.1f/hr", b.price) + val durationLabel = formatDuration(b.sessionStart, b.sessionEnd) + val dateLabel = formatDate(b.sessionStart) + + val ratingCount = ratings.size + val ratingStars = + if (ratingCount > 0) { + val total = ratings.sumOf { it.starRating.value } // assuming value is Int 1..5 + (total.toDouble() / ratingCount).roundToInt().coerceIn(0, 5) + } else { + 0 + } + + return BookingCardUi( + id = b.bookingId, + tutorId = b.listingCreatorId, + tutorName = tutorName, + subject = subject, + pricePerHourLabel = pricePerHourLabel, + durationLabel = durationLabel, + dateLabel = dateLabel, + ratingStars = ratingStars, + ratingCount = ratingCount) + } + + private fun formatDuration(start: Date, end: Date): String { + val durationMs = (end.time - start.time).coerceAtLeast(0L) + val hours = durationMs / (60 * 60 * 1000) + val mins = (durationMs / (60 * 1000)) % 60 + return if (mins == 0L) { + val plural = if (hours > 1L) "s" else "" + "${hours}hr$plural" + } else { + "${hours}h ${mins}m" + } + } + + private fun formatDate(d: Date): String = + try { + dateFmt.format(d) + } catch (_: Throwable) { + "" + } +} diff --git a/app/src/main/java/com/android/sample/ui/components/AppButton.kt b/app/src/main/java/com/android/sample/ui/components/AppButton.kt new file mode 100644 index 00000000..7deb6c5d --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/components/AppButton.kt @@ -0,0 +1,37 @@ +package com.android.sample.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.android.sample.ui.theme.BlueApp +import com.android.sample.ui.theme.GreenApp + +@Composable +fun AppButton(text: String, onClick: () -> Unit, testTag: String) { + Box( + modifier = + Modifier.width(300.dp) + .background( + brush = Brush.linearGradient(colors = listOf(BlueApp, GreenApp)), + shape = MaterialTheme.shapes.large)) { + ExtendedFloatingActionButton( + modifier = + Modifier.fillMaxWidth().shadow(0.dp, MaterialTheme.shapes.large).testTag(testTag), + containerColor = Color.Transparent, + elevation = FloatingActionButtonDefaults.elevation(0.dp), + onClick = onClick, + content = { Text(text = text, color = Color.White) }) + } +} diff --git a/app/src/main/java/com/android/sample/ui/components/BottomNavBar.kt b/app/src/main/java/com/android/sample/ui/components/BottomNavBar.kt new file mode 100644 index 00000000..36c9bf3f --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/components/BottomNavBar.kt @@ -0,0 +1,99 @@ +package com.android.sample.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.android.sample.ui.navigation.NavRoutes +import com.android.sample.ui.navigation.RouteStackManager + +object BottomNavBarTestTags { + const val BOTTOM_NAV_BAR = "bottomNavBar" + const val NAV_HOME = "navHome" + const val NAV_BOOKINGS = "navBookings" + const val NAV_SKILLS = "navMessages" + const val NAV_PROFILE = "navProfile" +} +/** + * BottomNavBar - Main navigation bar component for SkillBridge app + * + * A Material3 NavigationBar that provides tab-based navigation between main app sections. + * Integrates with RouteStackManager to maintain proper navigation state and back stack handling. + * + * Features: + * - Shows 4 main tabs: Home, Skills, Profile, Settings + * - Highlights currently selected tab based on navigation state + * - Resets route stack when switching tabs to prevent deep navigation issues + * - Preserves tab state with saveState/restoreState for smooth UX + * - Uses launchSingleTop to prevent duplicate destinations + * + * Usage: + * - Place in main activity/screen as persistent bottom navigation + * - Pass NavHostController from parent composable + * - Navigation routes must match those defined in NavRoutes object + * + * Adding a new tab: + * 1. Add new BottomNavItem to the items list with label, icon, and route + * 2. Ensure corresponding route exists in NavRoutes + * 3. Add route to RouteStackManager.mainRoutes if needed + * 4. Import appropriate Material icon + * + * Note: Tab switching automatically clears and resets the navigation stack + */ +@Composable +fun BottomNavBar(navController: NavHostController) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + val items = + listOf( + BottomNavItem("Home", Icons.Default.Home, NavRoutes.HOME), + BottomNavItem("Bookings", Icons.Default.DateRange, NavRoutes.BOOKINGS), + BottomNavItem("Skills", Icons.Default.Star, NavRoutes.SKILLS), + BottomNavItem("Profile", Icons.Default.Person, NavRoutes.PROFILE), + ) + + NavigationBar(modifier = Modifier.testTag(BottomNavBarTestTags.BOTTOM_NAV_BAR)) { + items.forEach { item -> + val itemModifier = + when (item.route) { + NavRoutes.HOME -> Modifier.testTag(BottomNavBarTestTags.NAV_HOME) + NavRoutes.BOOKINGS -> Modifier.testTag(BottomNavBarTestTags.NAV_BOOKINGS) + NavRoutes.PROFILE -> Modifier.testTag(BottomNavBarTestTags.NAV_PROFILE) + NavRoutes.SKILLS -> Modifier.testTag(BottomNavBarTestTags.NAV_SKILLS) + + // Add NAV_MESSAGES mapping here if needed + else -> Modifier + } + + NavigationBarItem( + modifier = itemModifier, + selected = currentRoute == item.route, + onClick = { + RouteStackManager.clear() + RouteStackManager.addRoute(item.route) + navController.navigate(item.route) { + popUpTo(NavRoutes.HOME) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label) }) + } + } +} + +data class BottomNavItem( + val label: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val route: String +) diff --git a/app/src/main/java/com/android/sample/ui/components/RatingStars.kt b/app/src/main/java/com/android/sample/ui/components/RatingStars.kt new file mode 100644 index 00000000..8372101a --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/components/RatingStars.kt @@ -0,0 +1,43 @@ +package com.android.sample.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import kotlin.math.roundToInt + +/** Test tags for the [RatingStars] composable. */ +object RatingStarsTestTags { + const val FILLED_STAR = "RatingStarsTestTags.FILLED_STAR" + const val OUTLINED_STAR = "RatingStarsTestTags.OUTLINED_STAR" +} + +/** + * A composable that displays a star rating out of 5. + * + * Filled stars represent the rating, while outlined stars represent the remaining out of 5. + * + * @param ratingOutOfFive The rating value between 0 and 5. + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun RatingStars(ratingOutOfFive: Double, modifier: Modifier = Modifier) { + // Coerce the rating to be within the range of 0 to 5 and round to the nearest integer + val filled = ratingOutOfFive.coerceIn(0.0, 5.0).roundToInt() + Row(modifier) { + repeat(5) { i -> + val isFilled = i < filled + Icon( + imageVector = if (i < filled) Icons.Filled.Star else Icons.Outlined.Star, + contentDescription = null, + modifier = + Modifier.testTag( + if (isFilled) RatingStarsTestTags.FILLED_STAR + else RatingStarsTestTags.OUTLINED_STAR)) + } + } +} diff --git a/app/src/main/java/com/android/sample/ui/components/SkillChip.kt b/app/src/main/java/com/android/sample/ui/components/SkillChip.kt new file mode 100644 index 00000000..e5cf38ea --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/components/SkillChip.kt @@ -0,0 +1,59 @@ +package com.android.sample.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.android.sample.model.skill.Skill +import com.android.sample.ui.theme.TealChip +import com.android.sample.ui.theme.White + +/** Test tags for the [SkillChip] composable. */ +object SkillChipTestTags { + const val CHIP = "SkillChipTestTags.CHIP" + const val TEXT = "SkillChipTestTags.TEXT" +} + +/** + * Formats the years of experience as a string, ensuring that whole numbers are displayed without + * decimal places. + */ +private fun yearsText(years: Double): String { + val y = if (years % 1.0 == 0.0) years.toInt().toString() else years.toString() + return "$y years" +} + +/** + * A chip that displays a skill with its name, years of experience, and expertise level. + * + * @param skill The skill to be displayed. + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun SkillChip(skill: Skill, modifier: Modifier = Modifier) { + val level = skill.expertise.name.lowercase() + val name = skill.skill.replace('_', ' ').lowercase().replaceFirstChar { it.uppercase() } + Surface( + color = TealChip, + shape = MaterialTheme.shapes.large, + modifier = modifier.padding(vertical = 4.dp).fillMaxWidth().testTag(SkillChipTestTags.CHIP)) { + Box( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.CenterStart) { + Text( + text = "$name: ${yearsText(skill.skillTime)}, $level", + color = White, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + modifier = Modifier.testTag(SkillChipTestTags.TEXT)) + } + } +} diff --git a/app/src/main/java/com/android/sample/ui/components/TopAppBar.kt b/app/src/main/java/com/android/sample/ui/components/TopAppBar.kt new file mode 100644 index 00000000..7967f746 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/components/TopAppBar.kt @@ -0,0 +1,118 @@ +package com.android.sample.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.android.sample.ui.navigation.NavRoutes +import com.android.sample.ui.navigation.RouteStackManager + +/** + * TopAppBar - Reusable top navigation bar component for SkillBridge app + * + * A Material3 TopAppBar that displays the current screen title and handles back navigation. + * Integrates with both NavController and RouteStackManager for intelligent navigation behavior. + * + * Features: + * - Dynamic title based on current route (Home, Skills, Profile, Settings, or default + * "SkillBridge") + * - Smart back button visibility (hidden on Home screen, shown when navigation is possible) + * - Dual navigation logic: main routes navigate to Home, secondary screens use custom stack + * - Preserves navigation state with launchSingleTop and restoreState + * + * Navigation Behavior: + * - Main routes (bottom nav): Back button goes to Home and resets stack + * - Secondary screens: Back button uses RouteStackManager's previous route + * - Fallback to NavController.navigateUp() if stack is empty + * + * Usage: + * - Place in main activity/screen layout above content area + * - Pass NavHostController from parent composable + * - Works automatically with routes defined in NavRoutes object + * + * Modifying titles: + * 1. Add new route case to the title when() expression + * 2. Ensure route constant exists in NavRoutes object + * 3. Update RouteStackManager.mainRoutes if it's a main route + * + * Note: Requires @OptIn(ExperimentalMaterial3Api::class) for TopAppBar usage + */ +object TopAppBarTestTags { + const val DISPLAY_TITLE = "title" + const val NAVIGATE_BACK = "navigateBack" + const val TOP_APP_BAR = "topAppBar" +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopAppBar(navController: NavController) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val currentRoute = currentDestination?.route + + val title = + when (currentRoute) { + NavRoutes.HOME -> "Home" + NavRoutes.PROFILE -> "Profile" + NavRoutes.SKILLS -> "skills" + NavRoutes.BOOKINGS -> "My Bookings" + else -> "SkillBridge" + } + + // show back arrow if we have either NavController's previous or our stack knows of a previous + // do not show it while on the home page + val canNavigateBack = + currentRoute != NavRoutes.HOME && + (navController.previousBackStackEntry != null || + RouteStackManager.getCurrentRoute() != null) + + TopAppBar( + modifier = Modifier.testTag(TopAppBarTestTags.TOP_APP_BAR), + title = { + Text( + text = title, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.testTag(TopAppBarTestTags.DISPLAY_TITLE)) + }, + navigationIcon = { + if (canNavigateBack) { + IconButton( + onClick = { + // If current route is one of the 4 main pages -> go to Home (resetting the stack) + if (RouteStackManager.isMainRoute(currentRoute)) { + // If already home -> just navigateUp (or exit) + if (currentRoute == NavRoutes.HOME) { + navController.navigateUp() + } else { + RouteStackManager.clear() + RouteStackManager.addRoute(NavRoutes.HOME) + navController.navigate(NavRoutes.HOME) { + // pop everything above home and go to home + popUpTo(NavRoutes.HOME) { inclusive = false } + launchSingleTop = true + restoreState = true + } + } + } else { + // Secondary page -> pop custom stack and navigate to previous route + val previous = RouteStackManager.popAndGetPrevious() + if (previous != null) { + navController.navigate(previous) { launchSingleTop = true } + } else { + // fallback + navController.navigateUp() + } + } + }, + modifier = Modifier.testTag(TopAppBarTestTags.NAVIGATE_BACK)) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + }) +} diff --git a/app/src/main/java/com/android/sample/ui/components/TutorCard.kt b/app/src/main/java/com/android/sample/ui/components/TutorCard.kt new file mode 100644 index 00000000..525bf860 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/components/TutorCard.kt @@ -0,0 +1,121 @@ +package com.android.sample.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.android.sample.model.rating.RatingInfo +import com.android.sample.model.user.Profile +import com.android.sample.ui.theme.TealChip +import com.android.sample.ui.theme.White + +/** Test tags for the tutor card and its elements. */ +object TutorCardTestTags { + const val CARD = "TutorCardTestTags.CARD" + const val ACTION_BUTTON = "TutorCardTestTags.ACTION_BUTTON" +} + +/** + * Reusable tutor card. + * + * @param profile Tutor data + * @param pricePerHour e.g. "$25/hr" (null -> show placeholder "—/hr") + * @param secondaryText Optional subtitle (null -> uses profile.description or "Lessons") + * @param buttonLabel Primary action button text ("Book" by default) + * @param onPrimaryAction Callback when the button is pressed + * @param modifier External modifier + * @param cardTestTag Optional testTag for the card + * @param buttonTestTag Optional testTag for the button + */ +@Composable +fun TutorCard( + modifier: Modifier = Modifier, + profile: Profile, + pricePerHour: String? = null, + secondaryText: String? = null, + buttonLabel: String = "Book", + onPrimaryAction: (Profile) -> Unit, + cardTestTag: String? = null, + buttonTestTag: String? = null, +) { + ElevatedCard( + shape = MaterialTheme.shapes.large, + colors = CardDefaults.elevatedCardColors(containerColor = White), + modifier = modifier.testTag(cardTestTag ?: TutorCardTestTags.CARD)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(16.dp)) { + // Avatar placeholder (replace later with Image) + Box( + modifier = + Modifier.size(44.dp) + .clip(MaterialTheme.shapes.extraLarge) + .background(MaterialTheme.colorScheme.surfaceVariant)) + + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = profile.name?.ifBlank { "Tutor" } ?: "", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + + val subtitle = secondaryText ?: profile.description.ifBlank { "Lessons" } + + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis) + + Spacer(Modifier.height(4.dp)) + RatingRow(rating = profile.tutorRating) + } + + Spacer(Modifier.width(8.dp)) + + Column(horizontalAlignment = Alignment.End) { + Text(text = pricePerHour ?: "—/hr", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(6.dp)) + Button( + onClick = { onPrimaryAction(profile) }, + colors = + ButtonDefaults.buttonColors( + containerColor = TealChip, + contentColor = White, + disabledContainerColor = TealChip.copy(alpha = 0.38f), + disabledContentColor = White.copy(alpha = 0.38f)), + shape = MaterialTheme.shapes.extraLarge, + modifier = Modifier.testTag(buttonTestTag ?: TutorCardTestTags.ACTION_BUTTON)) { + Text(buttonLabel) + } + } + } + } +} + +/** + * Row showing star rating and total number of ratings. + * + * @param rating The rating info. + */ +@Composable +private fun RatingRow(rating: RatingInfo) { + Row(verticalAlignment = Alignment.CenterVertically) { + RatingStars(ratingOutOfFive = rating.averageRating) + Spacer(Modifier.width(6.dp)) + Text( + "(${rating.totalRatings})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} diff --git a/app/src/main/java/com/android/sample/ui/login/LoginScreen.kt b/app/src/main/java/com/android/sample/ui/login/LoginScreen.kt new file mode 100644 index 00000000..c9466fa5 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/login/LoginScreen.kt @@ -0,0 +1,384 @@ +package com.android.sample.ui.login + +import android.R +import androidx.activity.ComponentActivity +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.sample.model.authentication.* +import com.android.sample.ui.theme.extendedColors + +object SignInScreenTestTags { + const val TITLE = "title" + const val ROLE_LEARNER = "roleLearner" + const val EMAIL_INPUT = "emailInput" + const val PASSWORD_INPUT = "passwordInput" + const val SIGN_IN_BUTTON = "signInButton" + const val AUTH_GOOGLE = "authGoogle" + const val SIGNUP_LINK = "signUpLink" + const val AUTH_GITHUB = "authGitHub" + const val FORGOT_PASSWORD = "forgotPassword" + const val AUTH_SECTION = "authSection" + const val ROLE_TUTOR = "roleTutor" + const val SUBTITLE = "subtitle" +} + +@Composable +fun LoginScreen( + viewModel: AuthenticationViewModel = AuthenticationViewModel(LocalContext.current), + onGoogleSignIn: () -> Unit = {}, + onGitHubSignIn: () -> Unit = {}, + onNavigateToSignUp: () -> Unit = {} // Add this parameter +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val authResult by viewModel.authResult.collectAsStateWithLifecycle() + + // Handle authentication results + LaunchedEffect(authResult) { + when (authResult) { + is AuthResult.Success -> viewModel.showSuccessMessage(true) + is AuthResult.Error -> { + /* Error is handled in uiState */ + } + null -> { + /* No action needed */ + } + } + } + + Column( + modifier = Modifier.fillMaxSize().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + if (uiState.showSuccessMessage) { + SuccessCard( + authResult = authResult, + onSignOut = { + viewModel.showSuccessMessage(false) + viewModel.signOut() + }) + } else { + LoginForm( + uiState = uiState, + viewModel = viewModel, + onGoogleSignIn = onGoogleSignIn, + onGitHubSignIn = onGitHubSignIn, + onNavigateToSignUp) + } + } +} + +@Composable +private fun SuccessCard(authResult: AuthResult?, onSignOut: () -> Unit) { + val extendedColors = MaterialTheme.extendedColors + + Card( + modifier = Modifier.fillMaxWidth().padding(16.dp), + colors = CardDefaults.cardColors(containerColor = extendedColors.successGreen)) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Authentication Successful!", + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + "Welcome ${authResult?.let { (it as? AuthResult.Success)?.user?.displayName ?: "User" }}", + color = Color.White, + fontSize = 14.sp) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onSignOut, + colors = ButtonDefaults.buttonColors(containerColor = Color.White)) { + Text("Sign Out", color = extendedColors.successGreen) + } + } + } +} + +@Composable +private fun LoginForm( + uiState: AuthenticationUiState, + viewModel: AuthenticationViewModel, + onGoogleSignIn: () -> Unit, + onGitHubSignIn: () -> Unit = {}, + onNavigateToSignUp: () -> Unit = {} +) { + LoginHeader() + Spacer(modifier = Modifier.height(20.dp)) + + RoleSelectionButtons( + selectedRole = uiState.selectedRole, onRoleSelected = viewModel::updateSelectedRole) + Spacer(modifier = Modifier.height(30.dp)) + + EmailPasswordFields( + email = uiState.email, + password = uiState.password, + onEmailChange = viewModel::updateEmail, + onPasswordChange = viewModel::updatePassword) + + ErrorAndMessageDisplay(error = uiState.error, message = uiState.message) + + ForgotPasswordLink() + Spacer(modifier = Modifier.height(30.dp)) + + SignInButton( + isLoading = uiState.isLoading, + isEnabled = uiState.isSignInButtonEnabled, + onClick = viewModel::signIn) + Spacer(modifier = Modifier.height(20.dp)) + + AlternativeAuthSection( + isLoading = uiState.isLoading, + onGoogleSignIn = onGoogleSignIn, + onGitHubSignIn = onGitHubSignIn) + Spacer(modifier = Modifier.height(20.dp)) + + SignUpLink(onNavigateToSignUp = onNavigateToSignUp) +} + +@Composable +private fun LoginHeader() { + val extendedColors = MaterialTheme.extendedColors + + Text( + text = "SkillBridge", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = extendedColors.loginTitleBlue, + modifier = Modifier.testTag(SignInScreenTestTags.TITLE)) + Spacer(modifier = Modifier.height(10.dp)) + Text("Welcome back! Please sign in.", modifier = Modifier.testTag(SignInScreenTestTags.SUBTITLE)) +} + +@Composable +private fun RoleSelectionButtons(selectedRole: UserRole, onRoleSelected: (UserRole) -> Unit) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + RoleButton( + text = "I'm a Learner", + role = UserRole.LEARNER, + isSelected = selectedRole == UserRole.LEARNER, + onRoleSelected = onRoleSelected, + testTag = SignInScreenTestTags.ROLE_LEARNER) + RoleButton( + text = "I'm a Tutor", + role = UserRole.TUTOR, + isSelected = selectedRole == UserRole.TUTOR, + onRoleSelected = onRoleSelected, + testTag = SignInScreenTestTags.ROLE_TUTOR) + } +} + +@Composable +private fun RoleButton( + text: String, + role: UserRole, + isSelected: Boolean, + onRoleSelected: (UserRole) -> Unit, + testTag: String +) { + val extendedColors = MaterialTheme.extendedColors + + Button( + onClick = { onRoleSelected(role) }, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (isSelected) MaterialTheme.colorScheme.primary + else extendedColors.unselectedGray), + shape = RoundedCornerShape(10.dp), + modifier = Modifier.testTag(testTag)) { + Text(text) + } +} + +@Composable +private fun EmailPasswordFields( + email: String, + password: String, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit +) { + OutlinedTextField( + value = email, + onValueChange = onEmailChange, + label = { Text("Email") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + leadingIcon = { + Icon(painterResource(id = R.drawable.ic_dialog_email), contentDescription = null) + }, + modifier = Modifier.fillMaxWidth().testTag(SignInScreenTestTags.EMAIL_INPUT)) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + leadingIcon = { + Icon(painterResource(id = R.drawable.ic_lock_idle_lock), contentDescription = null) + }, + modifier = Modifier.fillMaxWidth().testTag(SignInScreenTestTags.PASSWORD_INPUT)) +} + +@Composable +private fun ErrorAndMessageDisplay(error: String?, message: String?) { + val extendedColors = MaterialTheme.extendedColors + + error?.let { errorMessage -> + Spacer(modifier = Modifier.height(10.dp)) + Text(text = errorMessage, color = MaterialTheme.colorScheme.error, fontSize = 14.sp) + } + + message?.let { msg -> + Spacer(modifier = Modifier.height(10.dp)) + Text(text = msg, color = extendedColors.messageGreen, fontSize = 14.sp) + } +} + +@Composable +private fun ForgotPasswordLink() { + val extendedColors = MaterialTheme.extendedColors + + Spacer(modifier = Modifier.height(10.dp)) + Text( + "Forgot password?", + modifier = + Modifier.fillMaxWidth() + .wrapContentWidth(Alignment.End) + .clickable { /* TODO: Implement when needed */} + .testTag(SignInScreenTestTags.FORGOT_PASSWORD), + fontSize = 14.sp, + color = extendedColors.forgotPasswordGray) +} + +@Composable +private fun SignInButton(isLoading: Boolean, isEnabled: Boolean, onClick: () -> Unit) { + val extendedColors = MaterialTheme.extendedColors + + Button( + onClick = onClick, + enabled = isEnabled, + modifier = Modifier.fillMaxWidth().height(50.dp).testTag(SignInScreenTestTags.SIGN_IN_BUTTON), + colors = ButtonDefaults.buttonColors(containerColor = extendedColors.signInButtonTeal), + shape = RoundedCornerShape(12.dp)) { + if (isLoading) { + CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp)) + } else { + Text("Sign In", fontSize = 18.sp) + } + } +} + +@Composable +private fun AlternativeAuthSection( + isLoading: Boolean, + onGoogleSignIn: () -> Unit, + onGitHubSignIn: () -> Unit = {} +) { + Text("or continue with", modifier = Modifier.testTag(SignInScreenTestTags.AUTH_SECTION)) + Spacer(modifier = Modifier.height(15.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(15.dp)) { + AuthProviderButton( + text = "Google", + enabled = !isLoading, + onClick = onGoogleSignIn, + testTag = SignInScreenTestTags.AUTH_GOOGLE) + AuthProviderButton( + text = "GitHub", + enabled = !isLoading, + onClick = onGitHubSignIn, // This line is correct + testTag = SignInScreenTestTags.AUTH_GITHUB) + } +} + +@Composable +private fun RowScope.AuthProviderButton( + text: String, + enabled: Boolean, + onClick: () -> Unit, + testTag: String +) { + val extendedColors = MaterialTheme.extendedColors + + Button( + onClick = onClick, + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = Color.White), + shape = RoundedCornerShape(12.dp), + modifier = + Modifier.weight(1f) + .border( + width = 2.dp, + color = extendedColors.authButtonBorderGray, + shape = RoundedCornerShape(12.dp)) + .testTag(testTag)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center) { + Text(text, color = extendedColors.authProviderTextBlack) + } + } +} + +@Composable +private fun SignUpLink(onNavigateToSignUp: () -> Unit = {}) { + val extendedColors = MaterialTheme.extendedColors + + Row { + Text("Don't have an account? ") + Text( + "Sign Up", + color = extendedColors.signUpLinkBlue, + fontWeight = FontWeight.Bold, + modifier = + Modifier.clickable { onNavigateToSignUp() }.testTag(SignInScreenTestTags.SIGNUP_LINK)) + } +} + +// Legacy composable for backward compatibility and proper ViewModel creation +@Preview +@Composable +fun LoginScreenPreview() { + val context = LocalContext.current + val activity = context as? ComponentActivity + val viewModel: AuthenticationViewModel = remember { AuthenticationViewModel(context) } + + // Google Sign-In helper setup + val googleSignInHelper = + remember(activity) { + activity?.let { act -> + GoogleSignInHelper(act) { result -> viewModel.handleGoogleSignInResult(result) } + } + } + + LoginScreen( + viewModel = viewModel, + onGoogleSignIn = { + googleSignInHelper?.signInWithGoogle() + ?: run { viewModel.setError("Google Sign-In requires Activity context") } + }) +} diff --git a/app/src/main/java/com/android/sample/ui/navigation/NavGraph.kt b/app/src/main/java/com/android/sample/ui/navigation/NavGraph.kt new file mode 100644 index 00000000..c47f9b30 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/navigation/NavGraph.kt @@ -0,0 +1,125 @@ +package com.android.sample.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.android.sample.HomeScreen +import com.android.sample.MainPageViewModel +import com.android.sample.model.authentication.AuthenticationViewModel +import com.android.sample.ui.bookings.MyBookingsScreen +import com.android.sample.ui.bookings.MyBookingsViewModel +import com.android.sample.ui.login.LoginScreen +import com.android.sample.ui.profile.MyProfileScreen +import com.android.sample.ui.profile.MyProfileViewModel +import com.android.sample.ui.screens.newSkill.NewSkillScreen +import com.android.sample.ui.signup.SignUpScreen +import com.android.sample.ui.signup.SignUpViewModel +import com.android.sample.ui.subject.SubjectListScreen +import com.android.sample.ui.subject.SubjectListViewModel + +/** + * AppNavGraph - Main navigation configuration for the SkillBridge app + * + * This file defines all navigation routes and their corresponding screen composables. Each route is + * registered with the NavHost and includes route tracking via RouteStackManager. + * + * Usage: + * - Call AppNavGraph(navController) from your main activity/composable + * - Navigation is handled through the provided NavHostController + * + * Adding a new screen: + * 1. Add route constant to NavRoutes object + * 2. Import the new screen composable + * 3. Add composable() block with LaunchedEffect for route tracking + * 4. Pass navController parameter if screen needs navigation + * + * Removing a screen: + * 1. Delete the composable() block + * 2. Remove unused import + * 3. Remove route constant from NavRoutes (if no longer needed) + * + * Note: All screens automatically register with RouteStackManager for back navigation tracking + */ +@Composable +fun AppNavGraph( + navController: NavHostController, + bookingsViewModel: MyBookingsViewModel, + profileViewModel: MyProfileViewModel, + mainPageViewModel: MainPageViewModel, + authViewModel: AuthenticationViewModel, + onGoogleSignIn: () -> Unit +) { + NavHost(navController = navController, startDestination = NavRoutes.LOGIN) { + composable(NavRoutes.LOGIN) { + LaunchedEffect(Unit) { RouteStackManager.addRoute(NavRoutes.LOGIN) } + LoginScreen( + viewModel = authViewModel, + onGoogleSignIn = onGoogleSignIn, + onGitHubSignIn = { // Temporary functionality to go to home page while GitHub auth isn't + // implemented + navController.navigate(NavRoutes.HOME) { popUpTo(NavRoutes.LOGIN) { inclusive = true } } + }, + onNavigateToSignUp = { // Add this navigation callback + navController.navigate(NavRoutes.SIGNUP) + }) + } + + composable(NavRoutes.PROFILE) { + LaunchedEffect(Unit) { RouteStackManager.addRoute(NavRoutes.PROFILE) } + MyProfileScreen( + profileViewModel = profileViewModel, + profileId = "test" // Using the same hardcoded user ID from MainActivity for the demo + ) + } + + composable(NavRoutes.HOME) { + LaunchedEffect(Unit) { RouteStackManager.addRoute(NavRoutes.HOME) } + HomeScreen( + mainPageViewModel = mainPageViewModel, + onNavigateToNewSkill = { profileId -> + navController.navigate(NavRoutes.createNewSkillRoute(profileId)) + }) + } + + composable(NavRoutes.SKILLS) { + LaunchedEffect(Unit) { RouteStackManager.addRoute(NavRoutes.SKILLS) } + SubjectListScreen( + viewModel = + SubjectListViewModel(), // You may need to provide this through dependency injection + onBookTutor = { profile -> + // Navigate to booking or profile screen when tutor is booked + // Example: navController.navigate("booking/${profile.uid}") + }) + } + + composable(NavRoutes.BOOKINGS) { + LaunchedEffect(Unit) { RouteStackManager.addRoute(NavRoutes.BOOKINGS) } + MyBookingsScreen(viewModel = bookingsViewModel, navController = navController) + } + + composable( + route = NavRoutes.NEW_SKILL, + arguments = listOf(navArgument("profileId") { type = NavType.StringType })) { backStackEntry + -> + val profileId = backStackEntry.arguments?.getString("profileId") ?: "" + LaunchedEffect(Unit) { RouteStackManager.addRoute(NavRoutes.NEW_SKILL) } + NewSkillScreen(profileId = profileId) + } + + composable(NavRoutes.SIGNUP) { + LaunchedEffect(Unit) { RouteStackManager.addRoute(NavRoutes.SIGNUP) } + SignUpScreen( + vm = SignUpViewModel(), + onSubmitSuccess = { + // Navigate to home or login after successful signup + navController.navigate(NavRoutes.HOME) { + popUpTo(NavRoutes.SIGNUP) { inclusive = true } + } + }) + } + } +} diff --git a/app/src/main/java/com/android/sample/ui/navigation/NavRoutes.kt b/app/src/main/java/com/android/sample/ui/navigation/NavRoutes.kt new file mode 100644 index 00000000..28c83995 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/navigation/NavRoutes.kt @@ -0,0 +1,37 @@ +package com.android.sample.ui.navigation + +/** + * Defines the navigation routes for the application. + * + * This object centralizes all route constants, providing a single source of truth for navigation. + * This makes the navigation system easier to maintain, as all route strings are in one place. + * + * ## How to use + * + * ### Adding a new screen: + * 1. Add a new `const val` for the screen's route (e.g., `const val NEW_SCREEN = "new_screen"`). + * 2. Add the new route to the `NavGraph.kt` file with its corresponding composable. + * 3. If the screen should be in the bottom navigation bar, add it to the items list in + * `BottomNavBar.kt`. + * + * ### Removing a screen: + * 1. Remove the `const val` for the screen's route. + * 2. Remove the route and its composable from `NavGraph.kt`. + * 3. If it was in the bottom navigation bar, remove it from the items list in `BottomNavBar.kt`. + */ +object NavRoutes { + const val LOGIN = "login" + const val HOME = "home" + const val PROFILE = "profile/{profileId}" + const val SKILLS = "skills" + const val BOOKINGS = "bookings" + + // Secondary pages + const val NEW_SKILL = "new_skill/{profileId}" + const val MESSAGES = "messages" + const val SIGNUP = "signup" + + fun createProfileRoute(profileId: String) = "profile/$profileId" + + fun createNewSkillRoute(profileId: String) = "new_skill/$profileId" +} diff --git a/app/src/main/java/com/android/sample/ui/navigation/RouteStackManager.kt b/app/src/main/java/com/android/sample/ui/navigation/RouteStackManager.kt new file mode 100644 index 00000000..9f38086a --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/navigation/RouteStackManager.kt @@ -0,0 +1,63 @@ +package com.android.sample.ui.navigation + +/** + * RouteStackManager - Custom navigation stack manager for SkillBridge app + * + * A singleton that maintains a manual navigation stack to provide predictable back navigation + * between screens, especially for parameterized routes and complex navigation flows. + * + * Key Features: + * - Tracks navigation history with a maximum stack size of 20 + * - Prevents duplicate consecutive routes + * - Distinguishes between main routes (bottom nav) and other screens + * - Provides stack manipulation methods for custom back navigation + * + * Usage: + * - Call addRoute() when navigating to a new screen + * - Call popAndGetPrevious() to get the previous route for back navigation + * - Use isMainRoute() to check if a route is a main bottom navigation route + * + * Integration: + * - Used in AppNavGraph to track all route changes via LaunchedEffect + * - Main routes are automatically defined (HOME, SKILLS, PROFILE, BOOKINGS) + * - Works alongside NavHostController for enhanced navigation control + * + * Modifying main routes: + * - Update the mainRoutes set to add/remove bottom navigation routes + * - Ensure route constants match those defined in NavRoutes object + */ +object RouteStackManager { + private const val MAX_STACK_SIZE = 20 + private val stack = ArrayDeque() + + // Set of the app's main routes (bottom nav) + private val mainRoutes = + setOf(NavRoutes.HOME, NavRoutes.SKILLS, NavRoutes.PROFILE, NavRoutes.BOOKINGS) + + fun addRoute(route: String) { + // prevent consecutive duplicates + if (stack.lastOrNull() == route) return + + if (stack.size >= MAX_STACK_SIZE) { + stack.removeFirst() + } + stack.addLast(route) + } + + /** Pops the current route and returns the new current route (previous). */ + fun popAndGetPrevious(): String? { + if (stack.isNotEmpty()) stack.removeLast() + return stack.lastOrNull() + } + + /** Remove and return the popped route (legacy if you still want it) */ + fun popRoute(): String? = if (stack.isNotEmpty()) stack.removeLast() else null + + fun getCurrentRoute(): String? = stack.lastOrNull() + + fun clear() = stack.clear() + + fun getAllRoutes(): List = stack.toList() + + fun isMainRoute(route: String?): Boolean = route != null && mainRoutes.contains(route) +} diff --git a/app/src/main/java/com/android/sample/ui/newSkill/NewSkillScreen.kt b/app/src/main/java/com/android/sample/ui/newSkill/NewSkillScreen.kt new file mode 100644 index 00000000..7249e46d --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/newSkill/NewSkillScreen.kt @@ -0,0 +1,216 @@ +package com.android.sample.ui.screens.newSkill + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FabPosition +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.sample.model.skill.MainSubject +import com.android.sample.ui.components.AppButton + +object NewSkillScreenTestTag { + const val BUTTON_SAVE_SKILL = "buttonSaveSkill" + const val CREATE_LESSONS_TITLE = "createLessonsTitle" + const val INPUT_COURSE_TITLE = "inputCourseTitle" + const val INVALID_TITLE_MSG = "invalidTitleMsg" + const val INPUT_DESCRIPTION = "inputDescription" + const val INVALID_DESC_MSG = "invalidDescMsg" + const val INPUT_PRICE = "inputPrice" + const val INVALID_PRICE_MSG = "invalidPriceMsg" + const val SUBJECT_FIELD = "subjectField" + const val SUBJECT_DROPDOWN = "subjectDropdown" + const val SUBJECT_DROPDOWN_ITEM_PREFIX = "subjectItem" + const val INVALID_SUBJECT_MSG = "invalidSubjectMsg" +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewSkillScreen(skillViewModel: NewSkillViewModel = NewSkillViewModel(), profileId: String) { + + Scaffold( + floatingActionButton = { + AppButton( + text = "Save New Skill", + onClick = { skillViewModel.addProfile(userId = profileId) }, + testTag = NewSkillScreenTestTag.BUTTON_SAVE_SKILL) + }, + floatingActionButtonPosition = FabPosition.Center, + content = { pd -> SkillsContent(pd, profileId, skillViewModel) }) +} + +@Composable +fun SkillsContent(pd: PaddingValues, profileId: String, skillViewModel: NewSkillViewModel) { + + val textSpace = 8.dp + + LaunchedEffect(profileId) { skillViewModel.load() } + val skillUIState by skillViewModel.uiState.collectAsState() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(pd)) { + Spacer(modifier = Modifier.height(20.dp)) + + Box( + modifier = + Modifier.align(Alignment.CenterHorizontally) + .fillMaxWidth(0.9f) + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium) + .border( + width = 1.dp, + brush = Brush.linearGradient(colors = listOf(Color.Gray, Color.LightGray)), + shape = MaterialTheme.shapes.medium) + .padding(16.dp)) { + Column { + Text( + text = "Create Your Lessons !", + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag(NewSkillScreenTestTag.CREATE_LESSONS_TITLE)) + + Spacer(modifier = Modifier.height(10.dp)) + + // Title Input + OutlinedTextField( + value = skillUIState.title, + onValueChange = { skillViewModel.setTitle(it) }, + label = { Text("Course Title") }, + placeholder = { Text("Title") }, + isError = skillUIState.invalidTitleMsg != null, + supportingText = { + skillUIState.invalidTitleMsg?.let { + Text( + text = it, + modifier = Modifier.testTag(NewSkillScreenTestTag.INVALID_TITLE_MSG)) + } + }, + modifier = + Modifier.fillMaxWidth().testTag(NewSkillScreenTestTag.INPUT_COURSE_TITLE)) + + Spacer(modifier = Modifier.height(textSpace)) + + // Desc Input + OutlinedTextField( + value = skillUIState.description, + onValueChange = { skillViewModel.setDescription(it) }, + label = { Text("Description") }, + placeholder = { Text("Description of the skill") }, + isError = skillUIState.invalidDescMsg != null, + supportingText = { + skillUIState.invalidDescMsg?.let { + Text( + text = it, + modifier = Modifier.testTag(NewSkillScreenTestTag.INVALID_DESC_MSG)) + } + }, + modifier = + Modifier.fillMaxWidth().testTag(NewSkillScreenTestTag.INPUT_DESCRIPTION)) + + Spacer(modifier = Modifier.height(textSpace)) + + // Price Input + OutlinedTextField( + value = skillUIState.price, + onValueChange = { skillViewModel.setPrice(it) }, + label = { Text("Hourly Rate") }, + placeholder = { Text("Price per Hour") }, + isError = skillUIState.invalidPriceMsg != null, + supportingText = { + skillUIState.invalidPriceMsg?.let { + Text( + text = it, + modifier = Modifier.testTag(NewSkillScreenTestTag.INVALID_PRICE_MSG)) + } + }, + modifier = Modifier.fillMaxWidth().testTag(NewSkillScreenTestTag.INPUT_PRICE)) + + Spacer(modifier = Modifier.height(textSpace)) + + SubjectMenu( + selectedSubject = skillUIState.subject, + skillViewModel = skillViewModel, + skillUIState = skillUIState) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubjectMenu( + selectedSubject: MainSubject?, + skillViewModel: NewSkillViewModel, + skillUIState: SkillUIState +) { + var expanded by remember { mutableStateOf(false) } + val subjects = MainSubject.entries.toTypedArray() + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = selectedSubject?.name ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("Subject") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + isError = skillUIState.invalidSubjectMsg != null, + supportingText = { + skillUIState.invalidSubjectMsg?.let { + Text( + text = it, + modifier = Modifier.testTag(NewSkillScreenTestTag.INVALID_SUBJECT_MSG)) + } + }, + modifier = + Modifier.menuAnchor().fillMaxWidth().testTag(NewSkillScreenTestTag.SUBJECT_FIELD)) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.testTag(NewSkillScreenTestTag.SUBJECT_DROPDOWN)) { + subjects.forEach { subject -> + DropdownMenuItem( + text = { Text(subject.name) }, + onClick = { + skillViewModel.setSubject(subject) + expanded = false + }, + modifier = Modifier.testTag(NewSkillScreenTestTag.SUBJECT_DROPDOWN_ITEM_PREFIX)) + } + } + } +} + +@Preview(showBackground = true, widthDp = 320) +@Composable +fun NewSkillPreview() { + NewSkillScreen(profileId = "") +} diff --git a/app/src/main/java/com/android/sample/ui/newSkill/NewSkillViewModel.kt b/app/src/main/java/com/android/sample/ui/newSkill/NewSkillViewModel.kt new file mode 100644 index 00000000..c12b1f5b --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/newSkill/NewSkillViewModel.kt @@ -0,0 +1,173 @@ +package com.android.sample.ui.screens.newSkill + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.sample.model.listing.ListingRepository +import com.android.sample.model.listing.ListingRepositoryProvider +import com.android.sample.model.listing.Proposal +import com.android.sample.model.skill.MainSubject +import com.android.sample.model.skill.Skill +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * UI state for the New Skill screen. + * + * Holds all data required to render and validate the new skill form: + * - ownerId: identifier of the skill owner + * - title, description, price: input fields + * - subject: selected main subject + * - errorMsg: global error (e.g. network) + * - invalid*Msg: per-field validation messages + */ +data class SkillUIState( + val title: String = "", + val description: String = "", + val price: String = "", + val subject: MainSubject? = null, + val invalidTitleMsg: String? = null, + val invalidDescMsg: String? = null, + val invalidPriceMsg: String? = null, + val invalidSubjectMsg: String? = null, +) { + + /** Indicates whether the current UI state is valid for submission. */ + val isValid: Boolean + get() = + invalidTitleMsg == null && + invalidDescMsg == null && + invalidPriceMsg == null && + invalidSubjectMsg == null && + title.isNotBlank() && + description.isNotBlank() && + price.isNotBlank() && + subject != null +} + +/** + * ViewModel responsible for the NewSkillScreen UI logic. + * + * Exposes a StateFlow of [SkillUIState] and provides functions to update the state and perform + * simple validation. + */ +class NewSkillViewModel( + private val listingRepository: ListingRepository = ListingRepositoryProvider.repository +) : ViewModel() { + // Internal mutable UI state + private val _uiState = MutableStateFlow(SkillUIState()) + // Public read-only state flow for the UI to observe + val uiState: StateFlow = _uiState.asStateFlow() + + private val titleMsgError = "Title cannot be empty" + private val descMsgError = "Description cannot be empty" + private val priceEmptyMsg = "Price cannot be empty" + private val priceInvalidMsg = "Price must be a positive number" + private val subjectMsgError = "You must choose a subject" + + /** + * Placeholder to load an existing skill. + * + * Kept as a coroutine scope for future asynchronous loading. + */ + fun load() {} + + fun addProfile(userId: String) { + val state = _uiState.value + if (state.isValid) { + val newSkill = + Skill( + mainSubject = state.subject!!, + skill = state.title, + ) + + val newProposal = + Proposal( + listingId = listingRepository.getNewUid(), + creatorUserId = userId, + skill = newSkill, + description = state.description) + + addSkillToRepository(proposal = newProposal) + } else { + setError() + } + } + + private fun addSkillToRepository(proposal: Proposal) { + viewModelScope.launch { + try { + listingRepository.addProposal(proposal) + } catch (e: Exception) { + Log.e("NewSkillViewModel", "Error adding NewSkill", e) + } + } + } + + // Set all messages error, if invalid field + fun setError() { + _uiState.update { currentState -> + currentState.copy( + invalidTitleMsg = if (currentState.title.isBlank()) titleMsgError else null, + invalidDescMsg = if (currentState.description.isBlank()) descMsgError else null, + invalidPriceMsg = + if (currentState.price.isBlank()) priceEmptyMsg + else if (!isPosNumber(currentState.price)) priceInvalidMsg else null, + invalidSubjectMsg = if (currentState.subject == null) subjectMsgError else null) + } + } + + // --- State update helpers used by the UI --- + + /** Update the title and validate presence. If the title is blank, sets `invalidTitleMsg`. */ + fun setTitle(title: String) { + _uiState.value = + _uiState.value.copy( + title = title, invalidTitleMsg = if (title.isBlank()) titleMsgError else null) + } + + /** + * Update the description and validate presence. If the description is blank, sets + * `invalidDescMsg`. + */ + fun setDescription(description: String) { + _uiState.value = + _uiState.value.copy( + description = description, + invalidDescMsg = if (description.isBlank()) descMsgError else null) + } + + /** + * Update the price and validate format. + * + * Rules: + * - empty -> "Price cannot be empty" + * - non positive number or non-numeric -> "Price must be a positive number or null (0.0)" + */ + fun setPrice(price: String) { + _uiState.value = + _uiState.value.copy( + price = price, + invalidPriceMsg = + if (price.isBlank()) priceEmptyMsg + else if (!isPosNumber(price)) priceInvalidMsg else null) + } + + /** Update the selected main subject. */ + fun setSubject(sub: MainSubject) { + _uiState.value = _uiState.value.copy(subject = sub) + } + + /** Returns true if the given string represents a non-negative number. */ + private fun isPosNumber(num: String): Boolean { + return try { + val res = num.toDouble() + !res.isNaN() && (res >= 0.0) + } catch (_: Exception) { + false + } + } +} diff --git a/app/src/main/java/com/android/sample/ui/profile/MyProfileScreen.kt b/app/src/main/java/com/android/sample/ui/profile/MyProfileScreen.kt new file mode 100644 index 00000000..e00dff0f --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/profile/MyProfileScreen.kt @@ -0,0 +1,219 @@ +package com.android.sample.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.sample.ui.components.AppButton + +object MyProfileScreenTestTag { + const val PROFILE_ICON = "profileIcon" + const val NAME_DISPLAY = "nameDisplay" + const val ROLE_BADGE = "roleBadge" + const val CARD_TITLE = "cardTitle" + const val INPUT_PROFILE_NAME = "inputProfileName" + const val INPUT_PROFILE_EMAIL = "inputProfileEmail" + const val INPUT_PROFILE_LOCATION = "inputProfileLocation" + const val INPUT_PROFILE_DESC = "inputProfileDesc" + const val SAVE_BUTTON = "saveButton" + const val ERROR_MSG = "errorMsg" +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MyProfileScreen( + profileViewModel: MyProfileViewModel = viewModel(), + profileId: String, +) { + // Scaffold structures the screen with top bar, bottom bar, and save button + Scaffold( + topBar = {}, + bottomBar = {}, + floatingActionButton = { + // Button to save profile changes + AppButton( + text = "Save Profile Changes", + onClick = {}, + testTag = MyProfileScreenTestTag.SAVE_BUTTON) + }, + floatingActionButtonPosition = FabPosition.Center, + content = { pd -> + // Profile content + ProfileContent(pd, profileId, profileViewModel) + }) +} + +@Composable +private fun ProfileContent( + pd: PaddingValues, + profileId: String, + profileViewModel: MyProfileViewModel +) { + + LaunchedEffect(profileId) { profileViewModel.loadProfile(profileId) } + + // Observe profile state to update the UI + val profileUIState by profileViewModel.uiState.collectAsState() + + val fieldSpacing = 8.dp + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(pd)) { + // Profile icon (first letter of name) + Box( + modifier = + Modifier.size(50.dp) + .clip(CircleShape) + .background(Color.White) + .border(2.dp, Color.Blue, CircleShape) + .testTag(MyProfileScreenTestTag.PROFILE_ICON), + contentAlignment = Alignment.Center) { + Text( + text = profileUIState.name?.firstOrNull()?.uppercase() ?: "", + style = MaterialTheme.typography.titleLarge, + color = Color.Black, + fontWeight = FontWeight.Bold) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Display name + Text( + text = profileUIState.name ?: "Your Name", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.testTag(MyProfileScreenTestTag.NAME_DISPLAY)) + // Display role + Text( + text = "Student", + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + modifier = Modifier.testTag(MyProfileScreenTestTag.ROLE_BADGE)) + + // Form fields container + Box( + modifier = + Modifier.widthIn(max = 300.dp) + .align(Alignment.CenterHorizontally) + .padding(pd) + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.medium) + .border( + width = 1.dp, + brush = Brush.linearGradient(colors = listOf(Color.Gray, Color.LightGray)), + shape = MaterialTheme.shapes.medium) + .padding(16.dp)) { + Column { + // Section title + Text( + text = "Personal Details", + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag(MyProfileScreenTestTag.CARD_TITLE)) + + Spacer(modifier = Modifier.height(10.dp)) + + // Name input field + OutlinedTextField( + value = profileUIState.name ?: "", + onValueChange = { profileViewModel.setName(it) }, + label = { Text("Name") }, + placeholder = { Text("Enter Your Full Name") }, + isError = profileUIState.invalidNameMsg != null, + supportingText = { + profileUIState.invalidNameMsg?.let { + Text( + text = it, + modifier = Modifier.testTag(MyProfileScreenTestTag.ERROR_MSG)) + } + }, + modifier = + Modifier.fillMaxWidth().testTag(MyProfileScreenTestTag.INPUT_PROFILE_NAME)) + + Spacer(modifier = Modifier.height(fieldSpacing)) + + // Email input field + OutlinedTextField( + value = profileUIState.email ?: "", + onValueChange = { profileViewModel.setEmail(it) }, + label = { Text("Email") }, + placeholder = { Text("Enter Your Email") }, + isError = profileUIState.invalidEmailMsg != null, + supportingText = { + profileUIState.invalidEmailMsg?.let { + Text( + text = it, + modifier = Modifier.testTag(MyProfileScreenTestTag.ERROR_MSG)) + } + }, + modifier = + Modifier.fillMaxWidth().testTag(MyProfileScreenTestTag.INPUT_PROFILE_EMAIL)) + + Spacer(modifier = Modifier.height(fieldSpacing)) + + // Location input field + OutlinedTextField( + value = profileUIState.location?.name ?: "", + onValueChange = { profileViewModel.setLocation(it) }, + label = { Text("Location / Campus") }, + placeholder = { Text("Enter Your Location or University") }, + isError = profileUIState.invalidLocationMsg != null, + supportingText = { + profileUIState.invalidLocationMsg?.let { + Text( + text = it, + modifier = Modifier.testTag(MyProfileScreenTestTag.ERROR_MSG)) + } + }, + modifier = + Modifier.fillMaxWidth() + .testTag(MyProfileScreenTestTag.INPUT_PROFILE_LOCATION)) + + Spacer(modifier = Modifier.height(fieldSpacing)) + + // Description input field + OutlinedTextField( + value = profileUIState.description ?: "", + onValueChange = { profileViewModel.setDescription(it) }, + label = { Text("Description") }, + placeholder = { Text("Info About You") }, + isError = profileUIState.invalidDescMsg != null, + supportingText = { + profileUIState.invalidDescMsg?.let { + Text( + text = it, + modifier = Modifier.testTag(MyProfileScreenTestTag.ERROR_MSG)) + } + }, + minLines = 2, + modifier = + Modifier.fillMaxWidth().testTag(MyProfileScreenTestTag.INPUT_PROFILE_DESC)) + } + } + } +} diff --git a/app/src/main/java/com/android/sample/ui/profile/MyProfileViewModel.kt b/app/src/main/java/com/android/sample/ui/profile/MyProfileViewModel.kt new file mode 100644 index 00000000..ec22e8ac --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/profile/MyProfileViewModel.kt @@ -0,0 +1,164 @@ +package com.android.sample.ui.profile + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.sample.model.map.Location +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.model.user.ProfileRepositoryProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** UI state for the MyProfile screen. Holds all data needed to edit a profile */ +data class MyProfileUIState( + val name: String? = "", + val email: String? = "", + val location: Location? = Location(name = ""), + val description: String? = "", + val invalidNameMsg: String? = null, + val invalidEmailMsg: String? = null, + val invalidLocationMsg: String? = null, + val invalidDescMsg: String? = null, +) { + // Checks if all fields are valid + val isValid: Boolean + get() = + invalidNameMsg == null && + invalidEmailMsg == null && + invalidLocationMsg == null && + invalidDescMsg == null && + name?.isNotBlank() == true && + email?.isNotBlank() == true && + location != null && + description?.isNotBlank() == true +} + +// ViewModel to manage profile editing logic and state +class MyProfileViewModel( + private val repository: ProfileRepository = ProfileRepositoryProvider.repository +) : ViewModel() { + // Holds the current UI state + private val _uiState = MutableStateFlow(MyProfileUIState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val nameMsgError = "Name cannot be empty" + private val emailEmptyMsgError = "Email cannot be empty" + private val emailInvalidMsgError = "Email is not in the right format" + private val locationMsgError = "Location cannot be empty" + private val descMsgError = "Description cannot be empty" + + /** Loads the profile data (to be implemented) */ + fun loadProfile(userId: String) { + try { + viewModelScope.launch { + val profile = repository.getProfile(userId = userId) + _uiState.value = + MyProfileUIState( + name = profile?.name, + email = profile?.email, + location = profile?.location, + description = profile?.description) + } + } catch (e: Exception) { + Log.e("MyProfileViewModel", "Error loading ToDo by ID: $userId", e) + } + } + + /** + * Edits a Profile. + * + * @param userId The ID of the profile to edit. + * @return true if the update process was started, false if validation failed. + */ + fun editProfile(userId: String) { + val state = _uiState.value + if (!state.isValid) { + setError() + return + } + val profile = + Profile( + userId = userId, + name = state.name ?: "", + email = state.email ?: "", + location = state.location ?: Location(name = ""), + description = state.description ?: "") + + editProfileToRepository(userId = userId, profile = profile) + } + + /** + * Edits a Profile in the repository. + * + * @param userId The ID of the profile to be edited. + * @param profile The Profile object containing the new values. + */ + private fun editProfileToRepository(userId: String, profile: Profile) { + viewModelScope.launch { + try { + repository.updateProfile(userId = userId, profile = profile) + } catch (e: Exception) { + Log.e("MyProfileViewModel", "Error updating Profile", e) + } + } + } + + // Set all messages error, if invalid field + fun setError() { + _uiState.update { currentState -> + currentState.copy( + invalidNameMsg = currentState.name?.let { if (it.isBlank()) nameMsgError else null }, + invalidEmailMsg = validateEmail(currentState.email ?: ""), + invalidLocationMsg = + currentState.location?.let { if (it.name.isBlank()) locationMsgError else null }, + invalidDescMsg = + currentState.description?.let { if (it.isBlank()) descMsgError else null }) + } + } + + // Updates the name and validates it + fun setName(name: String) { + _uiState.value = + _uiState.value.copy( + name = name, invalidNameMsg = if (name.isBlank()) nameMsgError else null) + } + + // Updates the email and validates it + fun setEmail(email: String) { + _uiState.value = _uiState.value.copy(email = email, invalidEmailMsg = validateEmail(email)) + } + + // Updates the location and validates it + fun setLocation(locationName: String) { + _uiState.value = + _uiState.value.copy( + location = if (locationName.isBlank()) null else Location(name = locationName), + invalidLocationMsg = if (locationName.isBlank()) locationMsgError else null) + } + + // Updates the desc and validates it + fun setDescription(desc: String) { + _uiState.value = + _uiState.value.copy( + description = desc, invalidDescMsg = if (desc.isBlank()) descMsgError else null) + } + + // Checks if the email format is valid + private fun isValidEmail(email: String): Boolean { + val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + return email.matches(emailRegex.toRegex()) + } + + // Return the good error message corresponding of the given input + private fun validateEmail(email: String): String? { + return when { + email.isBlank() -> emailEmptyMsgError + !isValidEmail(email) -> emailInvalidMsgError + else -> null + } + } +} diff --git a/app/src/main/java/com/android/sample/ui/signup/SignUpScreen.kt b/app/src/main/java/com/android/sample/ui/signup/SignUpScreen.kt new file mode 100644 index 00000000..7784c961 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/signup/SignUpScreen.kt @@ -0,0 +1,244 @@ +package com.android.sample.ui.signup + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.sample.ui.theme.DisabledContent +import com.android.sample.ui.theme.FieldContainer +import com.android.sample.ui.theme.GrayE6 +import com.android.sample.ui.theme.SampleAppTheme +import com.android.sample.ui.theme.TurquoiseEnd +import com.android.sample.ui.theme.TurquoisePrimary +import com.android.sample.ui.theme.TurquoiseStart + +object SignUpScreenTestTags { + const val TITLE = "SignUpScreenTestTags.TITLE" + const val SUBTITLE = "SignUpScreenTestTags.SUBTITLE" + const val LEARNER = "SignUpScreenTestTags.LEARNER" + const val TUTOR = "SignUpScreenTestTags.TUTOR" + const val NAME = "SignUpScreenTestTags.NAME" + const val SURNAME = "SignUpScreenTestTags.SURNAME" + const val ADDRESS = "SignUpScreenTestTags.ADDRESS" + const val LEVEL_OF_EDUCATION = "SignUpScreenTestTags.LEVEL_OF_EDUCATION" + const val DESCRIPTION = "SignUpScreenTestTags.DESCRIPTION" + const val EMAIL = "SignUpScreenTestTags.EMAIL" + const val PASSWORD = "SignUpScreenTestTags.PASSWORD" + const val SIGN_UP = "SignUpScreenTestTags.SIGN_UP" +} + +@Composable +fun SignUpScreen(vm: SignUpViewModel, onSubmitSuccess: () -> Unit = {}) { + val state by vm.state.collectAsState() + + LaunchedEffect(state.submitSuccess) { if (state.submitSuccess) onSubmitSuccess() } + + val fieldShape = RoundedCornerShape(14.dp) + val fieldColors = + TextFieldDefaults.colors( + focusedContainerColor = FieldContainer, + unfocusedContainerColor = FieldContainer, + disabledContainerColor = FieldContainer, + focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + disabledIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface) + + val scrollState = rememberScrollState() + + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text( + "SkillBridge", + modifier = Modifier.fillMaxWidth().testTag(SignUpScreenTestTags.TITLE), + textAlign = TextAlign.Center, + style = + MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.ExtraBold, color = TurquoisePrimary)) + + Text( + "Personal Informations", + modifier = Modifier.testTag(SignUpScreenTestTags.SUBTITLE), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)) + + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + FilterChip( + selected = state.role == Role.LEARNER, + onClick = { vm.onEvent(SignUpEvent.RoleChanged(Role.LEARNER)) }, + label = { Text("I’m a Learner") }, + modifier = Modifier.testTag(SignUpScreenTestTags.LEARNER), + shape = RoundedCornerShape(20.dp)) + FilterChip( + selected = state.role == Role.TUTOR, + onClick = { vm.onEvent(SignUpEvent.RoleChanged(Role.TUTOR)) }, + label = { Text("I’m a Tutor") }, + modifier = Modifier.testTag(SignUpScreenTestTags.TUTOR), + shape = RoundedCornerShape(20.dp)) + } + + TextField( + value = state.name, + onValueChange = { vm.onEvent(SignUpEvent.NameChanged(it)) }, + modifier = Modifier.fillMaxWidth().testTag(SignUpScreenTestTags.NAME), + placeholder = { Text("Enter your Name", fontWeight = FontWeight.Bold) }, + singleLine = true, + shape = fieldShape, + colors = fieldColors) + + TextField( + value = state.surname, + onValueChange = { vm.onEvent(SignUpEvent.SurnameChanged(it)) }, + modifier = Modifier.fillMaxWidth().testTag(SignUpScreenTestTags.SURNAME), + placeholder = { Text("Enter your Surname", fontWeight = FontWeight.Bold) }, + singleLine = true, + shape = fieldShape, + colors = fieldColors) + + TextField( + value = state.address, + onValueChange = { vm.onEvent(SignUpEvent.AddressChanged(it)) }, + modifier = Modifier.fillMaxWidth().testTag(SignUpScreenTestTags.ADDRESS), + placeholder = { Text("Address", fontWeight = FontWeight.Bold) }, + singleLine = true, + shape = fieldShape, + colors = fieldColors) + + TextField( + value = state.levelOfEducation, + onValueChange = { vm.onEvent(SignUpEvent.LevelOfEducationChanged(it)) }, + modifier = Modifier.fillMaxWidth().testTag(SignUpScreenTestTags.LEVEL_OF_EDUCATION), + placeholder = { Text("Major, Year (e.g. CS, 3rd year)", fontWeight = FontWeight.Bold) }, + singleLine = true, + shape = fieldShape, + colors = fieldColors) + + TextField( + value = state.description, + onValueChange = { vm.onEvent(SignUpEvent.DescriptionChanged(it)) }, + modifier = + Modifier.fillMaxWidth() + .heightIn(min = 112.dp) + .testTag(SignUpScreenTestTags.DESCRIPTION), + placeholder = { Text("Short description of yourself", fontWeight = FontWeight.Bold) }, + shape = fieldShape, + colors = fieldColors) + + TextField( + value = state.email, + onValueChange = { vm.onEvent(SignUpEvent.EmailChanged(it)) }, + modifier = Modifier.fillMaxWidth().testTag(SignUpScreenTestTags.EMAIL), + placeholder = { Text("Email Address", fontWeight = FontWeight.Bold) }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }, + shape = fieldShape, + colors = fieldColors) + + TextField( + value = state.password, + onValueChange = { vm.onEvent(SignUpEvent.PasswordChanged(it)) }, + modifier = Modifier.fillMaxWidth().testTag(SignUpScreenTestTags.PASSWORD), + placeholder = { Text("Password", fontWeight = FontWeight.Bold) }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + visualTransformation = PasswordVisualTransformation(), + shape = fieldShape, + colors = fieldColors) + + Spacer(Modifier.height(6.dp)) + + // Password requirement checklist computed from the entered password + val pw = state.password + val minLength = pw.length >= 8 + val hasLetter = pw.any { it.isLetter() } + val hasDigit = pw.any { it.isDigit() } + val hasSpecial = Regex("[^A-Za-z0-9]").containsMatchIn(pw) + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)) { + RequirementItem(met = minLength, text = "At least 8 characters") + RequirementItem(met = hasLetter, text = "Contains a letter") + RequirementItem(met = hasDigit, text = "Contains a digit") + RequirementItem(met = hasSpecial, text = "Contains a special character") + } + + val gradient = Brush.horizontalGradient(listOf(TurquoiseStart, TurquoiseEnd)) + val disabledBrush = Brush.linearGradient(listOf(GrayE6, GrayE6)) + // Require the ViewModel's passwordRequirements to be satisfied (includes special character) + val enabled = + state.canSubmit && minLength && hasLetter && hasDigit && hasSpecial && !state.submitting + + val buttonColors = + ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = Color.White, // <-- white text when enabled + disabledContainerColor = Color.Transparent, + disabledContentColor = DisabledContent // <-- gray text when disabled + ) + + Button( + onClick = { vm.onEvent(SignUpEvent.Submit) }, + enabled = enabled, + modifier = + Modifier.fillMaxWidth() + .height(52.dp) + .clip(RoundedCornerShape(24.dp)) + .background(if (enabled) gradient else disabledBrush, RoundedCornerShape(24.dp)) + .testTag(SignUpScreenTestTags.SIGN_UP), + colors = buttonColors, + contentPadding = PaddingValues(0.dp)) { + Text( + if (state.submitting) "Submitting…" else "Sign Up", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun RequirementItem(met: Boolean, text: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically) { + val tint = if (met) MaterialTheme.colorScheme.primary else DisabledContent + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = tint, + modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = if (met) MaterialTheme.colorScheme.onSurface else DisabledContent) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewSignUpScreen() { + SampleAppTheme { SignUpScreen(vm = SignUpViewModel()) } +} diff --git a/app/src/main/java/com/android/sample/ui/signup/SignUpViewModel.kt b/app/src/main/java/com/android/sample/ui/signup/SignUpViewModel.kt new file mode 100644 index 00000000..23979c18 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/signup/SignUpViewModel.kt @@ -0,0 +1,131 @@ +package com.android.sample.ui.signup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.sample.model.map.Location +import com.android.sample.model.user.FakeProfileRepository +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +enum class Role { + LEARNER, + TUTOR +} + +data class SignUpUiState( + val role: Role = Role.LEARNER, + val name: String = "", + val surname: String = "", + val address: String = "", + val levelOfEducation: String = "", + val description: String = "", + val email: String = "", + val password: String = "", + val submitting: Boolean = false, + val error: String? = null, + val canSubmit: Boolean = false, + val submitSuccess: Boolean = false +) + +sealed interface SignUpEvent { + data class RoleChanged(val role: Role) : SignUpEvent + + data class NameChanged(val value: String) : SignUpEvent + + data class SurnameChanged(val value: String) : SignUpEvent + + data class AddressChanged(val value: String) : SignUpEvent + + data class LevelOfEducationChanged(val value: String) : SignUpEvent + + data class DescriptionChanged(val value: String) : SignUpEvent + + data class EmailChanged(val value: String) : SignUpEvent + + data class PasswordChanged(val value: String) : SignUpEvent + + object Submit : SignUpEvent +} + +class SignUpViewModel(private val repo: ProfileRepository = FakeProfileRepository()) : ViewModel() { + private val _state = MutableStateFlow(SignUpUiState()) + val state: StateFlow = _state + + fun onEvent(e: SignUpEvent) { + when (e) { + is SignUpEvent.RoleChanged -> _state.update { it.copy(role = e.role) } + is SignUpEvent.NameChanged -> _state.update { it.copy(name = e.value) } + is SignUpEvent.SurnameChanged -> _state.update { it.copy(surname = e.value) } + is SignUpEvent.AddressChanged -> _state.update { it.copy(address = e.value) } + is SignUpEvent.LevelOfEducationChanged -> + _state.update { it.copy(levelOfEducation = e.value) } + is SignUpEvent.DescriptionChanged -> _state.update { it.copy(description = e.value) } + is SignUpEvent.EmailChanged -> _state.update { it.copy(email = e.value) } + is SignUpEvent.PasswordChanged -> _state.update { it.copy(password = e.value) } + SignUpEvent.Submit -> submit() + } + validate() + } + + private fun validate() { + val namePattern = Regex("^[\\p{L} ]+\$") // Unicode letters and spaces only + + _state.update { s -> + val nameTrim = s.name.trim() + val surnameTrim = s.surname.trim() + val nameOk = nameTrim.isNotEmpty() && namePattern.matches(nameTrim) + val surnameOk = surnameTrim.isNotEmpty() && namePattern.matches(surnameTrim) + + val emailTrim = s.email.trim() + val emailOk = run { + // require exactly one '@', non-empty local and domain, and at least one dot in domain + val atCount = emailTrim.count { it == '@' } + if (atCount != 1) return@run false + val (local, domain) = emailTrim.split("@", limit = 2) + local.isNotEmpty() && domain.isNotEmpty() && domain.contains('.') + } + + val password = s.password + val passwordOk = + password.length >= 8 && password.any { it.isDigit() } && password.any { it.isLetter() } + val levelOk = s.levelOfEducation.trim().isNotEmpty() + val ok = nameOk && surnameOk && emailOk && passwordOk && levelOk + s.copy(canSubmit = ok, error = null) + } + } + + private fun submit() { + viewModelScope.launch { + _state.update { it.copy(submitting = true, error = null, submitSuccess = false) } + val current = _state.value + try { + val newUid = repo.getNewUid() + val fullName = + listOf(current.name.trim(), current.surname.trim()) + .filter { it.isNotEmpty() } + .joinToString(" ") + val profile = + Profile( + userId = newUid, + name = fullName, + email = current.email, + levelOfEducation = current.levelOfEducation, + description = current.description, + location = buildLocation(current.address)) + repo.addProfile(profile) + _state.update { it.copy(submitting = false, submitSuccess = true) } + } catch (t: Throwable) { + _state.update { it.copy(submitting = false, error = t.message ?: "Unknown error") } + } + } + } + + // Store the entered address into Location.name. Replace with geocoding later if needed. + private fun buildLocation(address: String): Location { + return Location(name = address.trim()) + } +} diff --git a/app/src/main/java/com/android/sample/ui/subject/SubjectListScreen.kt b/app/src/main/java/com/android/sample/ui/subject/SubjectListScreen.kt new file mode 100644 index 00000000..784e52ce --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/subject/SubjectListScreen.kt @@ -0,0 +1,153 @@ +package com.android.sample.ui.subject + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.android.sample.model.user.Profile +import com.android.sample.ui.components.TutorCard + +/** Test tags for the different elements of the SubjectListScreen */ +object SubjectListTestTags { + const val SEARCHBAR = "SubjectListTestTags.SEARCHBAR" + const val CATEGORY_SELECTOR = "SubjectListTestTags.CATEGORY_SELECTOR" + const val TOP_TUTORS_SECTION = "SubjectListTestTags.TOP_TUTORS_SECTION" + const val TUTOR_LIST = "SubjectListTestTags.TUTOR_LIST" + const val TUTOR_CARD = "SubjectListTestTags.TUTOR_CARD" + const val TUTOR_BOOK_BUTTON = "SubjectListTestTags.TUTOR_BOOK_BUTTON" +} + +/** + * Screen showing a list of tutors for a specific subject, with search and category filter. + * + * @param viewModel ViewModel providing the data + * @param onBookTutor Callback when the "Book" button is pressed on a tutor card + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubjectListScreen( + viewModel: SubjectListViewModel, + onBookTutor: (Profile) -> Unit = {}, +) { + val ui by viewModel.ui.collectAsState() + LaunchedEffect(Unit) { viewModel.refresh() } + + Scaffold { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + // Search + OutlinedTextField( + value = ui.query, + onValueChange = viewModel::onQueryChanged, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + placeholder = { Text("Find a tutor about...") }, + singleLine = true, + modifier = + Modifier.fillMaxWidth().padding(top = 8.dp).testTag(SubjectListTestTags.SEARCHBAR)) + + Spacer(Modifier.height(12.dp)) + + // Category selector (skills for current main subject) + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + readOnly = true, + value = ui.selectedSkill?.replace('_', ' ') ?: "e.g. instrument, sing, mix, ...", + onValueChange = {}, + label = { Text("Category") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = + Modifier.menuAnchor() + .fillMaxWidth() + .testTag(SubjectListTestTags.CATEGORY_SELECTOR)) + + // Hide the menu when a dismiss happens (expanded = false) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + // "All" option -> no skill filter + DropdownMenuItem( + text = { Text("All") }, + onClick = { + viewModel.onSkillSelected(null) + expanded = false + }) + ui.skillsForSubject.forEach { skillName -> + DropdownMenuItem( + text = { + Text( + skillName.replace('_', ' ').lowercase().replaceFirstChar { + it.titlecase() + }) + }, + onClick = { + viewModel.onSkillSelected(skillName) + expanded = false + }) + } + } + } + + Spacer(Modifier.height(16.dp)) + + // All tutors list + Text( + "All ${ui.mainSubject.name.lowercase()} lessons", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold) + + Spacer(Modifier.height(8.dp)) + + // Loading indicator or error message, if neither, this block shows nothing + if (ui.isLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else if (ui.error != null) { + Text(ui.error!!, color = MaterialTheme.colorScheme.error) + } + + // Tutors list + LazyColumn( + modifier = Modifier.fillMaxSize().testTag(SubjectListTestTags.TUTOR_LIST), + contentPadding = PaddingValues(bottom = 24.dp)) { + items(ui.tutors) { p -> + // Reuse TutorCard from components + TutorCard( + profile = p, + pricePerHour = null, + onPrimaryAction = onBookTutor, + cardTestTag = SubjectListTestTags.TUTOR_CARD, + buttonTestTag = SubjectListTestTags.TUTOR_BOOK_BUTTON) + Spacer(Modifier.height(16.dp)) + } + } + } + } +} diff --git a/app/src/main/java/com/android/sample/ui/subject/SubjectListViewModel.kt b/app/src/main/java/com/android/sample/ui/subject/SubjectListViewModel.kt new file mode 100644 index 00000000..ea792a0e --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/subject/SubjectListViewModel.kt @@ -0,0 +1,163 @@ +package com.android.sample.ui.subject + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.sample.model.skill.MainSubject +import com.android.sample.model.skill.Skill +import com.android.sample.model.skill.SkillsHelper +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.model.user.ProfileRepositoryProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope + +/** UI state for the Subject List screen */ +data class SubjectListUiState( + val mainSubject: MainSubject = MainSubject.MUSIC, + val query: String = "", + val selectedSkill: String? = null, + val skillsForSubject: List = SkillsHelper.getSkillNames(MainSubject.MUSIC), + /** Full set of tutors loaded from repo (before any filters) */ + val allTutors: List = emptyList(), + /** The currently displayed list (after filters applied) */ + val tutors: List = emptyList(), + /** Cache of each tutor's skills so filtering is non-suspending */ + val userSkills: Map> = emptyMap(), + val isLoading: Boolean = false, + val error: String? = null +) + +/** + * ViewModel for the Subject List screen. Loads and holds the list of tutors, applying search and + * skill filters as needed. + * + * @param repository The profile repository to load tutors from + */ +class SubjectListViewModel( + private val repository: ProfileRepository = ProfileRepositoryProvider.repository +) : ViewModel() { + + private val _ui = MutableStateFlow(SubjectListUiState()) + val ui: StateFlow = _ui + + private var loadJob: Job? = null + + /** Refreshes the list of tutors by loading from the repository. */ + fun refresh() { + // Cancel any ongoing load + loadJob?.cancel() + // Start a new load + loadJob = + viewModelScope.launch { + _ui.update { it.copy(isLoading = true, error = null) } + try { + // 1) Load all profiles + val allProfiles = repository.getAllProfiles() + + // 2) Load skills for each profile concurrently, but don't fail the whole refresh + val skillsByUser = loadSkillsForUsers(allProfiles) + + // 3) Update raw state, then apply current filters + _ui.update { + it.copy( + allTutors = allProfiles, + userSkills = skillsByUser, + isLoading = false, + error = null) + } + applyFilters() + } catch (t: Throwable) { + _ui.update { it.copy(isLoading = false, error = t.message ?: "Unknown error") } + } + } + } + + /** + * Loads skills for a list of users concurrently, returning a map of userId to their skills. + * + * @param profiles The list of profiles to load skills for + */ + private suspend fun loadSkillsForUsers(profiles: List): Map> = + supervisorScope { + profiles + .map { p -> + async { + val skills = + runCatching { repository.getSkillsForUser(p.userId) } + .onFailure { e -> + Log.w("SubjectListVM", "Failed to load skills for ${p.userId}", e) + } + .getOrElse { emptyList() } + p.userId to skills + } + } + .awaitAll() + .toMap() + } + + /** + * Called when the search query changes. Updates the query state and reapplies filters to the full + * list. + * + * @param newQuery The new search query string + */ + fun onQueryChanged(newQuery: String) { + _ui.update { it.copy(query = newQuery) } + applyFilters() + } + + /** + * Called when a skill is selected from the category dropdown. Updates the selected skill state + * and reapplies filters to the full list. + * + * @param skill The selected skill, or null to clear the filter + */ + fun onSkillSelected(skill: String?) { + _ui.update { it.copy(selectedSkill = skill) } + applyFilters() + } + + /** Applies the current search query and skill filter to the full list, then sorts by rating. */ + private fun applyFilters() { + val state = _ui.value + + // normalize a skill key for easier matching + fun key(s: String) = s.trim().lowercase() + val selectedSkillKey = state.selectedSkill?.let(::key) + + val filtered = + state.allTutors.filter { profile -> + val matchesQuery = + // Match if query is blank, or name or description contains the query + state.query.isBlank() || + profile.name?.contains(state.query, ignoreCase = true) == true || + profile.description.contains(state.query, ignoreCase = true) + + val matchesSkill = + // Match if no skill selected, or if user has the selected skill for this subject + selectedSkillKey == null || + state.userSkills[profile.userId].orEmpty().any { + it.mainSubject == state.mainSubject && key(it.skill) == selectedSkillKey + } + // Include if matches both query and skill + matchesQuery && matchesSkill + } + + // Sort best-first for the single list + val sorted = + filtered.sortedWith( + // Sort by average rating (desc), then by total ratings (desc), then by name (asc) + compareByDescending { it.tutorRating.averageRating } + .thenByDescending { it.tutorRating.totalRatings } + .thenBy { it.name }) + + _ui.update { it.copy(tutors = sorted) } + } +} diff --git a/app/src/main/java/com/android/sample/ui/theme/Color.kt b/app/src/main/java/com/android/sample/ui/theme/Color.kt index ba23d2ab..4e2214d3 100644 --- a/app/src/main/java/com/android/sample/ui/theme/Color.kt +++ b/app/src/main/java/com/android/sample/ui/theme/Color.kt @@ -9,3 +9,32 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260) + +val TurquoisePrimary = Color(0xFF2EC4B6) +val TurquoiseStart = Color(0xFF2AB7A9) +val TurquoiseEnd = Color(0xFF36D1C1) + +val FieldContainer = Color(0xFFE9ECF1) +val DisabledContent = Color(0xFF9E9E9E) +val GrayE6 = Color(0xFFE6E6E6) +val White = Color(0xFFFFFFFF) +val TealChip = Color(0xFF0EA5B6) +val BrandBlue = Color(0xFF0288D1) // links, price, "details" button +val CardBg = Color(0xFFE9EAF1) // card fill close to figma light grey +val ChipBorder = Color(0xFF333333) // chip border close to figma dark grey +val BlueApp = Color(0xFF90CAF9) +val GreenApp = Color(0xFF43EA7F) + +val PrimaryColor = Color(0xFF00ACC1) +val SecondaryColor = Color(0xFF1E88E5) + +// Login Screen Colors +val LoginTitleBlue = Color(0xFF1E88E5) +val SuccessGreen = Color(0xFF4CAF50) +val MessageGreen = Color(0xFF4CAF50) +val UnselectedGray = Color(0xFFD3D3D3) // LightGray +val ForgotPasswordGray = Color(0xFF808080) // Gray +val AuthButtonBorderGray = Color(0xFF808080) // Gray +val SignInButtonTeal = Color(0xFF00ACC1) +val AuthProviderTextBlack = Color(0xFF000000) +val SignUpLinkBlue = Color(0xFF2196F3) // Blue diff --git a/app/src/main/java/com/android/sample/ui/theme/Theme.kt b/app/src/main/java/com/android/sample/ui/theme/Theme.kt index 5ecb3910..5316c599 100644 --- a/app/src/main/java/com/android/sample/ui/theme/Theme.kt +++ b/app/src/main/java/com/android/sample/ui/theme/Theme.kt @@ -9,12 +9,32 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +// Extended colors for custom theming +@Immutable +data class ExtendedColors( + val loginTitleBlue: Color = LoginTitleBlue, + val successGreen: Color = SuccessGreen, + val messageGreen: Color = MessageGreen, + val unselectedGray: Color = UnselectedGray, + val forgotPasswordGray: Color = ForgotPasswordGray, + val authButtonBorderGray: Color = AuthButtonBorderGray, + val signInButtonTeal: Color = SignInButtonTeal, + val authProviderTextBlack: Color = AuthProviderTextBlack, + val signUpLinkBlue: Color = SignUpLinkBlue +) + +val LocalExtendedColors = staticCompositionLocalOf { ExtendedColors() } + private val DarkColorScheme = darkColorScheme(primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80) @@ -58,5 +78,11 @@ fun SampleAppTheme( } } - MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content) + CompositionLocalProvider(LocalExtendedColors provides ExtendedColors()) { + MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content) + } } + +// Extension property to access extended colors from MaterialTheme +val MaterialTheme.extendedColors: ExtendedColors + @Composable get() = LocalExtendedColors.current diff --git a/app/src/main/java/com/android/sample/ui/tutor/TutorProfileScreen.kt b/app/src/main/java/com/android/sample/ui/tutor/TutorProfileScreen.kt new file mode 100644 index 00000000..132b3c71 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/tutor/TutorProfileScreen.kt @@ -0,0 +1,214 @@ +package com.android.sample.ui.tutor + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MailOutline +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.android.sample.model.skill.Skill +import com.android.sample.model.user.Profile +import com.android.sample.ui.components.RatingStars +import com.android.sample.ui.components.SkillChip +import com.android.sample.ui.theme.White + +/** Test tags for the Tutor Profile screen. */ +object TutorPageTestTags { + const val PFP = "TutorPageTestTags.PFP" + const val NAME = "TutorPageTestTags.NAME" + const val RATING = "TutorPageTestTags.RATING" + const val SKILLS_SECTION = "TutorPageTestTags.SKILLS_SECTION" + const val SKILL = "TutorPageTestTags.SKILL" + const val CONTACT_SECTION = "TutorPageTestTags.CONTACT_SECTION" +} + +/** + * The Tutor Profile screen displays detailed information about a tutor, including their name, + * profile picture, skills, and contact information. + * + * @param tutorId The unique identifier of the tutor whose profile is to be displayed. + * @param vm The ViewModel that provides the data for the screen. + * @param navController The NavHostController for navigation actions. + * @param modifier The modifier to be applied to the composable. + */ +@Composable +fun TutorProfileScreen( + tutorId: String, + vm: TutorProfileViewModel, + navController: NavHostController, + modifier: Modifier = Modifier +) { + LaunchedEffect(tutorId) { vm.load(tutorId) } + val state by vm.state.collectAsStateWithLifecycle() + + Scaffold { innerPadding -> + // Show a loading spinner while loading and the content when loaded + if (state.loading) { + Box( + modifier = modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + val profile = state.profile + if (profile != null) { + TutorContent( + profile = profile, skills = state.skills, modifier = modifier, padding = innerPadding) + } + } + } +} + +/** + * The main content of the Tutor Profile screen, displaying the tutor's profile information, skills, + * and contact details. + * + * @param profile The profile of the tutor. + * @param skills The list of skills the tutor offers. + * @param modifier The modifier to be applied to the composable. + * @param padding The padding values to be applied to the content. + */ +@Composable +private fun TutorContent( + profile: Profile, + skills: List, + modifier: Modifier, + padding: PaddingValues +) { + LazyColumn( + contentPadding = PaddingValues(16.dp), + modifier = modifier.fillMaxSize().padding(padding), + verticalArrangement = Arrangement.spacedBy(16.dp)) { + item { + Surface( + color = White, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth()) { + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier.fillMaxWidth().height(140.dp), + contentAlignment = Alignment.Center) { + Box( + Modifier.size(96.dp) + .clip(MaterialTheme.shapes.extraLarge) + .background(MaterialTheme.colorScheme.surfaceVariant) + .testTag(TutorPageTestTags.PFP)) + } + Text( + profile.name ?: "No Name", + style = + MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold), + modifier = Modifier.testTag(TutorPageTestTags.NAME)) + RatingStars( + ratingOutOfFive = profile.tutorRating.averageRating, + modifier = Modifier.testTag(TutorPageTestTags.RATING)) + Text( + "(${profile.tutorRating.totalRatings})", + style = MaterialTheme.typography.bodyMedium) + } + } + } + + item { + Column(modifier = Modifier.fillMaxWidth().testTag(TutorPageTestTags.SKILLS_SECTION)) { + Text("Skills:", style = MaterialTheme.typography.titleMedium) + } + } + + items(skills) { s -> + SkillChip(skill = s, modifier = Modifier.fillMaxWidth().testTag(TutorPageTestTags.SKILL)) + } + + item { + Surface( + color = White, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth().testTag(TutorPageTestTags.CONTACT_SECTION)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.MailOutline, contentDescription = "Email") + Spacer(Modifier.width(8.dp)) + Text(profile.email, style = MaterialTheme.typography.bodyMedium) + } + Row(verticalAlignment = Alignment.CenterVertically) { + InstagramGlyph() + Spacer(Modifier.width(8.dp)) + val handle = "@${profile.name?.replace(" ", "")}" + Text(handle, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + } +} + +/** + * A simple Instagram glyph drawn using Canvas (Ai generated). + * + * @param modifier The modifier to be applied to the composable. + */ +@Composable +private fun InstagramGlyph(modifier: Modifier = Modifier) { + val color = LocalContentColor.current + Canvas(modifier.size(24.dp)) { + val w = size.width + val h = size.height + val stroke = w * 0.12f + // Rounded square outline + drawRoundRect( + color = color, + size = size, + cornerRadius = androidx.compose.ui.geometry.CornerRadius(w * 0.22f, h * 0.22f), + style = Stroke(width = stroke, cap = StrokeCap.Round, join = StrokeJoin.Round)) + // Camera lens + drawCircle( + color = color, + radius = w * 0.22f, + center = androidx.compose.ui.geometry.Offset(w * 0.5f, h * 0.5f), + style = Stroke(width = stroke, cap = StrokeCap.Round, join = StrokeJoin.Round)) + // Small dot + drawCircle( + color = color, + radius = w * 0.06f, + center = androidx.compose.ui.geometry.Offset(w * 0.78f, h * 0.22f), + style = Fill) + } +} diff --git a/app/src/main/java/com/android/sample/ui/tutor/TutorProfileViewModel.kt b/app/src/main/java/com/android/sample/ui/tutor/TutorProfileViewModel.kt new file mode 100644 index 00000000..0ea7b520 --- /dev/null +++ b/app/src/main/java/com/android/sample/ui/tutor/TutorProfileViewModel.kt @@ -0,0 +1,75 @@ +package com.android.sample.ui.tutor + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.sample.model.skill.Skill +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.model.user.ProfileRepositoryProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope + +/** + * UI state for the TutorProfile screen. This state holds the data needed to display a tutor's + * profile. + * + * @param loading Whether the data is still loading. + * @param profile The profile of the tutor. + * @param skills The list of skills the tutor offers. + */ +data class TutorUiState( + val loading: Boolean = true, + val profile: Profile? = null, + val skills: List = emptyList(), + val error: String? = null +) + +/** + * ViewModel for the TutorProfile screen. + * + * @param repository The repository to fetch tutor data. + */ +class TutorProfileViewModel( + private val repository: ProfileRepository = ProfileRepositoryProvider.repository, +) : ViewModel() { + + private val _state = MutableStateFlow(TutorUiState()) + val state: StateFlow = _state.asStateFlow() + + private var loadJob: Job? = null + + /** + * Loads the tutor data for the given tutor ID. If the data is already loaded, this function does + * nothing. + * + * @param tutorId The ID of the tutor to load. + */ + fun load(tutorId: String) { + val currentId = _state.value.profile?.userId + if (currentId == tutorId && !_state.value.loading) return + + loadJob?.cancel() + loadJob = + viewModelScope.launch { + _state.value = _state.value.copy(loading = true) + + val (profile, skills) = + supervisorScope { + val profileDeferred = async { repository.getProfile(tutorId) } + val skillsDeferred = async { repository.getSkillsForUser(tutorId) } + + val profile = runCatching { profileDeferred.await() }.getOrNull() + val skills = runCatching { skillsDeferred.await() }.getOrElse { emptyList() } + + profile to skills + } + + _state.value = TutorUiState(loading = false, profile = profile, skills = skills) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4acc7c97..11049cfa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,4 +2,6 @@ SampleApp MainActivity SecondActivity + + 1061045584009-duiljd2t9ijc3u8vc9193a4ecpk2di5f.apps.googleusercontent.com \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..77d9e138 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + 10.0.2.2 + localhost + + + diff --git a/app/src/test/java/com/android/sample/ExampleRobolectricTest.kt b/app/src/test/java/com/android/sample/ExampleRobolectricTest.kt index 9bcf1eb9..38ab4d29 100644 --- a/app/src/test/java/com/android/sample/ExampleRobolectricTest.kt +++ b/app/src/test/java/com/android/sample/ExampleRobolectricTest.kt @@ -22,12 +22,7 @@ class SecondActivityTest : TestCase() { @Test fun test() = run { step("Start Second Activity") { - ComposeScreen.onComposeScreen(composeTestRule) { - simpleText { - assertIsDisplayed() - assertTextEquals("Hello Robolectric!") - } - } + ComposeScreen.onComposeScreen(composeTestRule) {} } } } diff --git a/app/src/test/java/com/android/sample/model/authentication/AuthenticationModelsTest.kt b/app/src/test/java/com/android/sample/model/authentication/AuthenticationModelsTest.kt new file mode 100644 index 00000000..385f2e6e --- /dev/null +++ b/app/src/test/java/com/android/sample/model/authentication/AuthenticationModelsTest.kt @@ -0,0 +1,116 @@ +package com.android.sample.model.authentication + +import com.google.firebase.auth.FirebaseUser +import io.mockk.* +import org.junit.Assert.* +import org.junit.Test + +class AuthenticationModelsTest { + + @Test + fun authResult_Success_holdsUser() { + val mockUser = mockk() + val result = AuthResult.Success(mockUser) + + assertEquals(mockUser, result.user) + } + + @Test + fun authResult_Error_holdsMessage() { + val errorMessage = "Authentication failed" + val result = AuthResult.Error(errorMessage) + + assertEquals(errorMessage, result.message) + } + + @Test + fun userRole_hasCorrectValues() { + val roles = UserRole.entries + + assertEquals(2, roles.size) + assertTrue(roles.contains(UserRole.LEARNER)) + assertTrue(roles.contains(UserRole.TUTOR)) + } + + @Test + fun authenticationUiState_defaultValues() { + val state = AuthenticationUiState() + + assertEquals("", state.email) + assertEquals("", state.password) + assertEquals(UserRole.LEARNER, state.selectedRole) + assertFalse(state.isLoading) + assertNull(state.error) + assertNull(state.message) + assertFalse(state.showSuccessMessage) + } + + @Test + fun authenticationUiState_isSignInButtonEnabled_withEmptyFields() { + val state = AuthenticationUiState(email = "", password = "") + + assertFalse(state.isSignInButtonEnabled) + } + + @Test + fun authenticationUiState_isSignInButtonEnabled_withOnlyEmail() { + val state = AuthenticationUiState(email = "test@example.com", password = "") + + assertFalse(state.isSignInButtonEnabled) + } + + @Test + fun authenticationUiState_isSignInButtonEnabled_withOnlyPassword() { + val state = AuthenticationUiState(email = "", password = "password123") + + assertFalse(state.isSignInButtonEnabled) + } + + @Test + fun authenticationUiState_isSignInButtonEnabled_withBothFields() { + val state = AuthenticationUiState(email = "test@example.com", password = "password123") + + assertTrue(state.isSignInButtonEnabled) + } + + @Test + fun authenticationUiState_isSignInButtonEnabled_disabledWhileLoading() { + val state = + AuthenticationUiState( + email = "test@example.com", password = "password123", isLoading = true) + + assertFalse(state.isSignInButtonEnabled) + } + + @Test + fun authenticationUiState_withCustomValues() { + val state = + AuthenticationUiState( + email = "custom@example.com", + password = "custompass", + selectedRole = UserRole.TUTOR, + isLoading = true, + error = "Custom error", + message = "Custom message", + showSuccessMessage = true) + + assertEquals("custom@example.com", state.email) + assertEquals("custompass", state.password) + assertEquals(UserRole.TUTOR, state.selectedRole) + assertTrue(state.isLoading) + assertEquals("Custom error", state.error) + assertEquals("Custom message", state.message) + assertTrue(state.showSuccessMessage) + } + + @Test + fun authenticationUiState_copy_updatesSpecificFields() { + val originalState = + AuthenticationUiState(email = "original@example.com", password = "originalpass") + + val updatedState = originalState.copy(email = "updated@example.com") + + assertEquals("updated@example.com", updatedState.email) + assertEquals("originalpass", updatedState.password) + } +} diff --git a/app/src/test/java/com/android/sample/model/authentication/AuthenticationRepositoryTest.kt b/app/src/test/java/com/android/sample/model/authentication/AuthenticationRepositoryTest.kt new file mode 100644 index 00000000..8a3cb800 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/authentication/AuthenticationRepositoryTest.kt @@ -0,0 +1,79 @@ +package com.android.sample.model.authentication + +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import io.mockk.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AuthenticationRepositoryTest { + + @get:Rule val firebaseRule = FirebaseTestRule() + + private lateinit var mockAuth: FirebaseAuth + private lateinit var repository: AuthenticationRepository + + @Before + fun setUp() { + mockAuth = mockk(relaxed = true) + repository = AuthenticationRepository(mockAuth) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun signOut_callsFirebaseAuthSignOut() { + repository.signOut() + + verify { mockAuth.signOut() } + } + + @Test + fun getCurrentUser_returnsCurrentUser() { + val mockUser = mockk() + every { mockAuth.currentUser } returns mockUser + + val result = repository.getCurrentUser() + + assertEquals(mockUser, result) + } + + @Test + fun getCurrentUser_returnsNull_whenNoUserSignedIn() { + every { mockAuth.currentUser } returns null + + val result = repository.getCurrentUser() + + assertNull(result) + } + + @Test + fun isUserSignedIn_returnsTrue_whenUserSignedIn() { + val mockUser = mockk() + every { mockAuth.currentUser } returns mockUser + + val result = repository.isUserSignedIn() + + assertTrue(result) + } + + @Test + fun isUserSignedIn_returnsFalse_whenNoUserSignedIn() { + every { mockAuth.currentUser } returns null + + val result = repository.isUserSignedIn() + + assertFalse(result) + } +} diff --git a/app/src/test/java/com/android/sample/model/authentication/AuthenticationViewModelTest.kt b/app/src/test/java/com/android/sample/model/authentication/AuthenticationViewModelTest.kt new file mode 100644 index 00000000..2708bf94 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/authentication/AuthenticationViewModelTest.kt @@ -0,0 +1,407 @@ +@file:Suppress("DEPRECATION") + +package com.android.sample.model.authentication + +import android.content.Context +import androidx.activity.result.ActivityResult +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.credentials.PasswordCredential +import androidx.test.core.app.ApplicationProvider +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.firebase.auth.FirebaseUser +import io.mockk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class AuthenticationViewModelTest { + + @get:Rule val instantExecutorRule = InstantTaskExecutorRule() + @get:Rule val firebaseRule = FirebaseTestRule() + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var context: Context + private lateinit var mockRepository: AuthenticationRepository + private lateinit var mockCredentialHelper: CredentialAuthHelper + private lateinit var viewModel: AuthenticationViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + context = ApplicationProvider.getApplicationContext() + + mockRepository = mockk(relaxed = true) + mockCredentialHelper = mockk(relaxed = true) + + viewModel = AuthenticationViewModel(context, mockRepository, mockCredentialHelper) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun initialState_hasCorrectDefaults() = runTest { + val state = viewModel.uiState.first() + + assertFalse(state.isLoading) + assertNull(state.error) + assertNull(state.message) + assertEquals("", state.email) + assertEquals("", state.password) + assertEquals(UserRole.LEARNER, state.selectedRole) + assertFalse(state.showSuccessMessage) + assertFalse(state.isSignInButtonEnabled) + } + + @Test + fun updateEmail_updatesState() = runTest { + viewModel.updateEmail("test@example.com") + + val state = viewModel.uiState.first() + + assertEquals("test@example.com", state.email) + assertNull(state.error) + assertNull(state.message) + } + + @Test + fun updatePassword_updatesState() = runTest { + viewModel.updatePassword("password123") + + val state = viewModel.uiState.first() + + assertEquals("password123", state.password) + assertNull(state.error) + assertNull(state.message) + } + + @Test + fun updateSelectedRole_updatesState() = runTest { + viewModel.updateSelectedRole(UserRole.TUTOR) + + val state = viewModel.uiState.first() + + assertEquals(UserRole.TUTOR, state.selectedRole) + } + + @Test + fun signInButtonEnabled_onlyWhenEmailAndPasswordProvided() = runTest { + // Initially disabled + var state = viewModel.uiState.first() + assertFalse(state.isSignInButtonEnabled) + + // Still disabled with only email + viewModel.updateEmail("test@example.com") + state = viewModel.uiState.first() + assertFalse(state.isSignInButtonEnabled) + + // Enabled with both email and password + viewModel.updatePassword("password123") + state = viewModel.uiState.first() + assertTrue(state.isSignInButtonEnabled) + } + + @Test + fun signIn_withEmptyCredentials_showsError() = runTest { + viewModel.signIn() + + val state = viewModel.uiState.first() + + assertEquals("Email and password cannot be empty", state.error) + assertFalse(state.isLoading) + } + + @Test + fun signIn_withValidCredentials_succeeds() = runTest { + val mockUser = mockk() + viewModel.updateEmail("test@example.com") + viewModel.updatePassword("password123") + + coEvery { mockRepository.signInWithEmail(any(), any()) } returns Result.success(mockUser) + + viewModel.signIn() + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.first() + val authResult = viewModel.authResult.first() + + assertFalse(state.isLoading) + assertNull(state.error) + assertTrue(authResult is AuthResult.Success) + assertEquals(mockUser, (authResult as AuthResult.Success).user) + } + + @Test + fun signIn_withInvalidCredentials_showsError() = runTest { + viewModel.updateEmail("test@example.com") + viewModel.updatePassword("wrongpassword") + + val exception = Exception("Invalid credentials") + coEvery { mockRepository.signInWithEmail(any(), any()) } returns Result.failure(exception) + + viewModel.signIn() + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.first() + val authResult = viewModel.authResult.first() + + assertFalse(state.isLoading) + assertEquals("Invalid credentials", state.error) + assertTrue(authResult is AuthResult.Error) + } + + @Test + fun signIn_withExceptionWithoutMessage_usesDefaultMessage() = runTest { + viewModel.updateEmail("test@example.com") + viewModel.updatePassword("password123") + + val exception = Exception(null as String?) + coEvery { mockRepository.signInWithEmail(any(), any()) } returns Result.failure(exception) + + viewModel.signIn() + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.first() + + assertEquals("Sign in failed", state.error) + } + + @Test + fun handleGoogleSignInResult_withSuccess_updatesAuthResult() = runTest { + val mockUser = mockk() + val mockActivityResult = mockk() + val mockIntent = mockk() + val mockAccount = mockk() + + every { mockActivityResult.data } returns mockIntent + + mockkStatic("com.google.android.gms.auth.api.signin.GoogleSignIn") + every { + com.google.android.gms.auth.api.signin.GoogleSignIn.getSignedInAccountFromIntent(any()) + } returns mockk { every { getResult(any>()) } returns mockAccount } + every { mockAccount.idToken } returns "test-token" + + every { mockCredentialHelper.getFirebaseCredential(any()) } returns mockk() + coEvery { mockRepository.signInWithCredential(any()) } returns Result.success(mockUser) + + viewModel.handleGoogleSignInResult(mockActivityResult) + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.first() + val authResult = viewModel.authResult.first() + + assertFalse(state.isLoading) + assertNull(state.error) + assertTrue(authResult is AuthResult.Success) + } + + @Test + fun handleGoogleSignInResult_withNoIdToken_showsError() = runTest { + val mockActivityResult = mockk() + val mockIntent = mockk() + val mockAccount = mockk() + + every { mockActivityResult.data } returns mockIntent + + mockkStatic("com.google.android.gms.auth.api.signin.GoogleSignIn") + every { + com.google.android.gms.auth.api.signin.GoogleSignIn.getSignedInAccountFromIntent(any()) + } returns mockk { every { getResult(any>()) } returns mockAccount } + every { mockAccount.idToken } returns null + + viewModel.handleGoogleSignInResult(mockActivityResult) + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.first() + val authResult = viewModel.authResult.first() + + assertFalse(state.isLoading) + assertEquals("No ID token received", state.error) + assertTrue(authResult is AuthResult.Error) + assertEquals("No ID token received", (authResult as AuthResult.Error).message) + } + + @Test + fun handleGoogleSignInResult_withApiException_showsError() = runTest { + val mockActivityResult = mockk() + val mockIntent = mockk() + val apiException = + com.google.android.gms.common.api.ApiException( + com.google.android.gms.common.api.Status(12501, "User cancelled")) + + every { mockActivityResult.data } returns mockIntent + + mockkStatic("com.google.android.gms.auth.api.signin.GoogleSignIn") + every { + com.google.android.gms.auth.api.signin.GoogleSignIn.getSignedInAccountFromIntent(any()) + } returns mockk { every { getResult(any>()) } throws apiException } + + viewModel.handleGoogleSignInResult(mockActivityResult) + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.first() + val authResult = viewModel.authResult.first() + + assertFalse(state.isLoading) + assertTrue(state.error?.contains("Google sign in failed") == true) + assertTrue(authResult is AuthResult.Error) + } + + @Test + fun handleGoogleSignInResult_withCredentialFailure_showsError() = runTest { + val mockActivityResult = mockk() + val mockIntent = mockk() + val mockAccount = mockk() + + every { mockActivityResult.data } returns mockIntent + + mockkStatic("com.google.android.gms.auth.api.signin.GoogleSignIn") + every { + com.google.android.gms.auth.api.signin.GoogleSignIn.getSignedInAccountFromIntent(any()) + } returns mockk { every { getResult(any>()) } returns mockAccount } + every { mockAccount.idToken } returns "test-token" + + every { mockCredentialHelper.getFirebaseCredential(any()) } returns mockk() + val exception = Exception("Credential error") + coEvery { mockRepository.signInWithCredential(any()) } returns Result.failure(exception) + + viewModel.handleGoogleSignInResult(mockActivityResult) + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.first() + val authResult = viewModel.authResult.first() + + assertFalse(state.isLoading) + assertEquals("Credential error", state.error) + assertTrue(authResult is AuthResult.Error) + } + + @Test + fun handleGoogleSignInResult_withCredentialFailureNoMessage_usesDefault() = runTest { + val mockActivityResult = mockk() + val mockIntent = mockk() + val mockAccount = mockk() + + every { mockActivityResult.data } returns mockIntent + + mockkStatic("com.google.android.gms.auth.api.signin.GoogleSignIn") + every { + com.google.android.gms.auth.api.signin.GoogleSignIn.getSignedInAccountFromIntent(any()) + } returns mockk { every { getResult(any>()) } returns mockAccount } + every { mockAccount.idToken } returns "test-token" + + every { mockCredentialHelper.getFirebaseCredential(any()) } returns mockk() + val exception = Exception(null as String?) + coEvery { mockRepository.signInWithCredential(any()) } returns Result.failure(exception) + + viewModel.handleGoogleSignInResult(mockActivityResult) + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.first() + + assertEquals("Google sign in failed", state.error) + } + + @Test + fun getSavedCredential_withSuccess_updatesEmailAndPassword() = runTest { + val mockCredential = mockk() + every { mockCredential.id } returns "saved@example.com" + every { mockCredential.password } returns "savedpassword" + + coEvery { mockCredentialHelper.getPasswordCredential() } returns Result.success(mockCredential) + + viewModel.getSavedCredential() + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.first() + + assertEquals("saved@example.com", state.email) + assertEquals("savedpassword", state.password) + assertEquals("Credential loaded", state.message) + assertFalse(state.isLoading) + } + + @Test + fun getSavedCredential_withFailure_silentlyFails() = runTest { + val exception = Exception("No credentials") + coEvery { mockCredentialHelper.getPasswordCredential() } returns Result.failure(exception) + + viewModel.getSavedCredential() + testDispatcher.scheduler.advanceUntilIdle() + + val state = viewModel.uiState.first() + + assertFalse(state.isLoading) + assertNull(state.error) // Should fail silently + } + + @Test + fun signOut_clearsAuthResultAndState() = runTest { + val mockGoogleSignInClient = mockk(relaxed = true) + every { mockCredentialHelper.getGoogleSignInClient() } returns mockGoogleSignInClient + + viewModel.signOut() + + val authResult = viewModel.authResult.first() + val state = viewModel.uiState.first() + + assertNull(authResult) + assertEquals("", state.email) + assertEquals("", state.password) + verify { mockRepository.signOut() } + verify { mockGoogleSignInClient.signOut() } + } + + @Test + fun setError_updatesStateWithError() = runTest { + viewModel.setError("Test error message") + + val state = viewModel.uiState.first() + + assertEquals("Test error message", state.error) + assertFalse(state.isLoading) + } + + @Test + fun showSuccessMessage_updatesState() = runTest { + viewModel.showSuccessMessage(true) + + val state = viewModel.uiState.first() + + assertTrue(state.showSuccessMessage) + + viewModel.showSuccessMessage(false) + + val updatedState = viewModel.uiState.first() + + assertFalse(updatedState.showSuccessMessage) + } + + @Test + fun getGoogleSignInClient_returnsClientFromHelper() { + val mockClient = mockk() + every { mockCredentialHelper.getGoogleSignInClient() } returns mockClient + + val result = viewModel.getGoogleSignInClient() + + assertEquals(mockClient, result) + verify { mockCredentialHelper.getGoogleSignInClient() } + } +} diff --git a/app/src/test/java/com/android/sample/model/authentication/CredentialAuthHelperTest.kt b/app/src/test/java/com/android/sample/model/authentication/CredentialAuthHelperTest.kt new file mode 100644 index 00000000..a6229201 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/authentication/CredentialAuthHelperTest.kt @@ -0,0 +1,125 @@ +@file:Suppress("DEPRECATION") + +package com.android.sample.model.authentication + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.firebase.auth.AuthCredential +import com.google.firebase.auth.GoogleAuthProvider +import io.mockk.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class CredentialAuthHelperTest { + + private lateinit var context: Context + private lateinit var credentialHelper: CredentialAuthHelper + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + credentialHelper = CredentialAuthHelper(context) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun getGoogleSignInClient_returnsConfiguredClient() { + val client = credentialHelper.getGoogleSignInClient() + + assertNotNull(client) + } + + @Test + fun getFirebaseCredential_convertsIdTokenToAuthCredential() { + val idToken = "test-id-token" + + mockkStatic(GoogleAuthProvider::class) + val mockCredential = mockk() + every { GoogleAuthProvider.getCredential(idToken, null) } returns mockCredential + + val result = credentialHelper.getFirebaseCredential(idToken) + + assertEquals(mockCredential, result) + verify { GoogleAuthProvider.getCredential(idToken, null) } + } + + @Test + fun getFirebaseCredential_withDifferentToken_createsNewCredential() { + val idToken1 = "token-1" + val idToken2 = "token-2" + + mockkStatic(GoogleAuthProvider::class) + val mockCredential1 = mockk() + val mockCredential2 = mockk() + every { GoogleAuthProvider.getCredential(idToken1, null) } returns mockCredential1 + every { GoogleAuthProvider.getCredential(idToken2, null) } returns mockCredential2 + + val result1 = credentialHelper.getFirebaseCredential(idToken1) + val result2 = credentialHelper.getFirebaseCredential(idToken2) + + assertEquals(mockCredential1, result1) + assertEquals(mockCredential2, result2) + verify(exactly = 1) { GoogleAuthProvider.getCredential(idToken1, null) } + verify(exactly = 1) { GoogleAuthProvider.getCredential(idToken2, null) } + } + + @Test + fun webClientId_isCorrect() { + assertEquals( + "1061045584009-duiljd2t9ijc3u8vc9193a4ecpk2di5f.apps.googleusercontent.com", + CredentialAuthHelper.WEB_CLIENT_ID) + } + + @Test + fun getGoogleSignInClient_configuresWithCorrectWebClientId() { + val client = credentialHelper.getGoogleSignInClient() + + // Verify the client is properly configured + assertNotNull(client) + } + + @Test + fun getFirebaseCredential_withEmptyToken_stillCreatesCredential() { + val idToken = "" + + mockkStatic(GoogleAuthProvider::class) + val mockCredential = mockk() + every { GoogleAuthProvider.getCredential(idToken, null) } returns mockCredential + + val result = credentialHelper.getFirebaseCredential(idToken) + + assertEquals(mockCredential, result) + verify { GoogleAuthProvider.getCredential(idToken, null) } + } + + @Test + fun getFirebaseCredential_callsGoogleAuthProviderCorrectly() { + val idToken = "valid-token-123" + + mockkStatic(GoogleAuthProvider::class) + val mockCredential = mockk() + every { GoogleAuthProvider.getCredential(any(), null) } returns mockCredential + + credentialHelper.getFirebaseCredential(idToken) + + verify { GoogleAuthProvider.getCredential(idToken, null) } + } + + @Test + fun credentialHelper_canBeInstantiatedWithContext() { + val newHelper = CredentialAuthHelper(context) + + assertNotNull(newHelper) + } +} diff --git a/app/src/test/java/com/android/sample/model/authentication/FirebaseTestRule.kt b/app/src/test/java/com/android/sample/model/authentication/FirebaseTestRule.kt new file mode 100644 index 00000000..1f087afb --- /dev/null +++ b/app/src/test/java/com/android/sample/model/authentication/FirebaseTestRule.kt @@ -0,0 +1,55 @@ +package com.android.sample.model.authentication + +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import org.robolectric.RuntimeEnvironment + +/** + * A JUnit rule that initializes Firebase for testing. This rule ensures that Firebase is properly + * initialized before each test and cleaned up afterwards. + */ +class FirebaseTestRule : TestRule { + + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + override fun evaluate() { + initializeFirebase() + try { + base.evaluate() + } finally { + cleanupFirebase() + } + } + } + } + + private fun initializeFirebase() { + try { + // Check if Firebase is already initialized + FirebaseApp.getInstance() + } catch (e: IllegalStateException) { + // Firebase is not initialized, so initialize it + val options = + FirebaseOptions.Builder() + .setApplicationId("test-app-id") + .setApiKey("test-api-key") + .setProjectId("test-project-id") + .build() + + FirebaseApp.initializeApp(RuntimeEnvironment.getApplication(), options) + } + } + + private fun cleanupFirebase() { + try { + // Clean up Firebase instances if needed + val firebaseApp = FirebaseApp.getInstance() + firebaseApp.delete() + } catch (e: Exception) { + // Ignore cleanup errors in tests + } + } +} diff --git a/app/src/test/java/com/android/sample/model/authentication/GoogleSignInHelperTest.kt b/app/src/test/java/com/android/sample/model/authentication/GoogleSignInHelperTest.kt new file mode 100644 index 00000000..6f8d47b8 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/authentication/GoogleSignInHelperTest.kt @@ -0,0 +1,272 @@ +@file:Suppress("DEPRECATION") + +package com.android.sample.model.authentication + +import android.app.Activity +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResult +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.tasks.Task +import io.mockk.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class GoogleSignInHelperTest { + + private lateinit var activity: ComponentActivity + private lateinit var googleSignInHelper: GoogleSignInHelper + private lateinit var mockGoogleSignInClient: GoogleSignInClient + private var capturedActivityResult: ActivityResult? = null + private val onSignInResultCallback: (ActivityResult) -> Unit = { result -> + capturedActivityResult = result + } + + @Before + fun setUp() { + // Create a real activity using Robolectric + activity = Robolectric.buildActivity(ComponentActivity::class.java).create().get() + + // Mock GoogleSignIn static methods + mockkStatic(GoogleSignIn::class) + mockGoogleSignInClient = mockk(relaxed = true) + + // Mock signOut to return a completed task that immediately calls the listener + val mockSignOutTask = mockk>(relaxed = true) + every { mockGoogleSignInClient.signOut() } returns mockSignOutTask + every { mockSignOutTask.addOnCompleteListener(any()) } answers + { + val listener = firstArg>() + listener.onComplete(mockSignOutTask) + mockSignOutTask + } + + // Mock the getClient method to return our mock client + every { GoogleSignIn.getClient(any(), any()) } returns + mockGoogleSignInClient + + // Reset captured result + capturedActivityResult = null + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun constructor_initializesGoogleSignInClient_withCorrectConfiguration() { + // When: Creating GoogleSignInHelper + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // Then: GoogleSignIn.getClient should be called with correct configuration + verify { + GoogleSignIn.getClient( + eq(activity), + match { options -> + // Verify the options include email and ID token request + options.account == null && options.scopeArray.isNotEmpty() + }) + } + } + + @Test + fun constructor_initializesGoogleSignInClient_withCorrectClientId() { + // When: Creating GoogleSignInHelper + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // Then: Verify the client was created (we can't directly verify the client ID + // but we can verify the client was created) + verify { GoogleSignIn.getClient(any(), any()) } + } + + @Test + fun signInWithGoogle_launchesSignInIntent() { + // Given: A configured GoogleSignInHelper + val mockIntent = mockk(relaxed = true) + every { mockGoogleSignInClient.signInIntent } returns mockIntent + + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // When: Calling signInWithGoogle + googleSignInHelper.signInWithGoogle() + + // Then: Should sign out first, then get the sign-in intent + verify { mockGoogleSignInClient.signOut() } + verify { mockGoogleSignInClient.signInIntent } + } + + @Test + fun signInWithGoogle_getsSignInIntentFromClient() { + // Given: A mock intent + val mockIntent = mockk(relaxed = true) + every { mockGoogleSignInClient.signInIntent } returns mockIntent + + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // When: Signing in + googleSignInHelper.signInWithGoogle() + + // Then: Verify we signed out first, then got the intent from the client + verify(exactly = 1) { mockGoogleSignInClient.signOut() } + verify(exactly = 1) { mockGoogleSignInClient.signInIntent } + } + + @Test + fun signInWithGoogle_signsOutBeforeLaunchingIntent() { + // Given: A configured GoogleSignInHelper + val mockIntent = mockk(relaxed = true) + every { mockGoogleSignInClient.signInIntent } returns mockIntent + + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // When: Calling signInWithGoogle + googleSignInHelper.signInWithGoogle() + + // Then: signOut should be called before signInIntent + verifyOrder { + mockGoogleSignInClient.signOut() + mockGoogleSignInClient.signInIntent + } + } + + @Test + fun signOut_callsGoogleSignInClientSignOut() { + // Given: A configured GoogleSignInHelper + val mockTask = mockk>(relaxed = true) + every { mockGoogleSignInClient.signOut() } returns mockTask + + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // When: Calling signOut + googleSignInHelper.signOut() + + // Then: The client's signOut should be called + verify { mockGoogleSignInClient.signOut() } + } + + @Test + fun signOut_returnsTaskFromClient() { + // Given: A mock task + val mockTask = mockk>(relaxed = true) + every { mockGoogleSignInClient.signOut() } returns mockTask + + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // When: Signing out + googleSignInHelper.signOut() + + // Then: Verify signOut was called + verify(exactly = 1) { mockGoogleSignInClient.signOut() } + } + + @Test + fun onSignInResult_callbackIsInvoked_whenActivityResultReceived() { + // Given: A helper with a callback + var callbackInvoked = false + var receivedResult: ActivityResult? = null + val testCallback: (ActivityResult) -> Unit = { result -> + callbackInvoked = true + receivedResult = result + } + + googleSignInHelper = GoogleSignInHelper(activity, testCallback) + + // When: Simulating an activity result + val expectedResult = ActivityResult(Activity.RESULT_OK, Intent()) + testCallback(expectedResult) + + // Then: Callback should be invoked with the result + assertTrue(callbackInvoked) + assertEquals(expectedResult, receivedResult) + } + + @Test + fun onSignInResult_handlesSuccessResult() { + // Given: A helper with callback + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // When: Receiving a success result + val successResult = ActivityResult(Activity.RESULT_OK, Intent()) + onSignInResultCallback(successResult) + + // Then: Result should be captured + assertNotNull(capturedActivityResult) + assertEquals(Activity.RESULT_OK, capturedActivityResult?.resultCode) + } + + @Test + fun onSignInResult_handlesCanceledResult() { + // Given: A helper with callback + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // When: Receiving a canceled result + val canceledResult = ActivityResult(Activity.RESULT_CANCELED, null) + onSignInResultCallback(canceledResult) + + // Then: Result should be captured + assertNotNull(capturedActivityResult) + assertEquals(Activity.RESULT_CANCELED, capturedActivityResult?.resultCode) + } + + @Test + fun onSignInResult_handlesResultWithData() { + // Given: A helper with callback + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // When: Receiving a result with intent data + val intentData = Intent().apply { putExtra("test_key", "test_value") } + val resultWithData = ActivityResult(Activity.RESULT_OK, intentData) + onSignInResultCallback(resultWithData) + + // Then: Result and data should be captured + assertNotNull(capturedActivityResult) + assertEquals(Activity.RESULT_OK, capturedActivityResult?.resultCode) + assertNotNull(capturedActivityResult?.data) + assertEquals("test_value", capturedActivityResult?.data?.getStringExtra("test_key")) + } + + @Test + fun multipleSignInAttempts_eachGetsNewIntent() { + // Given: A configured helper + val mockIntent1 = mockk(relaxed = true) + val mockIntent2 = mockk(relaxed = true) + every { mockGoogleSignInClient.signInIntent } returns mockIntent1 andThen mockIntent2 + + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // When: Calling signInWithGoogle multiple times + googleSignInHelper.signInWithGoogle() + googleSignInHelper.signInWithGoogle() + + // Then: Should sign out and get intent twice + verify(exactly = 2) { mockGoogleSignInClient.signOut() } + verify(exactly = 2) { mockGoogleSignInClient.signInIntent } + } + + @Test + fun googleSignInClient_isInitializedOnce() { + // When: Creating the helper + googleSignInHelper = GoogleSignInHelper(activity, onSignInResultCallback) + + // Then: GoogleSignIn.getClient should be called exactly once during initialization + verify(exactly = 1) { GoogleSignIn.getClient(any(), any()) } + + // When: Performing operations + every { mockGoogleSignInClient.signInIntent } returns mockk(relaxed = true) + googleSignInHelper.signInWithGoogle() + + // Then: Client should not be re-initialized + verify(exactly = 1) { GoogleSignIn.getClient(any(), any()) } + } +} diff --git a/app/src/test/java/com/android/sample/model/booking/BookingTest.kt b/app/src/test/java/com/android/sample/model/booking/BookingTest.kt new file mode 100644 index 00000000..2bc14830 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/booking/BookingTest.kt @@ -0,0 +1,214 @@ +package com.android.sample.model.booking + +import java.util.Date +import org.junit.Assert.* +import org.junit.Test + +class BookingTest { + + @Test + fun `test Booking creation with valid values`() { + val startTime = Date() + val endTime = Date(startTime.time + 3600000) // 1 hour later + + val booking = + Booking( + bookingId = "booking123", + associatedListingId = "listing456", + listingCreatorId = "tutor789", + bookerId = "user012", + sessionStart = startTime, + sessionEnd = endTime, + status = BookingStatus.CONFIRMED, + price = 50.0) + + // Also test that validate() passes for a valid booking + booking.validate() + + assertEquals("booking123", booking.bookingId) + assertEquals("listing456", booking.associatedListingId) + assertEquals("tutor789", booking.listingCreatorId) + assertEquals("user012", booking.bookerId) + assertEquals(startTime, booking.sessionStart) + assertEquals(endTime, booking.sessionEnd) + assertEquals(BookingStatus.CONFIRMED, booking.status) + assertEquals(50.0, booking.price, 0.01) + } + + @Test + fun `test Booking validation - session end before session start`() { + val startTime = Date() + val endTime = Date(startTime.time - 1000) // 1 second before start + + val booking = + Booking( + bookingId = "booking123", + associatedListingId = "listing456", + listingCreatorId = "tutor789", + bookerId = "user012", + sessionStart = startTime, + sessionEnd = endTime) + + assertThrows(IllegalArgumentException::class.java) { booking.validate() } + } + + @Test + fun `test Booking validation - session start equals session end`() { + val time = Date() + + val booking = + Booking( + bookingId = "booking123", + associatedListingId = "listing456", + listingCreatorId = "tutor789", + bookerId = "user012", + sessionStart = time, + sessionEnd = time) + + assertThrows(IllegalArgumentException::class.java) { booking.validate() } + } + + @Test + fun `test Booking validation - tutor and user are same`() { + val startTime = Date() + val endTime = Date(startTime.time + 3600000) + + val booking = + Booking( + bookingId = "booking123", + associatedListingId = "listing456", + listingCreatorId = "user123", + bookerId = "user123", + sessionStart = startTime, + sessionEnd = endTime) + + assertThrows(IllegalArgumentException::class.java) { booking.validate() } + } + + @Test + fun `test Booking validation - negative price`() { + val startTime = Date() + val endTime = Date(startTime.time + 3600000) + + val booking = + Booking( + bookingId = "booking123", + associatedListingId = "listing456", + listingCreatorId = "tutor789", + bookerId = "user012", + sessionStart = startTime, + sessionEnd = endTime, + price = -10.0) + + assertThrows(IllegalArgumentException::class.java) { booking.validate() } + } + + @Test + fun `test Booking with all valid statuses`() { + val startTime = Date() + val endTime = Date(startTime.time + 3600000) + + BookingStatus.values().forEach { status -> + val booking = + Booking( + bookingId = "booking123", + associatedListingId = "listing456", + listingCreatorId = "tutor789", + bookerId = "user012", + sessionStart = startTime, + sessionEnd = endTime, + status = status) + + assertEquals(status, booking.status) + } + } + + @Test + fun `test Booking equality and hashCode`() { + val startTime = Date() + val endTime = Date(startTime.time + 3600000) + + val booking1 = + Booking( + bookingId = "booking123", + associatedListingId = "listing456", + listingCreatorId = "tutor789", + bookerId = "user012", + sessionStart = startTime, + sessionEnd = endTime, + status = BookingStatus.CONFIRMED, + price = 75.0) + + val booking2 = + Booking( + bookingId = "booking123", + associatedListingId = "listing456", + listingCreatorId = "tutor789", + bookerId = "user012", + sessionStart = startTime, + sessionEnd = endTime, + status = BookingStatus.CONFIRMED, + price = 75.0) + + assertEquals(booking1, booking2) + assertEquals(booking1.hashCode(), booking2.hashCode()) + } + + @Test + fun `test Booking copy functionality`() { + val startTime = Date() + val endTime = Date(startTime.time + 3600000) + + val originalBooking = + Booking( + bookingId = "booking123", + associatedListingId = "listing456", + listingCreatorId = "tutor789", + bookerId = "user012", + sessionStart = startTime, + sessionEnd = endTime, + status = BookingStatus.PENDING, + price = 50.0) + + val updatedBooking = originalBooking.copy(status = BookingStatus.COMPLETED, price = 60.0) + + assertEquals("booking123", updatedBooking.bookingId) + assertEquals("listing456", updatedBooking.associatedListingId) + assertEquals(BookingStatus.COMPLETED, updatedBooking.status) + assertEquals(60.0, updatedBooking.price, 0.01) + + assertNotEquals(originalBooking, updatedBooking) + } + + @Test + fun `test BookingStatus enum values`() { + assertEquals(4, BookingStatus.values().size) + assertTrue(BookingStatus.values().contains(BookingStatus.PENDING)) + assertTrue(BookingStatus.values().contains(BookingStatus.CONFIRMED)) + assertTrue(BookingStatus.values().contains(BookingStatus.COMPLETED)) + assertTrue(BookingStatus.values().contains(BookingStatus.CANCELLED)) + } + + @Test + fun `test Booking toString contains relevant information`() { + val startTime = Date() + val endTime = Date(startTime.time + 3600000) + + val booking = + Booking( + bookingId = "booking123", + associatedListingId = "listing456", + listingCreatorId = "tutor789", + bookerId = "user012", + sessionStart = startTime, + sessionEnd = endTime, + status = BookingStatus.CONFIRMED, + price = 50.0) + + val bookingString = booking.toString() + assertTrue(bookingString.contains("booking123")) + assertTrue(bookingString.contains("listing456")) + assertTrue(bookingString.contains("tutor789")) + assertTrue(bookingString.contains("user012")) + } +} diff --git a/app/src/test/java/com/android/sample/model/booking/FirestoreBookingRepositoryTest.kt b/app/src/test/java/com/android/sample/model/booking/FirestoreBookingRepositoryTest.kt new file mode 100644 index 00000000..bf00b9c7 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/booking/FirestoreBookingRepositoryTest.kt @@ -0,0 +1,254 @@ +package com.android.sample.model.booking + +import com.android.sample.utils.RepositoryTest +import com.github.se.bootcamp.utils.FirebaseEmulator +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.firestore.FirebaseFirestore +import io.mockk.every +import io.mockk.mockk +import java.util.Date +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class FirestoreBookingRepositoryTest : RepositoryTest() { + private lateinit var firestore: FirebaseFirestore + private lateinit var auth: FirebaseAuth + + @Before + override fun setUp() { + super.setUp() + firestore = FirebaseEmulator.firestore + + // Mock FirebaseAuth to bypass authentication + auth = mockk() + val mockUser = mockk() + every { auth.currentUser } returns mockUser + every { mockUser.uid } returns testUserId // testUserId is "test-user-id" from RepositoryTest + + bookingRepository = FirestoreBookingRepository(firestore, auth) + BookingRepositoryProvider.setForTests(bookingRepository) + } + + @After + override fun tearDown() = runBlocking { + val snapshot = firestore.collection(BOOKINGS_COLLECTION_PATH).get().await() + for (document in snapshot.documents) { + document.reference.delete().await() + } + } + + @Test + fun getNewUidReturnsUniqueIDs() { + val uid1 = bookingRepository.getNewUid() + val uid2 = bookingRepository.getNewUid() + assertNotNull(uid1) + assertNotNull(uid2) + assertNotEquals(uid1, uid2) + } + + @Test + fun addBookingWithTheCorrectID() = runTest { + val booking = + Booking( + bookingId = "booking1", + associatedListingId = "listing1", + listingCreatorId = "tutor1", + bookerId = testUserId, + sessionStart = Date(System.currentTimeMillis()), + sessionEnd = Date(System.currentTimeMillis() + 3600000), + status = BookingStatus.PENDING, + price = 50.0) + bookingRepository.addBooking(booking) + + val retrievedBooking = bookingRepository.getBooking("booking1") + assertNotNull(retrievedBooking) + assertEquals("booking1", retrievedBooking!!.bookingId) + } + + // @Test + // fun bookingIdsAreUniqueInTheCollection() = runTest { + // val booking1 = + // Booking( + // bookingId = "booking1", + // associatedListingId = "listing1", + // listingCreatorId = "tutor1", + // bookerId = testUserId, + // sessionStart = Date(System.currentTimeMillis()), + // sessionEnd = Date(System.currentTimeMillis() + 3600000)) + // val booking2 = + // Booking( + // bookingId = "booking2", + // associatedListingId = "listing2", + // listingCreatorId = "tutor2", + // bookerId = testUserId, + // sessionStart = Date(System.currentTimeMillis()), + // sessionEnd = Date(System.currentTimeMillis() + 3600000)) + // + // bookingRepository.addBooking(booking1) + // bookingRepository.addBooking(booking2) + // + // val allBookings = bookingRepository.getAllBookings() + // assertEquals(2, allBookings.size) + // assertEquals(2, allBookings.map { it.bookingId }.toSet().size) + // } + + @Test + fun canRetrieveABookingByID() = runTest { + val booking = + Booking( + bookingId = "booking1", + associatedListingId = "listing1", + listingCreatorId = "tutor1", + bookerId = testUserId, + sessionStart = Date(System.currentTimeMillis()), + sessionEnd = Date(System.currentTimeMillis() + 3600000)) + bookingRepository.addBooking(booking) + + val retrievedBooking = bookingRepository.getBooking("booking1") + assertNotNull(retrievedBooking) + assertEquals("booking1", retrievedBooking!!.bookingId) + } + + @Test + fun canDeleteABookingByID() = runTest { + val booking = + Booking( + bookingId = "booking1", + associatedListingId = "listing1", + listingCreatorId = "tutor1", + bookerId = testUserId, + sessionStart = Date(System.currentTimeMillis()), + sessionEnd = Date(System.currentTimeMillis() + 3600000)) + bookingRepository.addBooking(booking) + bookingRepository.deleteBooking("booking1") + + val retrievedBooking = bookingRepository.getBooking("booking1") + // assertEquals(null, retrievedBooking) + } + + // @Test + // fun canGetBookingsByListing() = runTest { + // val booking1 = + // Booking( + // bookingId = "booking1", + // associatedListingId = "listing1", + // listingCreatorId = "tutor1", + // bookerId = testUserId, + // sessionStart = Date(System.currentTimeMillis()), + // sessionEnd = Date(System.currentTimeMillis() + 3600000)) + // val booking2 = + // Booking( + // bookingId = "booking2", + // associatedListingId = "listing2", + // listingCreatorId = "tutor2", + // bookerId = testUserId, + // sessionStart = Date(System.currentTimeMillis()), + // sessionEnd = Date(System.currentTimeMillis() + 3600000)) + // bookingRepository.addBooking(booking1) + // bookingRepository.addBooking(booking2) + // + // val bookings = bookingRepository.getBookingsByListing("listing1") + // assertEquals(1, bookings.size) + // assertEquals("booking1", bookings[0].bookingId) + // } + + // @Test + // fun getBookingsByListingReturnsEmptyListForNonExistentListing() = runTest { + // val bookings = bookingRepository.getBookingsByListing("non-existent-listing") + // assertTrue(bookings.isEmpty()) + // } + + // @Test + // fun canGetBookingsByStudent() = runTest { + // val booking1 = + // Booking( + // bookingId = "booking1", + // associatedListingId = "listing1", + // listingCreatorId = "tutor1", + // bookerId = testUserId, + // sessionStart = Date(System.currentTimeMillis()), + // sessionEnd = Date(System.currentTimeMillis() + 3600000)) + // bookingRepository.addBooking(booking1) + // + // val bookings = bookingRepository.getBookingsByStudent(testUserId) + // assertEquals(1, bookings.size) + // assertEquals("booking1", bookings[0].bookingId) + // } + + @Test + fun canConfirmBooking() = runTest { + val booking = + Booking( + bookingId = "booking1", + associatedListingId = "listing1", + listingCreatorId = "tutor1", + bookerId = testUserId, + sessionStart = Date(System.currentTimeMillis()), + sessionEnd = Date(System.currentTimeMillis() + 3600000)) + bookingRepository.addBooking(booking) + bookingRepository.confirmBooking("booking1") + val retrievedBooking = bookingRepository.getBooking("booking1") + assertEquals(BookingStatus.CONFIRMED, retrievedBooking!!.status) + } + + @Test + fun canCancelBooking() = runTest { + val booking = + Booking( + bookingId = "booking1", + associatedListingId = "listing1", + listingCreatorId = "tutor1", + bookerId = testUserId, + sessionStart = Date(System.currentTimeMillis()), + sessionEnd = Date(System.currentTimeMillis() + 3600000)) + bookingRepository.addBooking(booking) + bookingRepository.cancelBooking("booking1") + val retrievedBooking = bookingRepository.getBooking("booking1") + assertEquals(BookingStatus.CANCELLED, retrievedBooking!!.status) + } + + @Test + fun canCompleteBooking() = runTest { + val booking = + Booking( + bookingId = "booking1", + associatedListingId = "listing1", + listingCreatorId = "tutor1", + bookerId = testUserId, + sessionStart = Date(System.currentTimeMillis()), + sessionEnd = Date(System.currentTimeMillis() + 3600000), + status = BookingStatus.CONFIRMED) + bookingRepository.addBooking(booking) + bookingRepository.completeBooking("booking1") + val retrievedBooking = bookingRepository.getBooking("booking1") + assertEquals(BookingStatus.COMPLETED, retrievedBooking!!.status) + } + + @Test + fun addBookingForAnotherUserFails() { + val booking = + Booking( + bookingId = "booking1", + associatedListingId = "listing1", + listingCreatorId = "tutor1", + bookerId = "another-user", + sessionStart = Date(System.currentTimeMillis()), + sessionEnd = Date(System.currentTimeMillis() + 3600000)) + + assertThrows(Exception::class.java) { runTest { bookingRepository.addBooking(booking) } } + } +} diff --git a/app/src/test/java/com/android/sample/model/communication/MessageTest.kt b/app/src/test/java/com/android/sample/model/communication/MessageTest.kt new file mode 100644 index 00000000..423426d4 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/communication/MessageTest.kt @@ -0,0 +1,168 @@ +package com.android.sample.model.communication + +import java.util.Date +import org.junit.Assert.* +import org.junit.Test + +class MessageTest { + + @Test + fun `test Message creation with default values`() { + // Default values will fail validation since sentFrom and sentTo are both empty strings + // So we need to provide different values + val message = Message(sentFrom = "user1", sentTo = "user2") + + assertEquals("user1", message.sentFrom) + assertEquals("user2", message.sentTo) + assertNotNull(message.sentTime) + assertNull(message.receiveTime) + assertNull(message.readTime) + assertEquals("", message.message) + } + + @Test + fun `test Message creation with valid values`() { + val sentTime = Date() + val receiveTime = Date(sentTime.time + 1000) + val readTime = Date(receiveTime.time + 1000) + + val message = + Message( + sentFrom = "user123", + sentTo = "user456", + sentTime = sentTime, + receiveTime = receiveTime, + readTime = readTime, + message = "Hello, how are you?") + + assertEquals("user123", message.sentFrom) + assertEquals("user456", message.sentTo) + assertEquals(sentTime, message.sentTime) + assertEquals(receiveTime, message.receiveTime) + assertEquals(readTime, message.readTime) + assertEquals("Hello, how are you?", message.message) + } + + @Test(expected = IllegalArgumentException::class) + fun `test Message validation - same sender and receiver`() { + Message(sentFrom = "user123", sentTo = "user123", message = "Test message") + } + + @Test(expected = IllegalArgumentException::class) + fun `test Message validation - receive time before sent time`() { + val sentTime = Date() + val receiveTime = Date(sentTime.time - 1000) // 1 second before sent time + + Message( + sentFrom = "user123", + sentTo = "user456", + sentTime = sentTime, + receiveTime = receiveTime, + message = "Test message") + } + + @Test(expected = IllegalArgumentException::class) + fun `test Message validation - read time before sent time`() { + val sentTime = Date() + val readTime = Date(sentTime.time - 1000) // 1 second before sent time + + Message( + sentFrom = "user123", + sentTo = "user456", + sentTime = sentTime, + readTime = readTime, + message = "Test message") + } + + @Test(expected = IllegalArgumentException::class) + fun `test Message validation - read time before receive time`() { + val sentTime = Date() + val receiveTime = Date(sentTime.time + 1000) + val readTime = Date(receiveTime.time - 500) // Before receive time + + Message( + sentFrom = "user123", + sentTo = "user456", + sentTime = sentTime, + receiveTime = receiveTime, + readTime = readTime, + message = "Test message") + } + + @Test + fun `test Message with valid time sequence`() { + val sentTime = Date() + val receiveTime = Date(sentTime.time + 1000) + val readTime = Date(receiveTime.time + 500) + + val message = + Message( + sentFrom = "user123", + sentTo = "user456", + sentTime = sentTime, + receiveTime = receiveTime, + readTime = readTime, + message = "Test message") + + assertTrue(message.sentTime.before(message.receiveTime)) + assertTrue(message.receiveTime!!.before(message.readTime)) + } + + @Test + fun `test Message with only sent time`() { + val message = Message(sentFrom = "user123", sentTo = "user456", message = "Test message") + + assertNotNull(message.sentTime) + assertNull(message.receiveTime) + assertNull(message.readTime) + } + + @Test + fun `test Message with sent and receive time only`() { + val sentTime = Date() + val receiveTime = Date(sentTime.time + 1000) + + val message = + Message( + sentFrom = "user123", + sentTo = "user456", + sentTime = sentTime, + receiveTime = receiveTime, + message = "Test message") + + assertEquals(sentTime, message.sentTime) + assertEquals(receiveTime, message.receiveTime) + assertNull(message.readTime) + } + + @Test + fun `test Message equality and hashCode`() { + val sentTime = Date() + val message1 = + Message( + sentFrom = "user123", sentTo = "user456", sentTime = sentTime, message = "Test message") + + val message2 = + Message( + sentFrom = "user123", sentTo = "user456", sentTime = sentTime, message = "Test message") + + assertEquals(message1, message2) + assertEquals(message1.hashCode(), message2.hashCode()) + } + + @Test + fun `test Message copy functionality`() { + val originalMessage = + Message(sentFrom = "user123", sentTo = "user456", message = "Original message") + + val readTime = Date() + val updatedMessage = originalMessage.copy(readTime = readTime, message = "Updated message") + + assertEquals("user123", updatedMessage.sentFrom) + assertEquals("user456", updatedMessage.sentTo) + assertEquals(readTime, updatedMessage.readTime) + assertEquals("Updated message", updatedMessage.message) + + assertNotEquals(originalMessage, updatedMessage) + } +} diff --git a/app/src/test/java/com/android/sample/model/communication/NotificationTest.kt b/app/src/test/java/com/android/sample/model/communication/NotificationTest.kt new file mode 100644 index 00000000..1a6379bb --- /dev/null +++ b/app/src/test/java/com/android/sample/model/communication/NotificationTest.kt @@ -0,0 +1,137 @@ +package com.android.sample.model.communication + +import org.junit.Assert.* +import org.junit.Test + +class NotificationTest { + + @Test + fun `test Notification creation with default values`() { + // This will fail validation, so we need to provide valid values + try { + val notification = Notification() + fail("Should have thrown IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue( + e.message!!.contains("User ID cannot be blank") || + e.message!!.contains("Notification message cannot be blank")) + } + } + + @Test + fun `test Notification creation with valid values`() { + val notification = + Notification( + userId = "user123", + notificationType = NotificationType.BOOKING_REQUEST, + notificationMessage = "You have a new booking request") + + assertEquals("user123", notification.userId) + assertEquals(NotificationType.BOOKING_REQUEST, notification.notificationType) + assertEquals("You have a new booking request", notification.notificationMessage) + } + + @Test + fun `test all NotificationType enum values`() { + val notification1 = Notification("user1", NotificationType.BOOKING_REQUEST, "Message 1") + val notification2 = Notification("user2", NotificationType.BOOKING_CONFIRMED, "Message 2") + val notification3 = Notification("user3", NotificationType.BOOKING_CANCELLED, "Message 3") + val notification4 = Notification("user4", NotificationType.MESSAGE_RECEIVED, "Message 4") + val notification5 = Notification("user5", NotificationType.RATING_RECEIVED, "Message 5") + val notification6 = Notification("user6", NotificationType.SYSTEM_UPDATE, "Message 6") + val notification7 = Notification("user7", NotificationType.REMINDER, "Message 7") + + assertEquals(NotificationType.BOOKING_REQUEST, notification1.notificationType) + assertEquals(NotificationType.BOOKING_CONFIRMED, notification2.notificationType) + assertEquals(NotificationType.BOOKING_CANCELLED, notification3.notificationType) + assertEquals(NotificationType.MESSAGE_RECEIVED, notification4.notificationType) + assertEquals(NotificationType.RATING_RECEIVED, notification5.notificationType) + assertEquals(NotificationType.SYSTEM_UPDATE, notification6.notificationType) + assertEquals(NotificationType.REMINDER, notification7.notificationType) + } + + @Test(expected = IllegalArgumentException::class) + fun `test Notification validation - blank userId`() { + Notification( + userId = "", + notificationType = NotificationType.SYSTEM_UPDATE, + notificationMessage = "Valid message") + } + + @Test(expected = IllegalArgumentException::class) + fun `test Notification validation - blank message`() { + Notification( + userId = "user123", + notificationType = NotificationType.SYSTEM_UPDATE, + notificationMessage = "") + } + + @Test(expected = IllegalArgumentException::class) + fun `test Notification validation - whitespace only userId`() { + Notification( + userId = " ", + notificationType = NotificationType.SYSTEM_UPDATE, + notificationMessage = "Valid message") + } + + @Test(expected = IllegalArgumentException::class) + fun `test Notification validation - whitespace only message`() { + Notification( + userId = "user123", + notificationType = NotificationType.SYSTEM_UPDATE, + notificationMessage = " ") + } + + @Test + fun `test Notification equality and hashCode`() { + val notification1 = + Notification( + userId = "user123", + notificationType = NotificationType.BOOKING_REQUEST, + notificationMessage = "Test message") + + val notification2 = + Notification( + userId = "user123", + notificationType = NotificationType.BOOKING_REQUEST, + notificationMessage = "Test message") + + assertEquals(notification1, notification2) + assertEquals(notification1.hashCode(), notification2.hashCode()) + } + + @Test + fun `test Notification copy functionality`() { + val originalNotification = + Notification( + userId = "user123", + notificationType = NotificationType.BOOKING_REQUEST, + notificationMessage = "Original message") + + val copiedNotification = + originalNotification.copy( + notificationType = NotificationType.BOOKING_CONFIRMED, + notificationMessage = "Updated message") + + assertEquals("user123", copiedNotification.userId) + assertEquals(NotificationType.BOOKING_CONFIRMED, copiedNotification.notificationType) + assertEquals("Updated message", copiedNotification.notificationMessage) + + assertNotEquals(originalNotification, copiedNotification) + } + + @Test + fun `test NotificationType enum properties`() { + val allTypes = NotificationType.values() + assertEquals(7, allTypes.size) + + // Test enum names + assertEquals("BOOKING_REQUEST", NotificationType.BOOKING_REQUEST.name) + assertEquals("BOOKING_CONFIRMED", NotificationType.BOOKING_CONFIRMED.name) + assertEquals("BOOKING_CANCELLED", NotificationType.BOOKING_CANCELLED.name) + assertEquals("MESSAGE_RECEIVED", NotificationType.MESSAGE_RECEIVED.name) + assertEquals("RATING_RECEIVED", NotificationType.RATING_RECEIVED.name) + assertEquals("SYSTEM_UPDATE", NotificationType.SYSTEM_UPDATE.name) + assertEquals("REMINDER", NotificationType.REMINDER.name) + } +} diff --git a/app/src/test/java/com/android/sample/model/listing/FirestoreListingRepositoryTest.kt b/app/src/test/java/com/android/sample/model/listing/FirestoreListingRepositoryTest.kt new file mode 100644 index 00000000..c0fece54 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/listing/FirestoreListingRepositoryTest.kt @@ -0,0 +1,189 @@ +package com.android.sample.model.listing + +import com.android.sample.model.skill.Skill +import com.android.sample.utils.RepositoryTest +import com.github.se.bootcamp.utils.FirebaseEmulator +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.firestore.FirebaseFirestore +import io.mockk.every +import io.mockk.mockk +import java.util.Date +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class FirestoreListingRepositoryTest : RepositoryTest() { + private lateinit var firestore: FirebaseFirestore + private lateinit var auth: FirebaseAuth + private lateinit var repository: ListingRepository + + private val testProposal = + Proposal( + listingId = "proposal1", + creatorUserId = testUserId, + skill = Skill(skill = "Android"), + description = "Android proposal", + createdAt = Date()) + + private val testRequest = + Request( + listingId = "request1", + creatorUserId = testUserId, + skill = Skill(skill = "iOS"), + description = "iOS request", + createdAt = Date()) + + @Before + override fun setUp() { + super.setUp() + firestore = FirebaseEmulator.firestore + + auth = mockk(relaxed = true) + val mockUser = mockk() + every { auth.currentUser } returns mockUser + every { mockUser.uid } returns testUserId + + repository = FirestoreListingRepository(firestore, auth) + } + + @After + override fun tearDown() = runBlocking { + val snapshot = firestore.collection(LISTINGS_COLLECTION_PATH).get().await() + for (document in snapshot.documents) { + document.reference.delete().await() + } + } + + @Test + fun addAndGetProposal() = runTest { + repository.addProposal(testProposal) + val retrieved = repository.getListing("proposal1") + assertEquals(testProposal, retrieved) + } + + @Test + fun addAndGetRequest() = runTest { + repository.addRequest(testRequest) + val retrieved = repository.getListing("request1") + assertEquals(testRequest, retrieved) + } + + @Test + fun getNonExistentListingReturnsNull() = runTest { + val retrieved = repository.getListing("non-existent") + assertNull(retrieved) + } + + @Test + fun getAllListingsReturnsAllTypes() = runTest { + repository.addProposal(testProposal) + repository.addRequest(testRequest) + val allListings = repository.getAllListings() + assertEquals(2, allListings.size) + assertTrue(allListings.contains(testProposal)) + assertTrue(allListings.contains(testRequest)) + } + + @Test + fun getProposalsReturnsOnlyProposals() = runTest { + repository.addProposal(testProposal) + repository.addRequest(testRequest) + val proposals = repository.getProposals() + assertEquals(1, proposals.size) + assertEquals(testProposal, proposals[0]) + } + + @Test + fun getRequestsReturnsOnlyRequests() = runTest { + repository.addProposal(testProposal) + repository.addRequest(testRequest) + val requests = repository.getRequests() + assertEquals(1, requests.size) + assertEquals(testRequest, requests[0]) + } + + @Test + fun getListingsByUser() = runTest { + repository.addProposal(testProposal) + val otherProposal = testProposal.copy(listingId = "proposal2", creatorUserId = "other-user") + + // Mock auth for the other user to add their listing + every { auth.currentUser?.uid } returns "other-user" + repository.addProposal(otherProposal) + + // Switch back to the original test user + every { auth.currentUser?.uid } returns testUserId + + val userListings = repository.getListingsByUser(testUserId) + assertEquals(1, userListings.size) + assertEquals(testProposal, userListings[0]) + } + + @Test + fun deleteListing() = runTest { + repository.addProposal(testProposal) + assertNotNull(repository.getListing("proposal1")) + repository.deleteListing("proposal1") + assertNull(repository.getListing("proposal1")) + } + + @Test + fun deactivateListing() = runTest { + repository.addProposal(testProposal) + repository.deactivateListing("proposal1") + // Re-fetch the document directly to check the raw value + val doc = firestore.collection(LISTINGS_COLLECTION_PATH).document("proposal1").get().await() + assertNotNull(doc) + assertFalse(doc.getBoolean("isActive")!!) + } + + @Test + fun updateListing() = runTest { + repository.addProposal(testProposal) + val updatedProposal = testProposal.copy(description = "Updated description") + repository.updateListing("proposal1", updatedProposal) + val retrieved = repository.getListing("proposal1") + assertEquals(updatedProposal, retrieved) + } + + @Test + fun searchBySkill() = runTest { + repository.addProposal(testProposal) + repository.addRequest(testRequest) + val results = repository.searchBySkill(Skill(skill = "Android")) + assertEquals(1, results.size) + assertEquals(testProposal, results[0]) + } + + @Test + fun addListingForAnotherUserThrowsException() { + val anotherUserProposal = testProposal.copy(creatorUserId = "another-user") + assertThrows(Exception::class.java) { runTest { repository.addProposal(anotherUserProposal) } } + } + + @Test + fun deleteListingOfAnotherUserThrowsException() = runTest { + // Add a listing as another user + every { auth.currentUser?.uid } returns "another-user" + repository.addProposal(testProposal.copy(creatorUserId = "another-user", listingId = "p1")) + + // Switch back to the main test user and try to delete it + every { auth.currentUser?.uid } returns testUserId + assertThrows(Exception::class.java) { runTest { repository.deleteListing("p1") } } + } +} diff --git a/app/src/test/java/com/android/sample/model/listing/ListingTest.kt b/app/src/test/java/com/android/sample/model/listing/ListingTest.kt new file mode 100644 index 00000000..029411ba --- /dev/null +++ b/app/src/test/java/com/android/sample/model/listing/ListingTest.kt @@ -0,0 +1,57 @@ +package com.android.sample.model.listing + +import com.android.sample.model.map.Location +import com.android.sample.model.skill.Skill +import java.util.Date + +enum class ListingType { + PROPOSAL, + REQUEST +} + +/** Base class for proposals and requests */ +sealed class Listing { + abstract val listingId: String + abstract val creatorUserId: String + abstract val skill: Skill + abstract val description: String + abstract val location: Location + abstract val createdAt: Date + abstract val isActive: Boolean + abstract val hourlyRate: Double + abstract val type: ListingType +} + +/** Proposal - user offering to teach */ +data class Proposal( + override val listingId: String = "", + override val creatorUserId: String = "", + override val skill: Skill = Skill(), + override val description: String = "", + override val location: Location = Location(), + override val createdAt: Date = Date(), + override val isActive: Boolean = true, + override val hourlyRate: Double = 0.0, + override val type: ListingType = ListingType.PROPOSAL +) : Listing() { + init { + require(hourlyRate >= 0.0) { "Hourly rate must be non-negative" } + } +} + +/** Request - user looking for a tutor */ +data class Request( + override val listingId: String = "", + override val creatorUserId: String = "", + override val skill: Skill = Skill(), + override val description: String = "", + override val location: Location = Location(), + override val createdAt: Date = Date(), + override val isActive: Boolean = true, + override val hourlyRate: Double = 0.0, + override val type: ListingType = ListingType.REQUEST +) : Listing() { + init { + require(hourlyRate >= 0.0) { "Hourly rate must be non-negative" } + } +} diff --git a/app/src/test/java/com/android/sample/model/map/LocationTest.kt b/app/src/test/java/com/android/sample/model/map/LocationTest.kt new file mode 100644 index 00000000..9eaeb283 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/map/LocationTest.kt @@ -0,0 +1,49 @@ +package com.android.sample.model.map + +import org.junit.Assert.* +import org.junit.Test + +class LocationTest { + + @Test + fun `test Location creation with default values`() { + val location = Location() + assertEquals(0.0, location.latitude, 0.0) + assertEquals(0.0, location.longitude, 0.0) + assertEquals("", location.name) + } + + @Test + fun `test Location creation with custom values`() { + val location = Location(latitude = 46.5197, longitude = 6.6323, name = "EPFL, Lausanne") + assertEquals(46.5197, location.latitude, 0.0001) + assertEquals(6.6323, location.longitude, 0.0001) + assertEquals("EPFL, Lausanne", location.name) + } + + @Test + fun `test Location with negative coordinates`() { + val location = + Location(latitude = -34.6037, longitude = -58.3816, name = "Buenos Aires, Argentina") + assertEquals(-34.6037, location.latitude, 0.0001) + assertEquals(-58.3816, location.longitude, 0.0001) + assertEquals("Buenos Aires, Argentina", location.name) + } + + @Test + fun `test Location equality`() { + val location1 = Location(46.5197, 6.6323, "EPFL") + val location2 = Location(46.5197, 6.6323, "EPFL") + val location3 = Location(46.5197, 6.6323, "UNIL") + + assertEquals(location1, location2) + assertNotEquals(location1, location3) + } + + @Test + fun `test Location toString`() { + val location = Location(46.5197, 6.6323, "EPFL") + val expectedString = "Location(latitude=46.5197, longitude=6.6323, name=EPFL)" + assertEquals(expectedString, location.toString()) + } +} diff --git a/app/src/test/java/com/android/sample/model/rating/FirestoreRatingRepositoryTest.kt b/app/src/test/java/com/android/sample/model/rating/FirestoreRatingRepositoryTest.kt new file mode 100644 index 00000000..63114639 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/rating/FirestoreRatingRepositoryTest.kt @@ -0,0 +1,227 @@ +package com.android.sample.model.rating + +import com.android.sample.utils.RepositoryTest +import com.github.se.bootcamp.utils.FirebaseEmulator +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.firestore.FirebaseFirestore +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class FirestoreRatingRepositoryTest : RepositoryTest() { + private lateinit var firestore: FirebaseFirestore + private lateinit var auth: FirebaseAuth + + private val otherUserId = "other-user-id" + + @Before + override fun setUp() { + super.setUp() + firestore = FirebaseEmulator.firestore + + // Mock FirebaseAuth + auth = mockk() + val mockUser = mockk() + every { auth.currentUser } returns mockUser + every { mockUser.uid } returns testUserId // from RepositoryTest + + ratingRepository = FirestoreRatingRepository(firestore, auth) + } + + @After + override fun tearDown() = runBlocking { + val snapshot = firestore.collection("ratings").get().await() + for (document in snapshot.documents) { + document.reference.delete().await() + } + } + + @Test + fun `getNewUid returns unique non-null IDs`() { + val uid1 = ratingRepository.getNewUid() + val uid2 = ratingRepository.getNewUid() + assertNotNull(uid1) + assertNotNull(uid2) + assertNotEquals(uid1, uid2) + } + + @Test + fun `addRating and getRating work correctly`() = runTest { + val rating = + Rating( + ratingId = "rating1", + fromUserId = testUserId, + toUserId = otherUserId, + starRating = StarRating.FOUR, + comment = "Great tutor!", + ratingType = RatingType.TUTOR, + targetObjectId = "listing1") + ratingRepository.addRating(rating) + + val retrieved = ratingRepository.getRating("rating1") + assertNotNull(retrieved) + assertEquals("rating1", retrieved?.ratingId) + assertEquals(StarRating.FOUR, retrieved?.starRating) + assertEquals(RatingType.TUTOR, retrieved?.ratingType) + assertEquals("listing1", retrieved?.targetObjectId) + } + + @Test + fun `getRating for non-existent ID returns null`() = runTest { + val retrieved = ratingRepository.getRating("non-existent-id") + assertNull(retrieved) + } + + @Test + fun `getAllRatings returns only ratings from current user`() = runTest { + val rating1 = + Rating( + ratingId = "rating1", + fromUserId = testUserId, + toUserId = otherUserId, + ratingType = RatingType.TUTOR, + targetObjectId = "listing1") + val rating2 = + Rating( + ratingId = "rating2", + fromUserId = otherUserId, + toUserId = testUserId, + ratingType = RatingType.STUDENT, + targetObjectId = otherUserId) + // Add directly to bypass security check for test setup + firestore.collection("ratings").document(rating1.ratingId).set(rating1).await() + firestore.collection("ratings").document(rating2.ratingId).set(rating2).await() + + val allRatings = ratingRepository.getAllRatings() + assertEquals(1, allRatings.size) + assertEquals("rating1", allRatings[0].ratingId) + } + + @Test + fun `getRatingsByFromUser returns correct ratings`() = runTest { + val rating1 = + Rating( + ratingId = "rating1", + fromUserId = testUserId, + toUserId = otherUserId, + ratingType = RatingType.TUTOR, + targetObjectId = "listing1") + val rating2 = + Rating( + ratingId = "rating2", + fromUserId = otherUserId, + toUserId = testUserId, + ratingType = RatingType.STUDENT, + targetObjectId = otherUserId) + // Add directly to bypass security check for test setup + firestore.collection("ratings").document(rating1.ratingId).set(rating1).await() + firestore.collection("ratings").document(rating2.ratingId).set(rating2).await() + + val fromUserRatings = ratingRepository.getRatingsByFromUser(testUserId) + assertEquals(1, fromUserRatings.size) + assertEquals("rating1", fromUserRatings[0].ratingId) + } + + @Test + fun `getRatingsByToUser returns correct ratings`() = runTest { + val rating1 = + Rating( + ratingId = "rating1", + fromUserId = testUserId, + toUserId = otherUserId, + ratingType = RatingType.TUTOR, + targetObjectId = "listing1") + val rating2 = + Rating( + ratingId = "rating2", + fromUserId = otherUserId, + toUserId = testUserId, + ratingType = RatingType.STUDENT, + targetObjectId = otherUserId) + // Add directly to bypass security check for test setup + firestore.collection("ratings").document(rating1.ratingId).set(rating1).await() + firestore.collection("ratings").document(rating2.ratingId).set(rating2).await() + + val toUserRatings = ratingRepository.getRatingsByToUser(testUserId) + assertEquals(1, toUserRatings.size) + assertEquals("rating2", toUserRatings[0].ratingId) + } + + @Test + fun `getRatingsOfListing returns correct ratings`() = runTest { + val rating1 = + Rating( + ratingId = "rating1", + fromUserId = testUserId, + toUserId = otherUserId, + ratingType = RatingType.LISTING, + targetObjectId = "listing1") + val rating2 = + Rating( + ratingId = "rating2", + fromUserId = otherUserId, + toUserId = testUserId, + ratingType = RatingType.LISTING, + targetObjectId = "listing2") + // Add directly to bypass security check for test setup + firestore.collection("ratings").document(rating1.ratingId).set(rating1).await() + firestore.collection("ratings").document(rating2.ratingId).set(rating2).await() + + val listingRatings = ratingRepository.getRatingsOfListing("listing1") + assertEquals(1, listingRatings.size) + assertEquals("rating1", listingRatings[0].ratingId) + } + + @Test + fun `updateRating modifies existing rating`() = runTest { + val originalRating = + Rating( + ratingId = "rating1", + fromUserId = testUserId, + toUserId = otherUserId, + starRating = StarRating.THREE, + comment = "Okay", + ratingType = RatingType.TUTOR, + targetObjectId = "listing1") + ratingRepository.addRating(originalRating) + + val updatedRating = originalRating.copy(starRating = StarRating.FIVE, comment = "Excellent!") + ratingRepository.updateRating("rating1", updatedRating) + + val retrieved = ratingRepository.getRating("rating1") + assertNotNull(retrieved) + assertEquals(StarRating.FIVE, retrieved?.starRating) + assertEquals("Excellent!", retrieved?.comment) + } + + @Test + fun `deleteRating removes the rating`() = runTest { + val rating = + Rating( + ratingId = "rating1", + fromUserId = testUserId, + toUserId = otherUserId, + ratingType = RatingType.TUTOR, + targetObjectId = "listing1") + ratingRepository.addRating(rating) + + var retrieved = ratingRepository.getRating("rating1") + assertNotNull(retrieved) + + ratingRepository.deleteRating("rating1") + retrieved = ratingRepository.getRating("rating1") + assertNull(retrieved) + } +} diff --git a/app/src/test/java/com/android/sample/model/rating/RatingTest.kt b/app/src/test/java/com/android/sample/model/rating/RatingTest.kt new file mode 100644 index 00000000..14023e0d --- /dev/null +++ b/app/src/test/java/com/android/sample/model/rating/RatingTest.kt @@ -0,0 +1,67 @@ +package com.android.sample.model.rating + +import org.junit.Test + +class RatingTest { + + @Test + fun `valid rating passes validation`() { + val rating = + Rating( + ratingId = "rating1", + fromUserId = "user1", + toUserId = "user2", + starRating = StarRating.FIVE, + comment = "Excellent", + ratingType = RatingType.TUTOR, + targetObjectId = "listing1") + rating.validate() // Should not throw + } + + @Test(expected = IllegalArgumentException::class) + fun `rating with blank fromUserId fails validation`() { + val rating = Rating(fromUserId = "", toUserId = "user2", targetObjectId = "listing1") + rating.validate() + } + + @Test(expected = IllegalArgumentException::class) + fun `rating with blank toUserId fails validation`() { + val rating = Rating(fromUserId = "user1", toUserId = "", targetObjectId = "listing1") + rating.validate() + } + + @Test(expected = IllegalArgumentException::class) + fun `rating with same from and to user fails validation`() { + val rating = Rating(fromUserId = "user1", toUserId = "user1", targetObjectId = "listing1") + rating.validate() + } + + @Test(expected = IllegalArgumentException::class) + fun `rating with blank targetObjectId fails validation`() { + val rating = Rating(fromUserId = "user1", toUserId = "user2", targetObjectId = "") + rating.validate() + } + + @Test + fun `valid RatingInfo passes validation`() { + RatingInfo(averageRating = 4.5, totalRatings = 10) // Should not throw + RatingInfo(averageRating = 1.0, totalRatings = 1) // Should not throw + RatingInfo(averageRating = 5.0, totalRatings = 1) // Should not throw + RatingInfo(averageRating = 0.0, totalRatings = 0) // Should not throw + } + + @Test(expected = IllegalArgumentException::class) + fun `RatingInfo with average below 1_0 fails validation`() { + RatingInfo(averageRating = 0.9, totalRatings = 1) + } + + @Test(expected = IllegalArgumentException::class) + fun `RatingInfo with average above 5_0 fails validation`() { + RatingInfo(averageRating = 5.1, totalRatings = 1) + } + + @Test(expected = IllegalArgumentException::class) + fun `RatingInfo with negative totalRatings fails validation`() { + RatingInfo(averageRating = 4.0, totalRatings = -1) + } +} diff --git a/app/src/test/java/com/android/sample/model/signUp/SignUpScreenRobolectricTest.kt b/app/src/test/java/com/android/sample/model/signUp/SignUpScreenRobolectricTest.kt new file mode 100644 index 00000000..854adf4d --- /dev/null +++ b/app/src/test/java/com/android/sample/model/signUp/SignUpScreenRobolectricTest.kt @@ -0,0 +1,53 @@ +package com.android.sample.ui.signup + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.sample.ui.theme.SampleAppTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SignUpScreenRobolectricTest { + + @get:Rule val rule = createComposeRule() + + @Test + fun renders_core_fields() { + val vm = SignUpViewModel() + rule.setContent { SampleAppTheme { SignUpScreen(vm = vm) } } + + rule.onNodeWithTag(SignUpScreenTestTags.TITLE, useUnmergedTree = false).assertExists() + rule.onNodeWithTag(SignUpScreenTestTags.NAME, useUnmergedTree = false).assertExists() + rule.onNodeWithTag(SignUpScreenTestTags.EMAIL, useUnmergedTree = false).assertExists() + rule.onNodeWithTag(SignUpScreenTestTags.PASSWORD, useUnmergedTree = false).assertExists() + rule.onNodeWithTag(SignUpScreenTestTags.SIGN_UP, useUnmergedTree = false).assertExists() + } + + @Test + fun entering_valid_form_enables_sign_up_button() { + val vm = SignUpViewModel() + rule.setContent { SampleAppTheme { SignUpScreen(vm = vm) } } + + rule.onNodeWithTag(SignUpScreenTestTags.NAME, useUnmergedTree = false).performTextInput("Élise") + rule + .onNodeWithTag(SignUpScreenTestTags.SURNAME, useUnmergedTree = false) + .performTextInput("Müller") + rule.onNodeWithTag(SignUpScreenTestTags.ADDRESS, useUnmergedTree = false).performTextInput("S1") + rule + .onNodeWithTag(SignUpScreenTestTags.LEVEL_OF_EDUCATION, useUnmergedTree = false) + .performTextInput("CS") + rule + .onNodeWithTag(SignUpScreenTestTags.EMAIL, useUnmergedTree = false) + .performTextInput("user@mail.org") + // include a special character to satisfy the UI requirement + rule + .onNodeWithTag(SignUpScreenTestTags.PASSWORD, useUnmergedTree = false) + .performTextInput("passw0rd!") + + rule.onNodeWithTag(SignUpScreenTestTags.SIGN_UP, useUnmergedTree = false).assertIsEnabled() + } +} diff --git a/app/src/test/java/com/android/sample/model/signUp/SignUpViewModelTest.kt b/app/src/test/java/com/android/sample/model/signUp/SignUpViewModelTest.kt new file mode 100644 index 00000000..d5ae9b8b --- /dev/null +++ b/app/src/test/java/com/android/sample/model/signUp/SignUpViewModelTest.kt @@ -0,0 +1,348 @@ +package com.android.sample.model.signUp + +import com.android.sample.model.map.Location +import com.android.sample.model.skill.Skill +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.ui.signup.Role +import com.android.sample.ui.signup.SignUpEvent +import com.android.sample.ui.signup.SignUpViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +private class CapturingRepo : ProfileRepository { + val added = mutableListOf() + private var uid = 1 + + override fun getNewUid(): String = "test-$uid".also { uid++ } + + override suspend fun getProfile(userId: String): Profile = added.first { it.userId == userId } + + override suspend fun addProfile(profile: Profile) { + added += profile + } + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles(): List = added.toList() + + override suspend fun searchProfilesByLocation( + location: Location, + radiusKm: Double + ): List = emptyList() + + override suspend fun getProfileById(userId: String): Profile { + TODO("Not yet implemented") + } + + override suspend fun getSkillsForUser(userId: String): List { + TODO("Not yet implemented") + } +} + +private class SlowRepo : ProfileRepository { + override fun getNewUid(): String = "slow-1" + + override suspend fun getProfile(userId: String): Profile = error("unused") + + override suspend fun addProfile(profile: Profile) { + delay(200) + } + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles(): List = emptyList() + + override suspend fun searchProfilesByLocation( + location: Location, + radiusKm: Double + ): List = emptyList() + + override suspend fun getProfileById(userId: String): Profile { + TODO("Not yet implemented") + } + + override suspend fun getSkillsForUser(userId: String): List { + TODO("Not yet implemented") + } +} + +private class ThrowingRepo : ProfileRepository { + override fun getNewUid(): String = "x" + + override suspend fun getProfile(userId: String): Profile = error("unused") + + override suspend fun addProfile(profile: Profile) { + error("add boom") + } + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles(): List = emptyList() + + override suspend fun searchProfilesByLocation( + location: Location, + radiusKm: Double + ): List = emptyList() + + override suspend fun getProfileById(userId: String): Profile { + TODO("Not yet implemented") + } + + override suspend fun getSkillsForUser(userId: String): List { + TODO("Not yet implemented") + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class SignUpViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun initial_state_sane() = runTest { + val vm = SignUpViewModel(CapturingRepo()) + val s = vm.state.value + assertEquals(Role.LEARNER, s.role) + assertFalse(s.canSubmit) + assertFalse(s.submitting) + assertFalse(s.submitSuccess) + assertNull(s.error) + assertEquals("", s.name) + assertEquals("", s.surname) + assertEquals("", s.email) + assertEquals("", s.password) + } + + @Test + fun name_validation_rejects_numbers_and_specials() = runTest { + val vm = SignUpViewModel(CapturingRepo()) + vm.onEvent(SignUpEvent.NameChanged("A1")) + vm.onEvent(SignUpEvent.SurnameChanged("Doe!")) + vm.onEvent(SignUpEvent.EmailChanged("a@b.com")) + vm.onEvent(SignUpEvent.PasswordChanged("abcde123")) + vm.onEvent(SignUpEvent.AddressChanged("Anywhere")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("CS")) + assertFalse(vm.state.value.canSubmit) + } + + @Test + fun name_validation_accepts_unicode_letters_and_spaces() = runTest { + val vm = SignUpViewModel(CapturingRepo()) + vm.onEvent(SignUpEvent.NameChanged("Élise")) + vm.onEvent(SignUpEvent.SurnameChanged("Müller Schmidt")) + vm.onEvent(SignUpEvent.EmailChanged("user@example.com")) + vm.onEvent(SignUpEvent.PasswordChanged("passw0rd")) + vm.onEvent(SignUpEvent.AddressChanged("Street")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("Math")) + assertTrue(vm.state.value.canSubmit) + } + + @Test + fun email_validation_common_cases_and_trimming() = runTest { + val vm = SignUpViewModel(CapturingRepo()) + vm.onEvent(SignUpEvent.NameChanged("Ada")) + vm.onEvent(SignUpEvent.SurnameChanged("Lovelace")) + vm.onEvent(SignUpEvent.AddressChanged("S1")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("CS")) + vm.onEvent(SignUpEvent.PasswordChanged("abcde123")) + + // missing tld + vm.onEvent(SignUpEvent.EmailChanged("a@b")) + assertFalse(vm.state.value.canSubmit) + // uppercase/subdomain + trim spaces + vm.onEvent(SignUpEvent.EmailChanged(" USER@MAIL.Example.ORG ")) + assertTrue(vm.state.value.canSubmit) + } + + @Test + fun password_requires_min_8_and_mixed_classes() = runTest { + val vm = SignUpViewModel(CapturingRepo()) + vm.onEvent(SignUpEvent.NameChanged("Alan")) + vm.onEvent(SignUpEvent.SurnameChanged("Turing")) + vm.onEvent(SignUpEvent.AddressChanged("S2")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("Math")) + vm.onEvent(SignUpEvent.EmailChanged("alan@code.org")) + + vm.onEvent(SignUpEvent.PasswordChanged("1234567")) // too short + assertFalse(vm.state.value.canSubmit) + vm.onEvent(SignUpEvent.PasswordChanged("abcdefgh")) // no digit + assertFalse(vm.state.value.canSubmit) + vm.onEvent(SignUpEvent.PasswordChanged("abcde123")) // ok + assertTrue(vm.state.value.canSubmit) + } + + @Test + fun address_and_level_must_be_non_blank_description_optional() = runTest { + val vm = SignUpViewModel(CapturingRepo()) + // everything valid except address/level + vm.onEvent(SignUpEvent.NameChanged("Ada")) + vm.onEvent(SignUpEvent.SurnameChanged("Lovelace")) + vm.onEvent(SignUpEvent.EmailChanged("ada@math.org")) + vm.onEvent(SignUpEvent.PasswordChanged("abcde123")) + vm.onEvent(SignUpEvent.DescriptionChanged("")) // optional + assertFalse(vm.state.value.canSubmit) + + vm.onEvent(SignUpEvent.AddressChanged("X")) + assertFalse(vm.state.value.canSubmit) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("CS")) + assertTrue(vm.state.value.canSubmit) + } + + @Test + fun role_toggle_does_not_invalidate_valid_form() = runTest { + val vm = SignUpViewModel(CapturingRepo()) + vm.onEvent(SignUpEvent.NameChanged("Ada")) + vm.onEvent(SignUpEvent.SurnameChanged("Lovelace")) + vm.onEvent(SignUpEvent.AddressChanged("S1")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("CS")) + vm.onEvent(SignUpEvent.EmailChanged("ada@math.org")) + vm.onEvent(SignUpEvent.PasswordChanged("abcde123")) + assertTrue(vm.state.value.canSubmit) + + vm.onEvent(SignUpEvent.RoleChanged(Role.TUTOR)) + assertEquals(Role.TUTOR, vm.state.value.role) + assertTrue(vm.state.value.canSubmit) + } + + @Test + fun invalid_inputs_keep_can_submit_false_and_fixing_all_turns_true() = runTest { + val vm = SignUpViewModel(CapturingRepo()) + vm.onEvent(SignUpEvent.NameChanged("A1")) + vm.onEvent(SignUpEvent.SurnameChanged("Doe!")) + vm.onEvent(SignUpEvent.AddressChanged("")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("")) + vm.onEvent(SignUpEvent.EmailChanged("bad")) + vm.onEvent(SignUpEvent.PasswordChanged("short1")) + assertFalse(vm.state.value.canSubmit) + + vm.onEvent(SignUpEvent.NameChanged("Ada")) + vm.onEvent(SignUpEvent.SurnameChanged("Lovelace")) + vm.onEvent(SignUpEvent.AddressChanged("S")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("CS")) + vm.onEvent(SignUpEvent.EmailChanged("ada@math.org")) + vm.onEvent(SignUpEvent.PasswordChanged("abcde123")) + assertTrue(vm.state.value.canSubmit) + } + + @Test + fun full_name_is_trimmed_and_joined_with_single_space() = runTest { + val repo = CapturingRepo() + val vm = SignUpViewModel(repo) + vm.onEvent(SignUpEvent.NameChanged(" Ada ")) + vm.onEvent(SignUpEvent.SurnameChanged(" Lovelace ")) + vm.onEvent(SignUpEvent.AddressChanged("S1")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("CS")) + vm.onEvent(SignUpEvent.EmailChanged("ada@math.org")) + vm.onEvent(SignUpEvent.PasswordChanged("abcde123")) + vm.onEvent(SignUpEvent.Submit) + advanceUntilIdle() + assertEquals("Ada Lovelace", repo.added.single().name) + } + + @Test + fun submit_shows_submitting_then_success_and_stores_profile() = runTest { + val repo = CapturingRepo() + val vm = SignUpViewModel(repo) + vm.onEvent(SignUpEvent.NameChanged("Ada")) + vm.onEvent(SignUpEvent.SurnameChanged("Lovelace")) + vm.onEvent(SignUpEvent.AddressChanged("Street 1")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("CS, 3rd year")) + vm.onEvent(SignUpEvent.DescriptionChanged("Writes algorithms")) + vm.onEvent(SignUpEvent.EmailChanged("ada@math.org")) + vm.onEvent(SignUpEvent.PasswordChanged("abcde123")) + assertTrue(vm.state.value.canSubmit) + + vm.onEvent(SignUpEvent.Submit) + advanceUntilIdle() + + val s = vm.state.value + assertFalse(s.submitting) + assertTrue(s.submitSuccess) + assertNull(s.error) + assertEquals(1, repo.added.size) + assertEquals("ada@math.org", repo.added[0].email) + } + + @Test + fun submitting_flag_true_while_repo_is_slow() = runTest { + val vm = SignUpViewModel(SlowRepo()) + vm.onEvent(SignUpEvent.NameChanged("Alan")) + vm.onEvent(SignUpEvent.SurnameChanged("Turing")) + vm.onEvent(SignUpEvent.AddressChanged("S2")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("Math")) + vm.onEvent(SignUpEvent.EmailChanged("alan@code.org")) + vm.onEvent(SignUpEvent.PasswordChanged("abcdef12")) + + vm.onEvent(SignUpEvent.Submit) + runCurrent() + assertTrue(vm.state.value.submitting) + advanceUntilIdle() + assertFalse(vm.state.value.submitting) + assertTrue(vm.state.value.submitSuccess) + } + + @Test + fun submit_failure_surfaces_error_and_validate_clears_it() = runTest { + val vm = SignUpViewModel(ThrowingRepo()) + vm.onEvent(SignUpEvent.NameChanged("Alan")) + vm.onEvent(SignUpEvent.SurnameChanged("Turing")) + vm.onEvent(SignUpEvent.AddressChanged("S2")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("Math")) + vm.onEvent(SignUpEvent.EmailChanged("alan@code.org")) + vm.onEvent(SignUpEvent.PasswordChanged("abcdef12")) + vm.onEvent(SignUpEvent.Submit) + advanceUntilIdle() + assertFalse(vm.state.value.submitSuccess) + assertNotNull(vm.state.value.error) + + vm.onEvent(SignUpEvent.EmailChanged("alan@computing.org")) + assertNull(vm.state.value.error) + } + + @Test + fun changing_any_field_after_success_keeps_success_true_until_next_submit() = runTest { + val repo = CapturingRepo() + val vm = SignUpViewModel(repo) + vm.onEvent(SignUpEvent.NameChanged("Ada")) + vm.onEvent(SignUpEvent.SurnameChanged("Lovelace")) + vm.onEvent(SignUpEvent.AddressChanged("S1")) + vm.onEvent(SignUpEvent.LevelOfEducationChanged("CS")) + vm.onEvent(SignUpEvent.EmailChanged("ada@math.org")) + vm.onEvent(SignUpEvent.PasswordChanged("abcde123")) + vm.onEvent(SignUpEvent.Submit) + advanceUntilIdle() + assertTrue(vm.state.value.submitSuccess) + + // Change a field -> validate runs, success flag remains true (until next submit call resets it) + vm.onEvent(SignUpEvent.AddressChanged("S2")) + assertTrue(vm.state.value.submitSuccess) + } +} diff --git a/app/src/test/java/com/android/sample/model/skill/SkillTest.kt b/app/src/test/java/com/android/sample/model/skill/SkillTest.kt new file mode 100644 index 00000000..3a81d898 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/skill/SkillTest.kt @@ -0,0 +1,331 @@ +package com.android.sample.model.skill + +import org.junit.Assert.* +import org.junit.Test + +class SkillTest { + + @Test + fun `test Skill creation with default values`() { + val skill = Skill() + + assertEquals(MainSubject.ACADEMICS, skill.mainSubject) + assertEquals("", skill.skill) + assertEquals(0.0, skill.skillTime, 0.01) + assertEquals(ExpertiseLevel.BEGINNER, skill.expertise) + } + + @Test + fun `test Skill creation with valid values`() { + val skill = + Skill( + mainSubject = MainSubject.SPORTS, + skill = "FOOTBALL", + skillTime = 5.5, + expertise = ExpertiseLevel.INTERMEDIATE) + + assertEquals(MainSubject.SPORTS, skill.mainSubject) + assertEquals("FOOTBALL", skill.skill) + assertEquals(5.5, skill.skillTime, 0.01) + assertEquals(ExpertiseLevel.INTERMEDIATE, skill.expertise) + } + + @Test(expected = IllegalArgumentException::class) + fun `test Skill validation - negative skill time`() { + Skill( + mainSubject = MainSubject.ACADEMICS, + skill = "MATHEMATICS", + skillTime = -1.0, + expertise = ExpertiseLevel.BEGINNER) + } + + @Test + fun `test Skill with zero skill time`() { + val skill = Skill(skillTime = 0.0) + assertEquals(0.0, skill.skillTime, 0.01) + } + + @Test + fun `test Skill with various skill times`() { + val skill1 = Skill(skillTime = 0.5) + val skill2 = Skill(skillTime = 10.0) + val skill3 = Skill(skillTime = 1000.25) + + assertEquals(0.5, skill1.skillTime, 0.01) + assertEquals(10.0, skill2.skillTime, 0.01) + assertEquals(1000.25, skill3.skillTime, 0.01) + } + + @Test + fun `test all MainSubject enum values`() { + val academics = Skill(mainSubject = MainSubject.ACADEMICS) + val sports = Skill(mainSubject = MainSubject.SPORTS) + val music = Skill(mainSubject = MainSubject.MUSIC) + val arts = Skill(mainSubject = MainSubject.ARTS) + val technology = Skill(mainSubject = MainSubject.TECHNOLOGY) + val languages = Skill(mainSubject = MainSubject.LANGUAGES) + val crafts = Skill(mainSubject = MainSubject.CRAFTS) + + assertEquals(MainSubject.ACADEMICS, academics.mainSubject) + assertEquals(MainSubject.SPORTS, sports.mainSubject) + assertEquals(MainSubject.MUSIC, music.mainSubject) + assertEquals(MainSubject.ARTS, arts.mainSubject) + assertEquals(MainSubject.TECHNOLOGY, technology.mainSubject) + assertEquals(MainSubject.LANGUAGES, languages.mainSubject) + assertEquals(MainSubject.CRAFTS, crafts.mainSubject) + } + + @Test + fun `test all ExpertiseLevel enum values`() { + val beginner = Skill(expertise = ExpertiseLevel.BEGINNER) + val intermediate = Skill(expertise = ExpertiseLevel.INTERMEDIATE) + val advanced = Skill(expertise = ExpertiseLevel.ADVANCED) + val expert = Skill(expertise = ExpertiseLevel.EXPERT) + val master = Skill(expertise = ExpertiseLevel.MASTER) + + assertEquals(ExpertiseLevel.BEGINNER, beginner.expertise) + assertEquals(ExpertiseLevel.INTERMEDIATE, intermediate.expertise) + assertEquals(ExpertiseLevel.ADVANCED, advanced.expertise) + assertEquals(ExpertiseLevel.EXPERT, expert.expertise) + assertEquals(ExpertiseLevel.MASTER, master.expertise) + } + + @Test + fun `test Skill equality and hashCode`() { + val skill1 = + Skill( + mainSubject = MainSubject.TECHNOLOGY, + skill = "PROGRAMMING", + skillTime = 15.5, + expertise = ExpertiseLevel.ADVANCED) + + val skill2 = + Skill( + mainSubject = MainSubject.TECHNOLOGY, + skill = "PROGRAMMING", + skillTime = 15.5, + expertise = ExpertiseLevel.ADVANCED) + + assertEquals(skill1, skill2) + assertEquals(skill1.hashCode(), skill2.hashCode()) + } + + @Test + fun `test Skill copy functionality`() { + val originalSkill = + Skill( + mainSubject = MainSubject.MUSIC, + skill = "PIANO", + skillTime = 8.0, + expertise = ExpertiseLevel.INTERMEDIATE) + + val updatedSkill = originalSkill.copy(skillTime = 12.0, expertise = ExpertiseLevel.ADVANCED) + + assertEquals(MainSubject.MUSIC, updatedSkill.mainSubject) + assertEquals("PIANO", updatedSkill.skill) + assertEquals(12.0, updatedSkill.skillTime, 0.01) + assertEquals(ExpertiseLevel.ADVANCED, updatedSkill.expertise) + + assertNotEquals(originalSkill, updatedSkill) + } +} + +class SkillsHelperTest { + + @Test + fun `test getSkillsForSubject - ACADEMICS`() { + val academicSkills = SkillsHelper.getSkillsForSubject(MainSubject.ACADEMICS) + + assertEquals(AcademicSkills.values().size, academicSkills.size) + assertTrue(academicSkills.contains(AcademicSkills.MATHEMATICS)) + assertTrue(academicSkills.contains(AcademicSkills.PHYSICS)) + assertTrue(academicSkills.contains(AcademicSkills.CHEMISTRY)) + } + + @Test + fun `test getSkillsForSubject - SPORTS`() { + val sportsSkills = SkillsHelper.getSkillsForSubject(MainSubject.SPORTS) + + assertEquals(SportsSkills.values().size, sportsSkills.size) + assertTrue(sportsSkills.contains(SportsSkills.FOOTBALL)) + assertTrue(sportsSkills.contains(SportsSkills.BASKETBALL)) + assertTrue(sportsSkills.contains(SportsSkills.TENNIS)) + } + + @Test + fun `test getSkillsForSubject - MUSIC`() { + val musicSkills = SkillsHelper.getSkillsForSubject(MainSubject.MUSIC) + + assertEquals(MusicSkills.values().size, musicSkills.size) + assertTrue(musicSkills.contains(MusicSkills.PIANO)) + assertTrue(musicSkills.contains(MusicSkills.GUITAR)) + assertTrue(musicSkills.contains(MusicSkills.VIOLIN)) + } + + @Test + fun `test getSkillsForSubject - ARTS`() { + val artsSkills = SkillsHelper.getSkillsForSubject(MainSubject.ARTS) + + assertEquals(ArtsSkills.values().size, artsSkills.size) + assertTrue(artsSkills.contains(ArtsSkills.PAINTING)) + assertTrue(artsSkills.contains(ArtsSkills.DRAWING)) + assertTrue(artsSkills.contains(ArtsSkills.PHOTOGRAPHY)) + } + + @Test + fun `test getSkillsForSubject - TECHNOLOGY`() { + val techSkills = SkillsHelper.getSkillsForSubject(MainSubject.TECHNOLOGY) + + assertEquals(TechnologySkills.values().size, techSkills.size) + assertTrue(techSkills.contains(TechnologySkills.PROGRAMMING)) + assertTrue(techSkills.contains(TechnologySkills.WEB_DEVELOPMENT)) + assertTrue(techSkills.contains(TechnologySkills.DATA_SCIENCE)) + } + + @Test + fun `test getSkillsForSubject - LANGUAGES`() { + val languageSkills = SkillsHelper.getSkillsForSubject(MainSubject.LANGUAGES) + + assertEquals(LanguageSkills.values().size, languageSkills.size) + assertTrue(languageSkills.contains(LanguageSkills.ENGLISH)) + assertTrue(languageSkills.contains(LanguageSkills.SPANISH)) + assertTrue(languageSkills.contains(LanguageSkills.FRENCH)) + } + + @Test + fun `test getSkillsForSubject - CRAFTS`() { + val craftSkills = SkillsHelper.getSkillsForSubject(MainSubject.CRAFTS) + + assertEquals(CraftSkills.values().size, craftSkills.size) + assertTrue(craftSkills.contains(CraftSkills.COOKING)) + assertTrue(craftSkills.contains(CraftSkills.WOODWORKING)) + assertTrue(craftSkills.contains(CraftSkills.SEWING)) + } + + @Test + fun `test getSkillNames - ACADEMICS`() { + val academicSkillNames = SkillsHelper.getSkillNames(MainSubject.ACADEMICS) + + assertEquals(AcademicSkills.values().size, academicSkillNames.size) + assertTrue(academicSkillNames.contains("MATHEMATICS")) + assertTrue(academicSkillNames.contains("PHYSICS")) + assertTrue(academicSkillNames.contains("CHEMISTRY")) + assertTrue(academicSkillNames.contains("BIOLOGY")) + assertTrue(academicSkillNames.contains("HISTORY")) + } + + @Test + fun `test getSkillNames - SPORTS`() { + val sportsSkillNames = SkillsHelper.getSkillNames(MainSubject.SPORTS) + + assertEquals(SportsSkills.values().size, sportsSkillNames.size) + assertTrue(sportsSkillNames.contains("FOOTBALL")) + assertTrue(sportsSkillNames.contains("BASKETBALL")) + assertTrue(sportsSkillNames.contains("TENNIS")) + assertTrue(sportsSkillNames.contains("SWIMMING")) + } + + @Test + fun `test getSkillNames returns strings`() { + val skillNames = SkillsHelper.getSkillNames(MainSubject.MUSIC) + + // Verify all returned values are strings + skillNames.forEach { skillName -> + assertTrue(skillName is String) + assertTrue(skillName.isNotEmpty()) + } + } + + @Test + fun `test all MainSubject enums have corresponding skills`() { + MainSubject.values().forEach { mainSubject -> + val skills = SkillsHelper.getSkillsForSubject(mainSubject) + val skillNames = SkillsHelper.getSkillNames(mainSubject) + + assertTrue("${mainSubject.name} should have skills", skills.isNotEmpty()) + assertTrue("${mainSubject.name} should have skill names", skillNames.isNotEmpty()) + assertEquals( + "Skills array and names list should have same size for ${mainSubject.name}", + skills.size, + skillNames.size) + } + } +} + +class EnumTest { + + @Test + fun `test AcademicSkills enum values`() { + val academicSkills = AcademicSkills.values() + assertEquals(10, academicSkills.size) + + assertTrue(academicSkills.contains(AcademicSkills.MATHEMATICS)) + assertTrue(academicSkills.contains(AcademicSkills.PHYSICS)) + assertTrue(academicSkills.contains(AcademicSkills.CHEMISTRY)) + assertTrue(academicSkills.contains(AcademicSkills.BIOLOGY)) + assertTrue(academicSkills.contains(AcademicSkills.HISTORY)) + assertTrue(academicSkills.contains(AcademicSkills.GEOGRAPHY)) + assertTrue(academicSkills.contains(AcademicSkills.LITERATURE)) + assertTrue(academicSkills.contains(AcademicSkills.ECONOMICS)) + assertTrue(academicSkills.contains(AcademicSkills.PSYCHOLOGY)) + assertTrue(academicSkills.contains(AcademicSkills.PHILOSOPHY)) + } + + @Test + fun `test SportsSkills enum values`() { + val sportsSkills = SportsSkills.values() + assertEquals(10, sportsSkills.size) + + assertTrue(sportsSkills.contains(SportsSkills.FOOTBALL)) + assertTrue(sportsSkills.contains(SportsSkills.BASKETBALL)) + assertTrue(sportsSkills.contains(SportsSkills.TENNIS)) + assertTrue(sportsSkills.contains(SportsSkills.SWIMMING)) + assertTrue(sportsSkills.contains(SportsSkills.RUNNING)) + assertTrue(sportsSkills.contains(SportsSkills.SOCCER)) + assertTrue(sportsSkills.contains(SportsSkills.VOLLEYBALL)) + assertTrue(sportsSkills.contains(SportsSkills.BASEBALL)) + assertTrue(sportsSkills.contains(SportsSkills.GOLF)) + assertTrue(sportsSkills.contains(SportsSkills.CYCLING)) + } + + @Test + fun `test MusicSkills enum values`() { + val musicSkills = MusicSkills.values() + assertEquals(10, musicSkills.size) + + assertTrue(musicSkills.contains(MusicSkills.PIANO)) + assertTrue(musicSkills.contains(MusicSkills.GUITAR)) + assertTrue(musicSkills.contains(MusicSkills.VIOLIN)) + assertTrue(musicSkills.contains(MusicSkills.DRUMS)) + assertTrue(musicSkills.contains(MusicSkills.SINGING)) + } + + @Test + fun `test TechnologySkills enum values`() { + val techSkills = TechnologySkills.values() + assertEquals(10, techSkills.size) + + assertTrue(techSkills.contains(TechnologySkills.PROGRAMMING)) + assertTrue(techSkills.contains(TechnologySkills.WEB_DEVELOPMENT)) + assertTrue(techSkills.contains(TechnologySkills.MOBILE_DEVELOPMENT)) + assertTrue(techSkills.contains(TechnologySkills.DATA_SCIENCE)) + assertTrue(techSkills.contains(TechnologySkills.AI_MACHINE_LEARNING)) + } + + @Test + fun `test enum name properties`() { + assertEquals("MATHEMATICS", AcademicSkills.MATHEMATICS.name) + assertEquals("FOOTBALL", SportsSkills.FOOTBALL.name) + assertEquals("PIANO", MusicSkills.PIANO.name) + assertEquals("PAINTING", ArtsSkills.PAINTING.name) + assertEquals("PROGRAMMING", TechnologySkills.PROGRAMMING.name) + assertEquals("ENGLISH", LanguageSkills.ENGLISH.name) + assertEquals("COOKING", CraftSkills.COOKING.name) + + assertEquals("BEGINNER", ExpertiseLevel.BEGINNER.name) + assertEquals("MASTER", ExpertiseLevel.MASTER.name) + + assertEquals("ACADEMICS", MainSubject.ACADEMICS.name) + assertEquals("SPORTS", MainSubject.SPORTS.name) + } +} diff --git a/app/src/test/java/com/android/sample/model/user/FakeProfileRepositoryTest.kt b/app/src/test/java/com/android/sample/model/user/FakeProfileRepositoryTest.kt new file mode 100644 index 00000000..d94773a4 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/user/FakeProfileRepositoryTest.kt @@ -0,0 +1,50 @@ +package com.android.sample.model.user + +import com.android.sample.model.map.Location +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test + +class FakeProfileRepositoryTest { + + @Test + fun uid_add_get_update_delete_roundtrip() = runTest { + val repo = FakeProfileRepository() + val uid1 = repo.getNewUid() + val uid2 = repo.getNewUid() + assertNotEquals(uid1, uid2) + + val p = Profile(userId = "", name = "Alice", email = "a@a.com") + repo.addProfile(p) + val saved = repo.getAllProfiles().single() + assertTrue(saved.userId.isNotBlank()) + + val fetched = repo.getProfile(saved.userId) + assertEquals("Alice", fetched.name) + + repo.updateProfile(saved.userId, fetched.copy(name = "Alice M.")) + assertEquals("Alice M.", repo.getProfile(saved.userId).name) + + repo.deleteProfile(saved.userId) + assertTrue(repo.getAllProfiles().isEmpty()) + } + + @Test + fun search_by_location_respects_radius() = runTest { + val repo = FakeProfileRepository() + val center = Location(latitude = 41.0, longitude = 29.0) + val near = Location(latitude = 41.01, longitude = 29.01) // ~1.4 km + val far = Location(latitude = 41.2, longitude = 29.2) // >> 10 km + + repo.addProfile(Profile("", "Center", "c@c", location = center)) + repo.addProfile(Profile("", "Near", "n@n", location = near)) + repo.addProfile(Profile("", "Far", "f@f", location = far)) + + // radius <= 0 => all + assertEquals(3, repo.searchProfilesByLocation(center, 0.0).size) + + // ~2 km => Center + Near + val names = repo.searchProfilesByLocation(center, 2.0).map { it.name }.toSet() + assertEquals(setOf("Center", "Near"), names) + } +} diff --git a/app/src/test/java/com/android/sample/model/user/FirestoreProfileRepositoryTest.kt b/app/src/test/java/com/android/sample/model/user/FirestoreProfileRepositoryTest.kt new file mode 100644 index 00000000..9e73c258 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/user/FirestoreProfileRepositoryTest.kt @@ -0,0 +1,169 @@ +package com.android.sample.model.user + +import com.android.sample.model.map.Location +import com.android.sample.model.rating.RatingInfo +import com.android.sample.utils.RepositoryTest +import com.github.se.bootcamp.utils.FirebaseEmulator +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.firestore.FirebaseFirestore +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class FirestoreProfileRepositoryTest : RepositoryTest() { + private lateinit var firestore: FirebaseFirestore + private lateinit var auth: FirebaseAuth + + @Before + override fun setUp() { + super.setUp() + firestore = FirebaseEmulator.firestore + + auth = mockk() + val mockUser = mockk() + every { auth.currentUser } returns mockUser + every { mockUser.uid } returns testUserId + + profileRepository = FirestoreProfileRepository(firestore, auth) + } + + @After + override fun tearDown() = runBlocking { + val snapshot = firestore.collection(PROFILES_COLLECTION_PATH).get().await() + for (document in snapshot.documents) { + document.reference.delete().await() + } + } + + @Test + fun getNewUidReturnsUniqueIDs() { + val uid1 = profileRepository.getNewUid() + val uid2 = profileRepository.getNewUid() + assertNotNull(uid1) + assertNotNull(uid2) + assertNotEquals(uid1, uid2) + } + + @Test + fun addAndGetProfileWorkCorrectly() = runTest { + val profile = + Profile( + userId = testUserId, + name = "John Doe", + email = "john.doe@example.com", + location = Location(46.519653, 6.632273), + hourlyRate = "50", + description = "Experienced tutor.", + tutorRating = RatingInfo(0.0, 0), + studentRating = RatingInfo(0.0, 0)) + profileRepository.addProfile(profile) + + val retrievedProfile = profileRepository.getProfile(testUserId) + assertNotNull(retrievedProfile) + assertEquals("John Doe", retrievedProfile!!.name) + } + + @Test + fun addProfileForAnotherUserFails() { + val profile = Profile(userId = "another-user-id", name = "Jane Doe") + assertThrows(Exception::class.java) { runTest { profileRepository.addProfile(profile) } } + } + + @Test + fun updateProfileWorksCorrectly() = runTest { + val originalProfile = Profile(userId = testUserId, name = "John Doe") + profileRepository.addProfile(originalProfile) + + val updatedProfileData = Profile(userId = testUserId, name = "John Updated") + profileRepository.updateProfile(testUserId, updatedProfileData) + + val retrievedProfile = profileRepository.getProfile(testUserId) + assertNotNull(retrievedProfile) + assertEquals("John Updated", retrievedProfile!!.name) + } + + @Test + fun updateProfileForAnotherUserFails() { + val profile = Profile(userId = "another-user-id", name = "Jane Doe") + assertThrows(Exception::class.java) { + runTest { profileRepository.updateProfile("another-user-id", profile) } + } + } + + @Test + fun deleteProfileWorksCorrectly() = runTest { + val profile = Profile(userId = testUserId, name = "John Doe") + profileRepository.addProfile(profile) + + profileRepository.deleteProfile(testUserId) + val retrievedProfile = profileRepository.getProfile(testUserId) + assertNull(retrievedProfile) + } + + @Test + fun deleteProfileForAnotherUserFails() { + assertThrows(Exception::class.java) { + runTest { profileRepository.deleteProfile("another-user-id") } + } + } + + @Test + fun getAllProfilesReturnsAllProfiles() = runTest { + val profile1 = Profile(userId = testUserId, name = "John Doe") + val profile2 = + Profile( + userId = "user2", + name = "Jane Smith") // Note: addProfile checks current user, so this won't work + // directly. We'll add to Firestore manually for this test. + firestore.collection(PROFILES_COLLECTION_PATH).document(testUserId).set(profile1).await() + firestore.collection(PROFILES_COLLECTION_PATH).document("user2").set(profile2).await() + + val profiles = profileRepository.getAllProfiles() + assertEquals(2, profiles.size) + assertTrue(profiles.any { it.name == "John Doe" }) + assertTrue(profiles.any { it.name == "Jane Smith" }) + } + + @Test + fun getProfileByIdIsSameAsGetProfile() = runTest { + val profile = Profile(userId = testUserId, name = "John Doe") + profileRepository.addProfile(profile) + + val profileById = profileRepository.getProfileById(testUserId) + val profileByGet = profileRepository.getProfile(testUserId) + assertEquals(profileByGet, profileById) + } + + @Test + fun searchByLocationIsNotImplemented() { + assertThrows(NotImplementedError::class.java) { + runTest { profileRepository.searchProfilesByLocation(Location(), 10.0) } + } + } + + @Test + fun getSkillsForUserReturnsEmptyListWhenNoSkills() = runTest { + val profile = Profile(userId = testUserId, name = "John Doe") + profileRepository.addProfile(profile) + + val skills = profileRepository.getSkillsForUser(testUserId) + assertTrue(skills.isEmpty()) + } +} diff --git a/app/src/test/java/com/android/sample/model/user/ProfileTest.kt b/app/src/test/java/com/android/sample/model/user/ProfileTest.kt new file mode 100644 index 00000000..4b274a97 --- /dev/null +++ b/app/src/test/java/com/android/sample/model/user/ProfileTest.kt @@ -0,0 +1,159 @@ +package com.android.sample.model.user + +import com.android.sample.model.map.Location +import com.android.sample.model.rating.RatingInfo +import org.junit.Assert.* +import org.junit.Test + +class ProfileTest { + + @Test + fun `test Profile creation with default values`() { + val profile = Profile() + + assertEquals("", profile.userId) + assertEquals("", profile.name) + assertEquals("", profile.email) + assertEquals(Location(), profile.location) + assertEquals("", profile.description) + assertEquals(RatingInfo(), profile.tutorRating) + assertEquals(RatingInfo(), profile.studentRating) + } + + @Test + fun `test Profile creation with custom values`() { + val customLocation = Location(46.5197, 6.6323, "EPFL, Lausanne") + val tutorRating = RatingInfo(averageRating = 4.5, totalRatings = 20) + val studentRating = RatingInfo(averageRating = 4.2, totalRatings = 15) + + val profile = + Profile( + userId = "user123", + name = "John Doe", + email = "john.doe@example.com", + location = customLocation, + description = "Experienced mathematics tutor", + tutorRating = tutorRating, + studentRating = studentRating) + + assertEquals("user123", profile.userId) + assertEquals("John Doe", profile.name) + assertEquals("john.doe@example.com", profile.email) + assertEquals(customLocation, profile.location) + assertEquals("Experienced mathematics tutor", profile.description) + assertEquals(4.5, profile.tutorRating.averageRating, 0.01) + assertEquals(20, profile.tutorRating.totalRatings) + assertEquals(4.2, profile.studentRating.averageRating, 0.01) + assertEquals(15, profile.studentRating.totalRatings) + } + + @Test + fun `test RatingInfo creation with valid values`() { + val ratingInfo = RatingInfo(averageRating = 3.5, totalRatings = 10) + + assertEquals(3.5, ratingInfo.averageRating, 0.01) + assertEquals(10, ratingInfo.totalRatings) + } + + @Test(expected = IllegalArgumentException::class) + fun `test RatingInfo validation - average rating too low`() { + RatingInfo(averageRating = 0.5, totalRatings = 5) + } + + @Test(expected = IllegalArgumentException::class) + fun `test RatingInfo validation - average rating too high`() { + RatingInfo(averageRating = 5.5, totalRatings = 5) + } + + @Test(expected = IllegalArgumentException::class) + fun `test RatingInfo validation - negative total ratings`() { + RatingInfo(averageRating = 4.0, totalRatings = -1) + } + + @Test + fun `test RatingInfo with zero average and zero ratings`() { + val ratingInfo = RatingInfo(averageRating = 0.0, totalRatings = 0) + + assertEquals(0.0, ratingInfo.averageRating, 0.01) + assertEquals(0, ratingInfo.totalRatings) + } + + @Test + fun `test RatingInfo boundary values`() { + val minRating = RatingInfo(averageRating = 1.0, totalRatings = 1) + val maxRating = RatingInfo(averageRating = 5.0, totalRatings = 100) + + assertEquals(1.0, minRating.averageRating, 0.01) + assertEquals(5.0, maxRating.averageRating, 0.01) + } + + @Test + fun `test Profile data class equality`() { + val location = Location(46.5197, 6.6323, "EPFL, Lausanne") + val tutorRating = RatingInfo(averageRating = 4.5, totalRatings = 20) + val studentRating = RatingInfo(averageRating = 4.2, totalRatings = 15) + + val profile1 = + Profile( + userId = "user123", + name = "John Doe", + email = "john.doe@example.com", + location = location, + description = "Tutor", + tutorRating = tutorRating, + studentRating = studentRating) + + val profile2 = + Profile( + userId = "user123", + name = "John Doe", + email = "john.doe@example.com", + location = location, + description = "Tutor", + tutorRating = tutorRating, + studentRating = studentRating) + + assertEquals(profile1, profile2) + assertEquals(profile1.hashCode(), profile2.hashCode()) + } + + @Test + fun `test Profile copy functionality`() { + val originalProfile = + Profile( + userId = "user123", + name = "John Doe", + tutorRating = RatingInfo(averageRating = 4.0, totalRatings = 10)) + + val updatedRating = RatingInfo(averageRating = 4.5, totalRatings = 15) + val copiedProfile = originalProfile.copy(name = "Jane Doe", tutorRating = updatedRating) + + assertEquals("user123", copiedProfile.userId) + assertEquals("Jane Doe", copiedProfile.name) + assertEquals(4.5, copiedProfile.tutorRating.averageRating, 0.01) + assertEquals(15, copiedProfile.tutorRating.totalRatings) + + assertNotEquals(originalProfile, copiedProfile) + } + + @Test + fun `test Profile with different tutor and student ratings`() { + val profile = + Profile( + userId = "user123", + tutorRating = RatingInfo(averageRating = 5.0, totalRatings = 50), + studentRating = RatingInfo(averageRating = 3.5, totalRatings = 20)) + + assertTrue(profile.tutorRating.averageRating > profile.studentRating.averageRating) + assertTrue(profile.tutorRating.totalRatings > profile.studentRating.totalRatings) + } + + @Test + fun `test Profile toString contains key information`() { + val profile = Profile(userId = "user123", name = "John Doe", email = "john.doe@example.com") + + val profileString = profile.toString() + assertTrue(profileString.contains("user123")) + assertTrue(profileString.contains("John Doe")) + } +} diff --git a/app/src/test/java/com/android/sample/screen/LoginScreenUnit.java b/app/src/test/java/com/android/sample/screen/LoginScreenUnit.java new file mode 100644 index 00000000..8694fb7d --- /dev/null +++ b/app/src/test/java/com/android/sample/screen/LoginScreenUnit.java @@ -0,0 +1,5 @@ +package com.android.sample.screen; + +public class LoginScreenUnit { + +} diff --git a/app/src/test/java/com/android/sample/screen/MyBookingsViewModelLogicTest.kt b/app/src/test/java/com/android/sample/screen/MyBookingsViewModelLogicTest.kt new file mode 100644 index 00000000..d79909dd --- /dev/null +++ b/app/src/test/java/com/android/sample/screen/MyBookingsViewModelLogicTest.kt @@ -0,0 +1,346 @@ +package com.android.sample.screen + +import com.android.sample.model.booking.Booking +import com.android.sample.model.booking.BookingRepository +import com.android.sample.model.booking.BookingStatus +import com.android.sample.model.listing.Listing +import com.android.sample.model.listing.ListingRepository +import com.android.sample.model.listing.Proposal +import com.android.sample.model.listing.Request +import com.android.sample.model.map.Location +import com.android.sample.model.rating.Rating +import com.android.sample.model.rating.RatingRepository +import com.android.sample.model.rating.RatingType +import com.android.sample.model.rating.StarRating +import com.android.sample.model.skill.Skill +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.ui.bookings.MyBookingsViewModel +import java.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class MyBookingsViewModelLogicTest { + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun booking( + id: String = "b1", + creatorId: String = "t1", + bookerId: String = "s1", + listingId: String = "L1", + start: Date = Date(), + end: Date = Date(start.time + 90 * 60 * 1000), // 1h30 + price: Double = 30.0 + ) = + Booking( + bookingId = id, + associatedListingId = listingId, + listingCreatorId = creatorId, + bookerId = bookerId, + sessionStart = start, + sessionEnd = end, + status = BookingStatus.CONFIRMED, + price = price) + + /** Simple in-memory fakes */ + private class FakeBookingRepo(private val list: List) : BookingRepository { + override fun getNewUid() = "X" + + override suspend fun getAllBookings() = list + + override suspend fun getBooking(bookingId: String) = list.first { it.bookingId == bookingId } + + override suspend fun getBookingsByTutor(tutorId: String) = + list.filter { it.listingCreatorId == tutorId } + + override suspend fun getBookingsByUserId(userId: String) = list.filter { it.bookerId == userId } + + override suspend fun getBookingsByStudent(studentId: String) = + list.filter { it.bookerId == studentId } + + override suspend fun getBookingsByListing(listingId: String) = + list.filter { it.associatedListingId == listingId } + + override suspend fun addBooking(booking: Booking) {} + + override suspend fun updateBooking(bookingId: String, booking: Booking) {} + + override suspend fun deleteBooking(bookingId: String) {} + + override suspend fun updateBookingStatus(bookingId: String, status: BookingStatus) {} + + override suspend fun confirmBooking(bookingId: String) {} + + override suspend fun completeBooking(bookingId: String) {} + + override suspend fun cancelBooking(bookingId: String) {} + } + + private class FakeRatingRepo( + private val map: Map> // key: listingId -> ratings + ) : RatingRepository { + override fun getNewUid() = "R" + + override suspend fun getAllRatings(): List = map.values.flatten() + + override suspend fun getRating(ratingId: String) = error("not used in these tests") + + override suspend fun getRatingsByFromUser(fromUserId: String) = emptyList() + + override suspend fun getRatingsByToUser(toUserId: String) = emptyList() + + override suspend fun getRatingsOfListing(listingId: String): List = + map[listingId] ?: emptyList() + + override suspend fun addRating(rating: Rating) {} + + override suspend fun updateRating(ratingId: String, rating: Rating) {} + + override suspend fun deleteRating(ratingId: String) {} + + override suspend fun getTutorRatingsOfUser(userId: String) = emptyList() + + override suspend fun getStudentRatingsOfUser(userId: String) = emptyList() + } + + private class FakeProfileRepo(private val map: Map) : ProfileRepository { + override fun getNewUid() = "P" + + override suspend fun getProfile(userId: String) = map.getValue(userId) + + override suspend fun addProfile(profile: Profile) {} + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles() = map.values.toList() + + override suspend fun searchProfilesByLocation( + location: com.android.sample.model.map.Location, + radiusKm: Double + ) = emptyList() + + override suspend fun getProfileById(userId: String): Profile { + TODO("Not yet implemented") + } + + override suspend fun getSkillsForUser(userId: String): List { + TODO("Not yet implemented") + } + } + + private class FakeListingRepo(private val map: Map) : ListingRepository { + override fun getNewUid() = "L" + + override suspend fun getAllListings() = map.values.toList() + + override suspend fun getProposals() = map.values.filterIsInstance() + + override suspend fun getRequests() = map.values.filterIsInstance() + + override suspend fun getListing(listingId: String) = map.getValue(listingId) + + override suspend fun getListingsByUser(userId: String) = + map.values.filter { it.creatorUserId == userId } + + override suspend fun addProposal(proposal: Proposal) {} + + override suspend fun addRequest(request: Request) {} + + override suspend fun updateListing(listingId: String, listing: Listing) {} + + override suspend fun deleteListing(listingId: String) {} + + override suspend fun deactivateListing(listingId: String) {} + + override suspend fun searchBySkill(skill: Skill) = map.values.filter { it.skill == skill } + + override suspend fun searchByLocation( + location: com.android.sample.model.map.Location, + radiusKm: Double + ) = emptyList() + } + + @Test + fun load_success_populates_cards_and_formats_labels() = runTest { + val start = Date(0L) // 01/01/1970 00:00 UTC + val end = Date(0L + 90 * 60 * 1000) // +1h30 + + val listing = Proposal("L1", "t1", description = "", location = Location(), hourlyRate = 30.0) + val prof = Profile("t1", "Alice Martin", "a@a.com") + val rating = Rating("r1", "s1", "t1", StarRating.FOUR, "", RatingType.TUTOR) + + val vm = + MyBookingsViewModel( + bookingRepo = FakeBookingRepo(listOf(booking(start = start, end = end))), + userId = "s1", + listingRepo = FakeListingRepo(mapOf("L1" to listing)), + profileRepo = FakeProfileRepo(mapOf("t1" to prof)), + ratingRepo = FakeRatingRepo(mapOf("L1" to listOf(rating))), + locale = Locale.UK, + ) + + this.testScheduler.advanceUntilIdle() + + val c = vm.uiState.value.single() + assertEquals("01/01/1970", c.dateLabel) // now deterministic + assertEquals("1h 30m", c.durationLabel) + } + + @Test + fun when_rating_absent_stars_and_count_are_zero_and_pluralization_for_exact_hours() = runTest { + val twoHours = + booking( + id = "b2", start = Date(0L), end = Date(0L + 2 * 60 * 60 * 1000) // 2 hours exact + ) + + val vm = + MyBookingsViewModel( + bookingRepo = FakeBookingRepo(listOf(twoHours)), + userId = "s1", + listingRepo = + FakeListingRepo( + mapOf( + "L1" to + Proposal( + "L1", + "t1", + description = "", + location = Location(), + hourlyRate = 10.0))), + profileRepo = FakeProfileRepo(mapOf("t1" to Profile("t1", "T", "t@t.com"))), + ratingRepo = FakeRatingRepo(mapOf("L1" to emptyList())), // no rating + locale = Locale.US, + ) + + this.testScheduler.advanceUntilIdle() + val c = vm.uiState.value.single() + assertEquals(0, c.ratingStars) + assertEquals(0, c.ratingCount) + assertEquals("2hrs", c.durationLabel) // pluralization branch + } + + @Test + fun listing_fetch_failure_skips_booking() = runTest { + val failingListingRepo = + object : ListingRepository { + override fun getNewUid() = "L" + + override suspend fun getAllListings() = emptyList() + + override suspend fun getProposals() = emptyList() + + override suspend fun getRequests() = emptyList() + + override suspend fun getListing(listingId: String) = throw RuntimeException("no listing") + + override suspend fun getListingsByUser(userId: String) = emptyList() + + override suspend fun addProposal(proposal: Proposal) {} + + override suspend fun addRequest(request: Request) {} + + override suspend fun updateListing(listingId: String, listing: Listing) {} + + override suspend fun deleteListing(listingId: String) {} + + override suspend fun deactivateListing(listingId: String) {} + + override suspend fun searchBySkill(skill: Skill) = emptyList() + + override suspend fun searchByLocation( + location: com.android.sample.model.map.Location, + radiusKm: Double + ) = emptyList() + } + + val vm = + MyBookingsViewModel( + bookingRepo = FakeBookingRepo(listOf(booking())), + userId = "s1", + listingRepo = failingListingRepo, + profileRepo = FakeProfileRepo(emptyMap()), + ratingRepo = FakeRatingRepo(emptyMap()), + ) + + this.testScheduler.advanceUntilIdle() + assertTrue(vm.uiState.value.isEmpty()) // buildCardSafely returned null → skipped + } + + @Test + fun profile_fetch_failure_skips_booking() = runTest { + val listing = Proposal("L1", "t1", description = "", location = Location(), hourlyRate = 10.0) + val failingProfiles = + object : ProfileRepository { + override fun getNewUid() = "P" + + override suspend fun getProfile(userId: String) = throw RuntimeException("no profile") + + override suspend fun addProfile(profile: Profile) {} + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles() = emptyList() + + override suspend fun searchProfilesByLocation( + location: com.android.sample.model.map.Location, + radiusKm: Double + ) = emptyList() + + override suspend fun getProfileById(userId: String): Profile { + TODO("Not yet implemented") + } + + override suspend fun getSkillsForUser(userId: String): List { + TODO("Not yet implemented") + } + } + + val vm = + MyBookingsViewModel( + bookingRepo = FakeBookingRepo(listOf(booking())), + userId = "s1", + listingRepo = FakeListingRepo(mapOf("L1" to listing)), + profileRepo = failingProfiles, + ratingRepo = FakeRatingRepo(emptyMap()), + ) + + this.testScheduler.advanceUntilIdle() + assertTrue(vm.uiState.value.isEmpty()) + } + + @Test + fun load_empty_results_in_empty_list() = runTest { + val vm = + MyBookingsViewModel( + bookingRepo = FakeBookingRepo(emptyList()), + userId = "s1", + listingRepo = FakeListingRepo(emptyMap()), + profileRepo = FakeProfileRepo(emptyMap()), + ratingRepo = FakeRatingRepo(emptyMap()), + ) + this.testScheduler.advanceUntilIdle() + assertTrue(vm.uiState.value.isEmpty()) + } +} diff --git a/app/src/test/java/com/android/sample/screen/MyProfileViewModelTest.kt b/app/src/test/java/com/android/sample/screen/MyProfileViewModelTest.kt new file mode 100644 index 00000000..6303d89f --- /dev/null +++ b/app/src/test/java/com/android/sample/screen/MyProfileViewModelTest.kt @@ -0,0 +1,213 @@ +package com.android.sample.screen + +import com.android.sample.model.map.Location +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.ui.profile.MyProfileViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class MyProfileViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // -------- Fake repository ------------------------------------------------------ + + private class FakeRepo(private var storedProfile: Profile? = null) : ProfileRepository { + var updatedProfile: Profile? = null + var updateCalled = false + var getProfileCalled = false + + override fun getNewUid(): String = "fake" + + override suspend fun getProfile(userId: String): Profile { + getProfileCalled = true + return storedProfile ?: error("not found") + } + + override suspend fun addProfile(profile: Profile) {} + + override suspend fun updateProfile(userId: String, profile: Profile) { + updateCalled = true + updatedProfile = profile + } + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles(): List = emptyList() + + override suspend fun searchProfilesByLocation(location: Location, radiusKm: Double) = + emptyList() + + override suspend fun getProfileById(userId: String) = storedProfile ?: error("not found") + + override suspend fun getSkillsForUser(userId: String) = + emptyList() + } + + // -------- Helpers ------------------------------------------------------ + + private fun makeProfile( + id: String = "1", + name: String = "Kendrick", + email: String = "kdot@example.com", + location: Location = Location(name = "Compton"), + desc: String = "Rap tutor" + ) = Profile(id, name, email, location = location, description = desc) + + private fun newVm(repo: ProfileRepository = FakeRepo()) = MyProfileViewModel(repo) + + // -------- Tests -------------------------------------------------------- + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun loadProfile_populatesUiState() = runTest { + val profile = makeProfile() + val repo = FakeRepo(profile) + val vm = newVm(repo) + + vm.loadProfile(profile.userId) + advanceUntilIdle() + + val ui = vm.uiState.value + assertEquals(profile.name, ui.name) + assertEquals(profile.email, ui.email) + assertEquals(profile.location, ui.location) + assertEquals(profile.description, ui.description) + assertTrue(repo.getProfileCalled) + } + + @Test + fun setName_updatesName_and_setsErrorIfBlank() { + val vm = newVm() + + vm.setName("K Dot") + assertEquals("K Dot", vm.uiState.value.name) + assertNull(vm.uiState.value.invalidNameMsg) + + vm.setName("") + assertEquals("Name cannot be empty", vm.uiState.value.invalidNameMsg) + } + + @Test + fun setEmail_validatesFormat_andRequired() { + val vm = newVm() + + vm.setEmail("") + assertEquals("Email cannot be empty", vm.uiState.value.invalidEmailMsg) + + vm.setEmail("invalid-email") + assertEquals("Email is not in the right format", vm.uiState.value.invalidEmailMsg) + + vm.setEmail("good@mail.com") + assertNull(vm.uiState.value.invalidEmailMsg) + } + + @Test + fun setLocation_updatesLocation_andErrorIfBlank() { + val vm = newVm() + + vm.setLocation("Paris") + assertEquals("Paris", vm.uiState.value.location?.name) + assertNull(vm.uiState.value.invalidLocationMsg) + + vm.setLocation("") + assertNull(vm.uiState.value.location) + assertEquals("Location cannot be empty", vm.uiState.value.invalidLocationMsg) + } + + @Test + fun setDescription_updatesDesc_andErrorIfBlank() { + val vm = newVm() + + vm.setDescription("Music mentor") + assertEquals("Music mentor", vm.uiState.value.description) + assertNull(vm.uiState.value.invalidDescMsg) + + vm.setDescription("") + assertEquals("Description cannot be empty", vm.uiState.value.invalidDescMsg) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun editProfile_doesNotUpdate_whenInvalid() = runTest { + val repo = FakeRepo() + val vm = newVm(repo) + + // no name, invalid by default + vm.editProfile("1") + advanceUntilIdle() + + assertFalse(repo.updateCalled) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun editProfile_updatesRepository_whenValid() = runTest { + val repo = FakeRepo() + val vm = newVm(repo) + + vm.setName("Kendrick Lamar") + vm.setEmail("kdot@gmail.com") + vm.setLocation("Compton") + vm.setDescription("Hip-hop tutor") + + vm.editProfile("123") + advanceUntilIdle() + + assertTrue(repo.updateCalled) + val updated = repo.updatedProfile!! + assertEquals("Kendrick Lamar", updated.name) + assertEquals("kdot@gmail.com", updated.email) + assertEquals("Compton", updated.location.name) + assertEquals("Hip-hop tutor", updated.description) + } + + @Test + fun setError_setsAllErrorMessages_whenFieldsInvalid() { + val vm = newVm() + vm.setError() + + val ui = vm.uiState.value + assertEquals("Name cannot be empty", ui.invalidNameMsg) + assertEquals("Email cannot be empty", ui.invalidEmailMsg) + assertEquals("Location cannot be empty", ui.invalidLocationMsg) + assertEquals("Description cannot be empty", ui.invalidDescMsg) + } + + @Test + fun isValid_returnsTrue_onlyWhenAllFieldsAreCorrect() { + val vm = newVm() + + vm.setName("Test") + vm.setEmail("test@mail.com") + vm.setLocation("Paris") + vm.setDescription("Teacher") + + assertTrue(vm.uiState.value.isValid) + + vm.setEmail("wrong") + assertFalse(vm.uiState.value.isValid) + } +} diff --git a/app/src/test/java/com/android/sample/screen/NewSkillScreenTest.kt b/app/src/test/java/com/android/sample/screen/NewSkillScreenTest.kt new file mode 100644 index 00000000..6212054b --- /dev/null +++ b/app/src/test/java/com/android/sample/screen/NewSkillScreenTest.kt @@ -0,0 +1,186 @@ +package com.android.sample.screen + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import com.android.sample.model.listing.FirestoreListingRepository +import com.android.sample.model.listing.ListingRepositoryProvider +import com.android.sample.model.skill.MainSubject +import com.android.sample.ui.screens.newSkill.NewSkillScreen +import com.android.sample.ui.screens.newSkill.NewSkillScreenTestTag +import com.android.sample.ui.screens.newSkill.NewSkillViewModel +import com.android.sample.utils.RepositoryTest +import com.github.se.bootcamp.utils.FirebaseEmulator +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.firestore.FirebaseFirestore +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class NewSkillScreenTest : RepositoryTest() { + @get:Rule val composeTestRule = createComposeRule() + + private lateinit var firestore: FirebaseFirestore + private lateinit var auth: FirebaseAuth + private lateinit var viewModel: NewSkillViewModel + + @Before + fun setup() { + super.setUp() + firestore = FirebaseEmulator.firestore + + // Mock FirebaseAuth to bypass authentication + auth = mockk(relaxed = true) + val mockUser = mockk() + every { auth.currentUser } returns mockUser + every { mockUser.uid } returns testUserId // testUserId is from RepositoryTest + + listingRepository = FirestoreListingRepository(firestore, auth) + ListingRepositoryProvider.setForTests(listingRepository) + + viewModel = NewSkillViewModel(listingRepository) + } + + @Test + fun saveButton_isDisplayed_andClickable() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.BUTTON_SAVE_SKILL).assertIsDisplayed() + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.BUTTON_SAVE_SKILL).performClick() + } + + @Test + fun createLessonsTitle_isDisplayed() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.CREATE_LESSONS_TITLE).assertIsDisplayed() + } + + @Test + fun inputCourseTitle_isDisplayed() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INPUT_COURSE_TITLE).assertIsDisplayed() + } + + @Test + fun inputDescription_isDisplayed() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INPUT_DESCRIPTION).assertIsDisplayed() + } + + @Test + fun inputPrice_isDisplayed() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INPUT_PRICE).assertIsDisplayed() + } + + @Test + fun subjectField_isDisplayed() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.SUBJECT_FIELD).assertIsDisplayed() + } + + @Test + fun subjectDropdown_showsItems_whenClicked() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.SUBJECT_FIELD).performClick() + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.SUBJECT_DROPDOWN).assertIsDisplayed() + val itemsDisplay = + composeTestRule + .onAllNodesWithTag(NewSkillScreenTestTag.SUBJECT_DROPDOWN_ITEM_PREFIX) + .fetchSemanticsNodes() + Assert.assertEquals(MainSubject.entries.size, itemsDisplay.size) + } + + @Test + fun titleField_acceptsInput_andNoError() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + val testTitle = "Cours Kotlin" + composeTestRule + .onNodeWithTag(NewSkillScreenTestTag.INPUT_COURSE_TITLE) + .performTextInput(testTitle) + composeTestRule + .onNodeWithTag(NewSkillScreenTestTag.INPUT_COURSE_TITLE) + .assertTextContains(testTitle) + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INVALID_TITLE_MSG).assertIsNotDisplayed() + } + + @Test + fun descriptionField_acceptsInput_andNoError() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + val testDesc = "Description du cours" + composeTestRule + .onNodeWithTag(NewSkillScreenTestTag.INPUT_DESCRIPTION) + .performTextInput(testDesc) + composeTestRule + .onNodeWithTag(NewSkillScreenTestTag.INPUT_DESCRIPTION) + .assertTextContains(testDesc) + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INVALID_DESC_MSG).assertIsNotDisplayed() + } + + @Test + fun priceField_acceptsInput_andNoError() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + val testPrice = "25" + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INPUT_PRICE).performTextInput(testPrice) + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INPUT_PRICE).assertTextContains(testPrice) + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INVALID_PRICE_MSG).assertIsNotDisplayed() + } + + @Test + fun titleField_empty_showsError() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INPUT_COURSE_TITLE).performTextInput(" ") + composeTestRule + .onNodeWithTag(testTag = NewSkillScreenTestTag.INVALID_TITLE_MSG, useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun descriptionField_empty_showsError() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INPUT_DESCRIPTION).performTextClearance() + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INPUT_DESCRIPTION).performTextInput(" ") + composeTestRule + .onNodeWithTag(testTag = NewSkillScreenTestTag.INVALID_DESC_MSG, useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun priceField_invalid_showsError() { + composeTestRule.setContent { NewSkillScreen(profileId = "test") } + composeTestRule.onNodeWithTag(NewSkillScreenTestTag.INPUT_PRICE).performTextInput("abc") + composeTestRule + .onNodeWithTag(testTag = NewSkillScreenTestTag.INVALID_PRICE_MSG, useUnmergedTree = true) + .assertIsDisplayed() + } + + @Test + fun setError_showsAllFieldErrors() { + val vm = NewSkillViewModel() + composeTestRule.setContent { NewSkillScreen(skillViewModel = vm, profileId = "test") } + + composeTestRule.runOnIdle { vm.setError() } + + composeTestRule + .onNodeWithTag(NewSkillScreenTestTag.INVALID_TITLE_MSG, useUnmergedTree = true) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(NewSkillScreenTestTag.INVALID_DESC_MSG, useUnmergedTree = true) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(NewSkillScreenTestTag.INVALID_PRICE_MSG, useUnmergedTree = true) + .assertIsDisplayed() + composeTestRule + .onNodeWithTag(NewSkillScreenTestTag.INVALID_SUBJECT_MSG, useUnmergedTree = true) + .assertIsDisplayed() + } +} diff --git a/app/src/test/java/com/android/sample/screen/NewSkillViewModelTest.kt b/app/src/test/java/com/android/sample/screen/NewSkillViewModelTest.kt new file mode 100644 index 00000000..2d9d16e9 --- /dev/null +++ b/app/src/test/java/com/android/sample/screen/NewSkillViewModelTest.kt @@ -0,0 +1,150 @@ +package com.android.sample.screen + +import com.android.sample.model.listing.FirestoreListingRepository +import com.android.sample.model.listing.ListingRepositoryProvider +import com.android.sample.model.skill.MainSubject +import com.android.sample.ui.screens.newSkill.NewSkillViewModel +import com.android.sample.utils.RepositoryTest +import com.github.se.bootcamp.utils.FirebaseEmulator +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import com.google.firebase.firestore.FirebaseFirestore +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class NewSkillViewModelTest : RepositoryTest() { + private lateinit var firestore: FirebaseFirestore + private lateinit var auth: FirebaseAuth + private lateinit var viewModel: NewSkillViewModel + + @Before + fun setup() { + super.setUp() + firestore = FirebaseEmulator.firestore + + // Mock FirebaseAuth to bypass authentication + auth = mockk() + val mockUser = mockk() + every { auth.currentUser } returns mockUser + every { mockUser.uid } returns testUserId // testUserId is "test-user-id" from RepositoryTest + + listingRepository = FirestoreListingRepository(firestore, auth) + ListingRepositoryProvider.setForTests(listingRepository) + + viewModel = NewSkillViewModel(listingRepository) + } + + @Test + fun `setTitle blank and valid`() { + viewModel.setTitle("") + assertNotNull(viewModel.uiState.value.invalidTitleMsg) + assertFalse(viewModel.uiState.value.isValid) + + viewModel.setTitle("My title") + assertNull(viewModel.uiState.value.invalidTitleMsg) + } + + @Test + fun `setDesc blank and valid`() { + viewModel.setDescription("") + assertNotNull(viewModel.uiState.value.invalidDescMsg) + assertFalse(viewModel.uiState.value.isValid) + + viewModel.setDescription("A description") + assertNull(viewModel.uiState.value.invalidDescMsg) + } + + @Test + fun `setPrice blank non-number negative and valid`() { + viewModel.setPrice("") + assertEquals("Price cannot be empty", viewModel.uiState.value.invalidPriceMsg) + assertFalse(viewModel.uiState.value.isValid) + + viewModel.setPrice("abc") + assertEquals("Price must be a positive number", viewModel.uiState.value.invalidPriceMsg) + + viewModel.setPrice("-1") + assertEquals("Price must be a positive number", viewModel.uiState.value.invalidPriceMsg) + + viewModel.setPrice("10.5") + assertNull(viewModel.uiState.value.invalidPriceMsg) + } + + @Test + fun `setSubject`() { + val subject = MainSubject.entries.firstOrNull() + if (subject != null) { + viewModel.setSubject(subject) + assertEquals(subject, viewModel.uiState.value.subject) + } + } + + @Test + fun `isValid becomes true when all fields valid`() { + viewModel.setTitle("T") + viewModel.setDescription("D") + viewModel.setPrice("5") + viewModel.setSubject(MainSubject.TECHNOLOGY) + assertTrue(viewModel.uiState.value.isValid) + } + + @Test + fun `setError sets all errors when fields are empty`() { + viewModel.setTitle("") + viewModel.setDescription("") + viewModel.setPrice("") + viewModel.setError() + + assertEquals("Title cannot be empty", viewModel.uiState.value.invalidTitleMsg) + assertEquals("Description cannot be empty", viewModel.uiState.value.invalidDescMsg) + assertEquals("Price cannot be empty", viewModel.uiState.value.invalidPriceMsg) + assertEquals("You must choose a subject", viewModel.uiState.value.invalidSubjectMsg) + assertFalse(viewModel.uiState.value.isValid) + } + + @Test + fun `setError sets price invalid message for non numeric or negative`() { + + viewModel.setTitle("Valid") + viewModel.setDescription("Valid") + viewModel.setPrice("abc") // non-numeric + viewModel.setError() + + assertNull(viewModel.uiState.value.invalidTitleMsg) + assertNull(viewModel.uiState.value.invalidDescMsg) + assertEquals("Price must be a positive number", viewModel.uiState.value.invalidPriceMsg) + assertEquals("You must choose a subject", viewModel.uiState.value.invalidSubjectMsg) + assertFalse(viewModel.uiState.value.isValid) + } + + @Test + fun `setError clears errors when all fields valid`() { + viewModel.setTitle("T") + viewModel.setDescription("D") + viewModel.setPrice("10") + viewModel.setSubject(MainSubject.TECHNOLOGY) + + viewModel.setError() + + assertNull(viewModel.uiState.value.invalidTitleMsg) + assertNull(viewModel.uiState.value.invalidDescMsg) + assertNull(viewModel.uiState.value.invalidPriceMsg) + assertNull(viewModel.uiState.value.invalidSubjectMsg) + assertTrue(viewModel.uiState.value.isValid) + } + + @Test + fun `addProfile withInvallid data`() { + viewModel.setTitle("T") + + viewModel.addProfile(userId = "") + + assertEquals("Description cannot be empty", viewModel.uiState.value.invalidDescMsg) + assertEquals("Price cannot be empty", viewModel.uiState.value.invalidPriceMsg) + assertEquals("You must choose a subject", viewModel.uiState.value.invalidSubjectMsg) + assertFalse(viewModel.uiState.value.isValid) + } +} diff --git a/app/src/test/java/com/android/sample/screen/SubjectListViewModelTest.kt b/app/src/test/java/com/android/sample/screen/SubjectListViewModelTest.kt new file mode 100644 index 00000000..627fd990 --- /dev/null +++ b/app/src/test/java/com/android/sample/screen/SubjectListViewModelTest.kt @@ -0,0 +1,199 @@ +package com.android.sample.screen + +import com.android.sample.model.map.Location +import com.android.sample.model.rating.RatingInfo +import com.android.sample.model.skill.MainSubject +import com.android.sample.model.skill.Skill +import com.android.sample.model.user.Profile +import com.android.sample.model.user.ProfileRepository +import com.android.sample.ui.subject.SubjectListViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +// Ai generated tests for the SubjectListViewModel +class SubjectListViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // ---------- Helpers ----------------------------------------------------- + + private fun profile(id: String, name: String, desc: String, rating: Double, total: Int) = + Profile(userId = id, name = name, description = desc, tutorRating = RatingInfo(rating, total)) + + private fun skill(userId: String, s: String) = Skill(mainSubject = MainSubject.MUSIC, skill = s) + + private class FakeRepo( + private val profiles: List = emptyList(), + private val skills: Map> = emptyMap(), + private val delayMs: Long = 0, + private val throwOnGetAll: Boolean = false + ) : ProfileRepository { + override fun getNewUid(): String = "unused" + + override suspend fun getProfile(userId: String): Profile = error("unused") + + override suspend fun addProfile(profile: Profile) {} + + override suspend fun updateProfile(userId: String, profile: Profile) {} + + override suspend fun deleteProfile(userId: String) {} + + override suspend fun getAllProfiles(): List { + if (throwOnGetAll) error("boom") + if (delayMs > 0) delay(delayMs) + return profiles + } + + override suspend fun searchProfilesByLocation( + location: Location, + radiusKm: Double + ): List = emptyList() + + override suspend fun getProfileById(userId: String): Profile = error("unused") + + override suspend fun getSkillsForUser(userId: String): List = skills[userId].orEmpty() + } + + // Seed used by most tests: + // Sorted (best first) should be: A(4.9,10), B(4.8,20), C(4.8,15), D(4.2,5) + private val A = profile("1", "Alpha", "Guitar lessons", 4.9, 10) + private val B = profile("2", "Beta", "Piano lessons", 4.8, 20) + private val C = profile("3", "Gamma", "Sing coach", 4.8, 15) + private val D = profile("4", "Delta", "Piano tutor", 4.2, 5) + + private val defaultRepo = + FakeRepo( + profiles = listOf(A, B, C, D), + skills = + mapOf( + "1" to listOf(skill("1", "GUITAR")), + "2" to listOf(skill("2", "PIANO")), + "3" to listOf(skill("3", "SING")), + "4" to listOf(skill("4", "PIANO"))), + delayMs = 1L) + + private fun newVm(repo: ProfileRepository = defaultRepo) = SubjectListViewModel(repository = repo) + + // ---------- Tests ------------------------------------------------------- + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun refresh_populatesSingleSortedList() = runTest { + val vm = newVm() + vm.refresh() + advanceUntilIdle() + + val ui = vm.ui.value + assertFalse(ui.isLoading) + assertNull(ui.error) + + // Single list contains everyone, sorted by rating desc, total ratings desc, then name + assertEquals(listOf(A.userId, B.userId, C.userId, D.userId), ui.tutors.map { it.userId }) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun onQueryChanged_filtersByNameOrDescription_caseInsensitive() = runTest { + val vm = newVm() + vm.refresh() + advanceUntilIdle() + + // "gamma" matches profile C by name + vm.onQueryChanged("gAmMa") + var ui = vm.ui.value + assertEquals(listOf(C.userId), ui.tutors.map { it.userId }) + + // "piano" matches B (desc) and D (desc/name) -> both shown, sorted best-first + vm.onQueryChanged("piano") + ui = vm.ui.value + assertEquals(listOf(B.userId, D.userId), ui.tutors.map { it.userId }) + + // nonsense query -> empty list + vm.onQueryChanged("zzz") + ui = vm.ui.value + assertTrue(ui.tutors.isEmpty()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun onSkillSelected_filtersByExactSkill_inCurrentMainSubject() = runTest { + val vm = newVm() + vm.refresh() + advanceUntilIdle() + + // PIANO should return B and D (no separate top section anymore), best-first + vm.onSkillSelected("PIANO") + val ui = vm.ui.value + assertEquals(listOf(B.userId, D.userId), ui.tutors.map { it.userId }) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun combined_filters_are_ANDed() = runTest { + val vm = newVm() + vm.refresh() + advanceUntilIdle() + + // D matches both query "del" and skill "PIANO" + vm.onQueryChanged("Del") + vm.onSkillSelected("PIANO") + var ui = vm.ui.value + assertEquals(listOf(D.userId), ui.tutors.map { it.userId }) + + // Change query to something that doesn't match D -> empty result + vm.onQueryChanged("Gamma") + ui = vm.ui.value + assertTrue(ui.tutors.isEmpty()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun sorting_respects_tieBreakers() = runTest { + // X and Y tie on rating & totals -> name tie-breaker (Aaron before Zed) + val X = profile("10", "Aaron", "Vocal coach", 4.8, 15) + val Y = profile("11", "Zed", "Vocal coach", 4.8, 15) + val repo = FakeRepo(profiles = listOf(A, X, Y), skills = emptyMap()) + val vm = newVm(repo) + vm.refresh() + advanceUntilIdle() + + val ui = vm.ui.value + assertEquals(listOf(A.userId, X.userId, Y.userId), ui.tutors.map { it.userId }) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun refresh_handlesErrors_and_setsErrorMessage() = runTest { + val failingRepo = FakeRepo(throwOnGetAll = true) + val vm = newVm(failingRepo) + vm.refresh() + advanceUntilIdle() + + val ui = vm.ui.value + assertFalse(ui.isLoading) + assertNotNull(ui.error) + assertTrue(ui.tutors.isEmpty()) + } +} diff --git a/app/src/test/java/com/android/sample/utils/AuthUtils.kt b/app/src/test/java/com/android/sample/utils/AuthUtils.kt new file mode 100644 index 00000000..5b7216d7 --- /dev/null +++ b/app/src/test/java/com/android/sample/utils/AuthUtils.kt @@ -0,0 +1,73 @@ +package com.github.se.bootcamp.utils + +import android.content.Context +import android.util.Base64 +import androidx.core.os.bundleOf +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential.Companion.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import org.json.JSONObject + +object FakeJwtGenerator { + private var _counter = 0 + private val counter + get() = _counter++ + + private fun base64UrlEncode(input: ByteArray): String { + return Base64.encodeToString(input, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + } + + fun createFakeGoogleIdToken(name: String, email: String): String { + val header = JSONObject(mapOf("alg" to "none")) + val payload = + JSONObject( + mapOf( + "sub" to counter.toString(), + "email" to email, + "name" to name, + "picture" to "http://example.com/avatar.png")) + + val headerEncoded = base64UrlEncode(header.toString().toByteArray()) + val payloadEncoded = base64UrlEncode(payload.toString().toByteArray()) + + // Signature can be anything, emulator doesn't check it + val signature = "sig" + + return "$headerEncoded.$payloadEncoded.$signature" + } +} + +class FakeCredentialManager private constructor(private val context: Context) : + CredentialManager by CredentialManager.create(context) { + companion object { + // Creates a mock CredentialManager that always returns a CustomCredential + // containing the given fakeUserIdToken when getCredential() is called. + fun create(fakeUserIdToken: String): CredentialManager { + mockkObject(GoogleIdTokenCredential) + val googleIdTokenCredential = mockk() + every { googleIdTokenCredential.idToken } returns fakeUserIdToken + every { GoogleIdTokenCredential.createFrom(any()) } returns googleIdTokenCredential + val fakeCredentialManager = mockk() + val mockGetCredentialResponse = mockk() + + val fakeCustomCredential = + CustomCredential( + type = TYPE_GOOGLE_ID_TOKEN_CREDENTIAL, + data = bundleOf("id_token" to fakeUserIdToken)) + + every { mockGetCredentialResponse.credential } returns fakeCustomCredential + coEvery { + fakeCredentialManager.getCredential(any(), any()) + } returns mockGetCredentialResponse + + return fakeCredentialManager + } + } +} diff --git a/app/src/test/java/com/android/sample/utils/FirebaseEmulator.kt b/app/src/test/java/com/android/sample/utils/FirebaseEmulator.kt new file mode 100644 index 00000000..76e82baf --- /dev/null +++ b/app/src/test/java/com/android/sample/utils/FirebaseEmulator.kt @@ -0,0 +1,151 @@ +package com.github.se.bootcamp.utils + +import android.util.Log +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.ktx.auth +import com.google.firebase.firestore.ktx.firestore +import com.google.firebase.ktx.Firebase // Changed import +import io.mockk.InternalPlatformDsl.toArray +import java.util.concurrent.TimeUnit +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +object FirebaseEmulator { + val auth by lazy { Firebase.auth } + val firestore by lazy { Firebase.firestore } + + const val HOST = "localhost" + const val EMULATORS_PORT = 4400 + const val FIRESTORE_PORT = 8080 + const val AUTH_PORT = 9099 + + private val projectID by lazy { FirebaseApp.getInstance().options.projectId!! } + + private val httpClient = + OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + + private val firestoreEndpoint by lazy { + "http://$HOST:$FIRESTORE_PORT/emulator/v1/projects/$projectID/databases/(default)/documents" + } + private val authEndpoint by lazy { + "http://$HOST:$AUTH_PORT/emulator/v1/projects/$projectID/accounts" + } + private val emulatorsEndpoint = "http://$HOST:$EMULATORS_PORT/emulators" + + var isRunning = false + private set + + fun connect() { + if (isRunning) return + + isRunning = areEmulatorsRunning() + if (isRunning) { + auth.useEmulator(HOST, AUTH_PORT) + firestore.useEmulator(HOST, FIRESTORE_PORT) + } + } + + private fun areEmulatorsRunning(): Boolean = + runCatching { + val request = Request.Builder().url(emulatorsEndpoint).build() + httpClient.newCall(request).execute().isSuccessful + } + .getOrDefault(false) + + private fun clearEmulator(endpoint: String) { + if (!isRunning) return + runCatching { + val request = Request.Builder().url(endpoint).delete().build() + httpClient.newCall(request).execute() + } + .onFailure { + Log.w("FirebaseEmulator", "Failed to clear emulator at $endpoint: ${it.message}") + } + } + + fun clearAuthEmulator() { + clearEmulator(authEndpoint) + } + + fun clearFirestoreEmulator() { + clearEmulator(firestoreEndpoint) + } + + /** + * Seeds a Google user in the Firebase Auth Emulator using a fake JWT id_token. + * + * @param fakeIdToken A JWT-shaped string, must contain at least "sub". + * @param email The email address to associate with the account. + */ + fun createGoogleUser(fakeIdToken: String) { + val url = + "http://$HOST:$AUTH_PORT/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=fake-api-key" + + // postBody must be x-www-form-urlencoded style string, wrapped in JSON + val postBody = "id_token=$fakeIdToken&providerId=google.com" + + val requestJson = + JSONObject().apply { + put("postBody", postBody) + put("requestUri", "http://localhost") + put("returnIdpCredential", true) + put("returnSecureToken", true) + } + + val mediaType = "application/json; charset=utf-8".toMediaType() + val body = requestJson.toString().toRequestBody(mediaType) + + val request = + Request.Builder().url(url).post(body).addHeader("Content-Type", "application/json").build() + + val response = httpClient.newCall(request).execute() + assert(response.isSuccessful) { + "Failed to create user in Auth Emulator: ${response.code} ${response.message}" + } + } + + fun changeEmail(fakeIdToken: String, newEmail: String) { + val response = + httpClient + .newCall( + Request.Builder() + .url( + "http://$HOST:$AUTH_PORT/identitytoolkit.googleapis.com/v1/accounts:update?key=fake-api-key") + .post( + """ + { + "idToken": "$fakeIdToken", + "email": "$newEmail", + "returnSecureToken": true + } + """ + .trimIndent() + .toRequestBody()) + .build()) + .execute() + assert(response.isSuccessful) { + "Failed to change email in Auth Emulator: ${response.code} ${response.message}" + } + } + + val users: String + get() { + val request = + Request.Builder() + .url( + "http://$HOST:$AUTH_PORT/identitytoolkit.googleapis.com/v1/accounts:query?key=fake-api-key") + .build() + + Log.d("FirebaseEmulator", "Fetching users with request: ${request.url.toString()}") + val response = httpClient.newCall(request).execute() + Log.d("FirebaseEmulator", "Response received: ${response.toArray()}") + return response.body.toString() + } +} diff --git a/app/src/test/java/com/android/sample/utils/RepositoryTest.kt b/app/src/test/java/com/android/sample/utils/RepositoryTest.kt new file mode 100644 index 00000000..9f6173fd --- /dev/null +++ b/app/src/test/java/com/android/sample/utils/RepositoryTest.kt @@ -0,0 +1,44 @@ +package com.android.sample.utils + +import com.android.sample.model.booking.BookingRepository +import com.android.sample.model.listing.ListingRepository +import com.android.sample.model.rating.RatingRepository +import com.android.sample.model.user.ProfileRepository +import com.github.se.bootcamp.utils.FirebaseEmulator +import com.google.firebase.FirebaseApp +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +abstract class RepositoryTest { + + // The repository is now a lateinit var, to be initialized by subclasses. + protected lateinit var bookingRepository: BookingRepository + protected lateinit var listingRepository: ListingRepository + protected lateinit var ratingRepository: RatingRepository + protected lateinit var profileRepository: ProfileRepository + protected var testUserId = "test-user-id" + + @Before + open fun setUp() { + val appContext = RuntimeEnvironment.getApplication() + if (FirebaseApp.getApps(appContext).isEmpty()) { + FirebaseApp.initializeApp(appContext) + } + + // Connect to emulators only after FirebaseApp is ready + FirebaseEmulator.connect() + + // The repository will be set for the provider in the subclass's setUp method + } + + @After + open fun tearDown() { + if (FirebaseEmulator.isRunning) { + FirebaseEmulator.auth.signOut() + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index a0985efc..3ae0baa4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,16 @@ plugins { alias(libs.plugins.androidApplication) apply false alias(libs.plugins.jetbrainsKotlinAndroid) apply false + id("com.google.gms.google-services") version "4.4.3" apply false +} + +// Force JaCoCo version globally to support Java 21 +allprojects { + configurations.all { + resolutionStrategy { + force("org.jacoco:org.jacoco.core:0.8.11") + force("org.jacoco:org.jacoco.agent:0.8.11") + force("org.jacoco:org.jacoco.report:0.8.11") + } + } } \ No newline at end of file diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..c8738c2c --- /dev/null +++ b/firebase.json @@ -0,0 +1,14 @@ +{ + "emulators": { + "auth": { + "port": 9099 + }, + "firestore": { + "port": 8080 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true + } +} diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 00000000..20b1781b --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,47 @@ +{ + "indexes": [ + { + "collectionGroup": "bookings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "bookerId", + "order": "ASCENDING" + }, + { + "fieldPath": "sessionStart", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "bookings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "listingCreatorId", + "order": "ASCENDING" + }, + { + "fieldPath": "sessionStart", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "bookings", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "associatedListingId", + "order": "ASCENDING" + }, + { + "fieldPath": "sessionStart", + "order": "ASCENDING" + } + ] + } + ], + "fieldOverrides": [] +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a976b61b..f54c030a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.3.0" -kotlin = "1.8.10" +kotlin = "1.9.0" coreKtx = "1.12.0" ktfmt = "0.17.0" junit = "4.13.2" @@ -13,10 +13,30 @@ composeActivity = "1.8.2" composeViewModel = "2.7.0" lifecycleRuntimeKtx = "2.7.0" kaspresso = "1.5.5" +playServicesAuth = "20.7.0" robolectric = "4.11.1" sonar = "4.4.1.3373" +credentialManager = "1.2.2" +googleIdCredential = "1.1.1" +okhttp = "4.12.0" + +# Testing Libraries +mockito = "5.7.0" +mockitoKotlin = "5.1.0" +mockk = "1.13.8" +coroutinesTest = "1.7.3" +archCoreTesting = "2.2.0" + +# Firebase Libraries +firebaseAuth = "23.0.0" +firebaseAuthKtx = "23.0.0" +firebaseDatabaseKtx = "21.0.0" +firebaseFirestore = "25.1.0" +firebaseUiAuth = "8.0.0" +navigationComposeJvmstubs = "2.9.5" [libraries] +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } 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" } @@ -39,8 +59,29 @@ compose-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifes kaspresso = { group = "com.kaspersky.android-components", name = "kaspresso", version.ref = "kaspresso" } kaspresso-compose = { group = "com.kaspersky.android-components", name = "kaspresso-compose-support", version.ref = "kaspresso" } +play-services-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "playServicesAuth" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +# Credential Manager +androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentialManager" } +androidx-credentials-play-services = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentialManager" } +googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleIdCredential" } + +# Firebase Libraries +firebase-auth = { module = "com.google.firebase:firebase-auth", version.ref = "firebaseAuth" } +firebase-auth-ktx = { module = "com.google.firebase:firebase-auth-ktx", version.ref = "firebaseAuthKtx" } +firebase-database-ktx = { module = "com.google.firebase:firebase-database-ktx", version.ref = "firebaseDatabaseKtx" } +firebase-firestore = { module = "com.google.firebase:firebase-firestore", version.ref = "firebaseFirestore" } +firebase-ui-auth = { module = "com.firebaseui:firebase-ui-auth", version.ref = "firebaseUiAuth" } +androidx-navigation-compose-jvmstubs = { group = "androidx.navigation", name = "navigation-compose-jvmstubs", version.ref = "navigationComposeJvmstubs" } + +# Testing Libraries +mockito = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } +mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockitoKotlin" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } +arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "archCoreTesting" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/gradlew b/gradlew old mode 100644 new mode 100755