diff --git a/README.md b/README.md index 73909c74..5b63195c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The table below catalogs the samples and applications available in this reposito | Sample App | Description | Directory | | :--- | :--- | :--- | | **Android AI Sample Catalog**

![Android AI Sample Catalog](https://developer.android.com/static/ai/assets/images/ai_catalog_screenshot_1440.png) | A standalone showcase application containing multiple standalone, self-contained samples demonstrating various Gemini integration capabilities (hybrid inference, chat, multimodal, summarization, writing assistance, etc.) on Android. | [ai-sample-catalog/](ai-sample-catalog/) | -| **Jetpacker (Coming Soon)** | A sample showing Jetpacker integration and AI-guided capabilities for Android development. | *To be added soon* | +| **Jetpacker**

![Jetpacker Home Screen](jetpacker/screenshots/home.png) | A complete travel planner application leveraging on-device ML Kit AI (for summaries, speech transcription, expense receipt parsing, translation fallback) and cloud-based Firebase AI logic to manage trips, itinerary events, reviews, and bookings. | [jetpacker/](jetpacker/) | --- @@ -19,10 +19,16 @@ The table below catalogs the samples and applications available in this reposito To explore or run a specific sample, navigate to its respective directory and follow the setup instructions inside its local `README.md`. -For example, to run the Android AI Sample Catalog: -1. Open the [ai-sample-catalog/](ai-sample-catalog/) directory in Android Studio.\n2. Follow the Firebase project configuration instructions detailed in the [ai-sample-catalog/README.md](ai-sample-catalog/README.md). +### Running the Android AI Sample Catalog +1. Open the [ai-sample-catalog/](ai-sample-catalog/) directory in Android Studio. +2. Follow the Firebase project configuration instructions detailed in the [ai-sample-catalog/README.md](ai-sample-catalog/README.md). 3. Build and run the `app` configuration. +### Running Jetpacker +1. Open the [jetpacker/android/](jetpacker/android/) directory in Android Studio. +2. Follow the Firebase project and local properties setup instructions in [jetpacker/README.md](jetpacker/README.md). +3. Build and run the `:app` configuration. + --- ## 🤝 Contributions and Feedback diff --git a/jetpacker/.agents/AGENTS.md b/jetpacker/.agents/AGENTS.md new file mode 100644 index 00000000..c2c58114 --- /dev/null +++ b/jetpacker/.agents/AGENTS.md @@ -0,0 +1,6 @@ +# Code Style & Formatting Constraints + +## Avoid Fully Qualified Class Names (FQCN) +When writing or modifying Kotlin, Java, or Compose files: +1. **Always Use Imports**: Never write inline Fully Qualified Class Names (e.g., `com.example.jetpacker.core.flags.FeatureFlags`, `java.time.Instant`) in function bodies, parameter lists, or property definitions. +2. **Explicit File-Level Imports**: Add explicit `import` statements at the top of the file for all referenced types, classes, and utilities. diff --git a/jetpacker/.github/workflows/ci.yml b/jetpacker/.github/workflows/ci.yml new file mode 100644 index 00000000..162e7186 --- /dev/null +++ b/jetpacker/.github/workflows/ci.yml @@ -0,0 +1,152 @@ +name: JetPacker CI + +on: + push: + branches: [ main, main2 ] + pull_request: + branches: [ main, main2 ] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + name: Run Unit Tests + runs-on: ubuntu-24-04-c3-standard-4 + defaults: + run: + working-directory: android + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Set up JDK 17 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a + with: + distribution: 'zulu' + java-version: '17' + + - name: Cache Android SDK + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 + with: + path: /usr/local/lib/android/sdk + key: ${{ runner.os }}-android-sdk + restore-keys: | + ${{ runner.os }}-android-sdk + + - name: Setup Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 + with: + cache-overwrite-existing: true + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Run Unit Tests + run: ./gradlew test --build-cache --stacktrace + + - name: Upload Test Reports + if: always() + uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 + with: + name: unit-test-reports + path: "**/build/reports/tests/test*/" + + screenshot-tests: + name: Run Screenshot Tests + runs-on: ubuntu-24-04-c3-standard-4 + defaults: + run: + working-directory: android + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Set up JDK 17 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a + with: + distribution: 'zulu' + java-version: '17' + + - name: Cache Android SDK + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 + with: + path: /usr/local/lib/android/sdk + key: ${{ runner.os }}-android-sdk + restore-keys: | + ${{ runner.os }}-android-sdk + + - name: Setup Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 + with: + cache-overwrite-existing: true + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Run Screenshot Tests + run: ./gradlew validateDebugScreenshotTest --build-cache --stacktrace + + - name: Upload Screenshot Test Reports + if: always() + uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 + with: + name: screenshot-test-reports + path: | + **/build/reports/screenshotTest/ + **/build/outputs/screenshotTest-results/ + **/build/test-results/validateDebugScreenshotTest/ + + build-apk: + name: Build Debug APK + runs-on: ubuntu-24-04-c3-standard-4 + defaults: + run: + working-directory: android + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Set up JDK 17 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a + with: + distribution: 'zulu' + java-version: '17' + + - name: Cache Android SDK + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 + with: + path: /usr/local/lib/android/sdk + key: ${{ runner.os }}-android-sdk + restore-keys: | + ${{ runner.os }}-android-sdk + + - name: Setup Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 + with: + cache-overwrite-existing: true + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Build Debug APK + run: ./gradlew assembleDebug --build-cache --stacktrace + + - name: Upload Debug APK + uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 + with: + name: jetpacker-debug-apk + path: android/app/build/outputs/apk/debug/*.apk diff --git a/jetpacker/.gitignore b/jetpacker/.gitignore new file mode 100644 index 00000000..e6bbe5e3 --- /dev/null +++ b/jetpacker/.gitignore @@ -0,0 +1,28 @@ +# Built artifacts +**/build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Google Services configuration (contains credentials) +**/google-services.json + +# Log Files +*.log + +# Android Studio local configuration +.idea/ +*.iml + +# Gradle files +.gradle/ + +# Backup files +*.bak +*~ + +# Exported versions +export/ + +# Generated third-party notices +THIRD_PARTY_NOTICES diff --git a/jetpacker/CODE_OF_CONDUCT.md b/jetpacker/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..ac9b3a0c --- /dev/null +++ b/jetpacker/CODE_OF_CONDUCT.md @@ -0,0 +1,16 @@ +# Code of Conduct + +## Our Pledge + +We are committed to providing a friendly, safe, and welcoming environment for all community members, developers, and users of this sample project. + +## Community Standards + +When interacting with this project (such as viewing code, discussing samples, or reference work): +* Be respectful, friendly, and professional. +* Focus on constructive discussions. +* Respect differing opinions and viewpoints. + +## Scope + +We pledge to maintain a safe, welcoming space for anyone who interacts with our samples or contributes to the project. diff --git a/jetpacker/CONTRIBUTING.md b/jetpacker/CONTRIBUTING.md new file mode 100644 index 00000000..f6abfb3c --- /dev/null +++ b/jetpacker/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to JetPacker + +We'd love to accept your patches! Before we can take them, we have to jump a couple of legal hurdles. + +## Contributor License Agreements + +Please fill out either the individual or corporate Contributor License Agreement (CLA). + + * If you are an individual writing original source code and you're sure you own the intellectual property, then you'll need to sign an [individual CLA](https://developers.google.com/open-source/cla/individual). + * If you work for a company that wants to allow you to contribute your work, then you'll need to sign a [corporate CLA](https://developers.google.com/open-source/cla/corporate). + +Follow either of the two links above to access the appropriate CLA and instructions for how to sign and return it. Once we receive it, we'll be able to accept your pull requests. + +***Note***: Only original source code from you and other people who have signed the CLA can be accepted into the main repository. + +## Contributing Code + +We welcome pull requests for bug fixes and small improvements. If you want to contribute a new feature or make a significant change, please open an issue first to discuss it with the maintainers. + +To submit a contribution: +1. Fork the repository. +2. Create a new branch for your changes (`git checkout -b feature/my-new-feature`). +3. Make your changes and ensure they follow the project's code style and guidelines. +4. Run all tests to make sure everything still works. +5. Commit your changes with a clear commit message. +6. Push to your fork and submit a pull request to the `main` branch. + +## Community Guidelines + +This project follows the [Google Open Source Community Guidelines](https://opensource.google/conduct/). + +## Setting Up the Development Environment + +If you wish to run, explore, or modify the code locally: + +### Prerequisites +1. **Android Studio**: Make sure you have the latest version of Android Studio. +2. **Android SDK**: Android SDK with API Level 36. +3. **Java Development Kit (JDK)**: JDK 17 is used for compile and target compatibility. + +### Building the Project +1. Clone the repository: + ```bash + git clone https://github.com/android/ai-samples.git + ``` +2. Open the project in Android Studio. +3. Gradle will automatically sync and resolve dependencies. +4. Run the `:app` module on an emulator or connected device. diff --git a/jetpacker/LICENSE b/jetpacker/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/jetpacker/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/jetpacker/README.md b/jetpacker/README.md new file mode 100644 index 00000000..22789057 --- /dev/null +++ b/jetpacker/README.md @@ -0,0 +1,113 @@ +# JetPacker + + + + + + + + + + + + +
Home ScreenTrip ItineraryFlight Detail
Home ScreenTrip ItineraryFlight Detail Screen
+ +## Overview +JetPacker provides users with powerful tools to manage their upcoming trips, build out rich itineraries, record voice notes, manage travel expenses, generate on-device "Trip Summaries and Tips", generate AI reviews, chat with hotel staff via automatic translation, and get real-time museum assistant guidance. + +## Architecture +This project is built using modern Android architecture components: +- **UI**: Jetpack Compose +- **Dependency Injection**: Dagger/Hilt +- **Local Persistence**: Room Database +- **State Management**: ViewModels with StateFlow +- **On-Device AI**: ML Kit GenAI (Prompt, Speech Recognition, Translation) +- **Cloud & Hybrid AI**: Firebase AI Logic (Gemini grounded with URL/Maps/Search, and hybrid models with on-device fallback) +- **App Security**: Firebase App Check (with Play Integrity and Debug Provider) + +## Module Overview +JetPacker follows a clean, multi-module Android structure organized by responsibility and domain: + +### Core Modules (`:core:*`) +- **`:core:ui`**: Shared Jetpack Compose design system components (`JetPackerFab`, `JetPackerToolbar`, themes, custom typography). +- **`:core:flags`**: Centralized feature toggle system (`FeatureFlags`) controlling optional runtime capabilities. +- **`:core:speech`**: On-device speech recognition wrappers and audio processing utilities. + +### Data Modules (`:data:*`) +- **`:data:db`**: Room database configuration, entities, and local DAOs. +- **`:data:trips`**: Repository layer and data models managing high-level trip records. +- **`:data:itinerary`**: Repository layer and data models managing daily itinerary events (`TimelineEvent`, `EventType`). + +### Feature Modules (`:feature:*`) +- **`:feature:home`**: Dashboard screen displaying the user's upcoming trips list. +- **`:feature:create_trip`**: Unified trip form module handling both new trip creation and existing trip editing (`EditTripScreen`). +- **`:feature:detail`**: Detailed view screens for specific itinerary events (Flights, Hotels, Restaurants, Museums, Tours). +- **`:feature:trip`**: Top-level trip container shell (`TripScreen`) holding bottom navigation and orchestrating trip sub-tabs. + - **`:feature:trip:itinerary`**: Pure itinerary timeline screen displaying daily scheduled events. + - **`:feature:trip:itinerary:enrichment`**: On-device AI summaries and tips (`TripSummaryAndTipsCard`) and dynamic daily theme generators. + - **`:feature:trip:expenses`**: Expense tracking screen and automated receipt parser. + - **`:feature:trip:voice_notes`**: Audio voice note recorder and real-time speech-to-text transcription screen. + +## Getting Started + +This project is built using the standard Android Gradle build system, allowing developers to quickly build, run, and experiment with the application locally using Android Studio. + +### Configuration Setup + +1. **Local Properties Setup**: + - Navigate to the `android` directory and copy `local.properties.example` to `local.properties`: + ```bash + cd android + cp local.properties.example local.properties + ``` + - Update `sdk.dir` inside `local.properties` with your local Android SDK directory path. + +2. **Firebase Setup (`google-services.json`)**: + - Register the application in your Firebase Project Console. + - Download the project's custom `google-services.json` and place it in the `android/app/` directory (overwriting the mock placeholder file). + +3. **Firebase App Check Debug Attestation**: + - Run the application on an emulator or a connected device. + - Filter your logcat logs for `DebugAppCheckProvider`. You will see a log similar to: + ```text + Enter this debug secret into the allow list in the Firebase Console for your project: a8c2dd4c-7f7f-4764-b653-ef6c114ba27e + ``` + - Copy the debug token and register it in the **Firebase Console** under **App Check** -> **Apps** -> **Manage Debug Tokens**. + + +### Building Using Gradle +To compile the application and run unit tests using Gradle, execute the following commands in your terminal (from the `android` directory): + +```bash +# Navigate to the android directory +cd android + +# Build a debug APK +./gradlew :app:assembleDebug + +# Run unit tests +./gradlew test +``` + +## On-Device AI Features +JetPacker integrates local on-device AI capabilities using ML Kit. These features run entirely on-device and can be toggled or customized in `android/core/flags/src/main/kotlin/com/example/jetpacker/core/flags/FeatureFlags.kt`: +- **ENABLE_TRIP_SUMMARY_AND_TIPS**: Generates an on-device card summary of the current trip using ML Kit GenAI Prompt. +- **ENABLE_ITINERARY_ENRICHMENT**: Local enrichment for timeline events. +- **ENABLE_EXPENSE_MANAGEMENT**: Local expense management receipt scanner and parser. +- **ENABLE_VOICE_NOTES**: On-device speech recognition and transcription for voice notes. + +## Cloud-Hybrid & Online AI Features +JetPacker also integrates online hybrid features using Firebase AI Logic (Gemini API) and ML Kit: +- **ENABLE_MUSEUM_ASSISTANT**: Museum Assistant chatbot with URL, Maps, and Search grounding (uses Gemini 2.5 flash-lite). +- **ENABLE_REVIEW_GENERATION**: Topic-selected review generator (uses Gemini 2.5 flash-lite on-device with cloud fallback). +- **Hotel Support Chat**: Receives hotel receptionist assistance with real-time ML Kit + Gemini translation. + + +## IDE Setup & Development + +To work on JetPacker locally, open the project in **Android Studio**: + +1. Open Android Studio and select **Open an Existing Project** (or **File > Open**). +2. Select the `android` directory folder within the repository. +3. Android Studio will automatically sync Gradle and prepare the project. diff --git a/jetpacker/android/app/build.gradle.kts b/jetpacker/android/app/build.gradle.kts new file mode 100644 index 00000000..a677752f --- /dev/null +++ b/jetpacker/android/app/build.gradle.kts @@ -0,0 +1,165 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.services) +} + +android { + namespace = "com.example.jetpacker" + compileSdk { + version = release(libs.versions.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.compileSdkMinor.get().toInt() + } + } + + defaultConfig { + applicationId = "com.example.jetpacker" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = libs.versions.versionCode.get().toInt() + versionName = libs.versions.versionName.get() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { + compose = true + buildConfig = true + } + + packaging { + jniLibs { + useLegacyPackaging = false + } + resources { + excludes += "META-INF/DEPENDENCIES" + excludes += "META-INF/LICENSE" + excludes += "META-INF/LICENSE.txt" + excludes += "META-INF/license.txt" + excludes += "META-INF/NOTICE" + excludes += "META-INF/NOTICE.txt" + excludes += "META-INF/notice.txt" + excludes += "META-INF/ASL2.0" + excludes += "META-INF/INDEX.LIST" + } + } +} + +kotlin { jvmToolchain(17) } + +hilt { enableAggregatingTask = false } + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + + implementation(project(":core:flags")) + implementation(project(":core:speech")) + implementation(project(":core:ui")) + implementation(project(":data:db")) + implementation(project(":data:itinerary")) + implementation(project(":data:trips")) + implementation(project(":feature:appfunctions")) + implementation(project(":feature:create_trip")) + implementation(project(":feature:detail")) + implementation(project(":feature:detail:museum_assistant")) + implementation(project(":feature:detail:hotel_chat")) + implementation(project(":feature:detail:review")) + implementation(project(":feature:home")) + implementation(project(":feature:trip")) + implementation(project(":feature:trip:itinerary")) + implementation(libs.accompanist.permissions) + implementation(project(":feature:trip:voice_notes")) + implementation(project(":feature:trip:expenses")) + implementation(project(":feature:trip:itinerary:enrichment")) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.datastore.core) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + "ksp"(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.runtime) + implementation(libs.coil.compose) + implementation(libs.converter.moshi) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.logging.interceptor) + implementation(libs.material) + implementation(libs.moshi.kotlin) + "ksp"(libs.moshi.kotlin.codegen) + implementation(libs.okhttp) + implementation(libs.play.services.location) + implementation(libs.retrofit) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.ai) + implementation(libs.firebase.ai.ondevice) + implementation(libs.firebase.auth.ktx) + implementation(libs.firebase.appcheck.playintegrity) + implementation(libs.firebase.appcheck.debug) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/jetpacker/android/app/proguard-rules.pro b/jetpacker/android/app/proguard-rules.pro new file mode 100644 index 00000000..38cbe8e9 --- /dev/null +++ b/jetpacker/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# Custom Proguard/R8 rules for the app module diff --git a/jetpacker/android/app/src/main/AndroidManifest.xml b/jetpacker/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8da10625 --- /dev/null +++ b/jetpacker/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/JetPackerApplication.kt b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/JetPackerApplication.kt new file mode 100644 index 00000000..0108e18d --- /dev/null +++ b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/JetPackerApplication.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker + +import android.app.Application +import androidx.compose.ui.ComposeUiFlags +import androidx.compose.ui.ExperimentalComposeUiApi +import android.util.Log +import com.example.jetpacker.core.flags.FeatureFlags +import com.google.firebase.Firebase +import com.google.firebase.appcheck.appCheck +import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory +import com.google.firebase.auth.auth +import com.google.firebase.initialize +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class JetPackerApplication : Application() { + + @OptIn(ExperimentalComposeUiApi::class) + override fun onCreate() { + ComposeUiFlags.isMediaQueryIntegrationEnabled = true + super.onCreate() + FeatureFlags.initialize(this) + Firebase.initialize(context = this) + Firebase.appCheck.installAppCheckProviderFactory( + DebugAppCheckProviderFactory.getInstance(), + ) + Firebase.auth.signInAnonymously() + .addOnCompleteListener { task -> + if (task.isSuccessful) { + Log.d("JetPackerApplication", "Anonymous auth successful") + } else { + Log.w("JetPackerApplication", "Anonymous auth failed", task.exception) + } + } + } +} diff --git a/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/MainActivity.kt b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/MainActivity.kt new file mode 100644 index 00000000..8577136a --- /dev/null +++ b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/MainActivity.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.hardware.Sensor +import android.hardware.SensorManager +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.ui.navigation.JetPackerNavGraph +import com.example.jetpacker.ui.navigation.Navigator +import com.example.jetpacker.ui.navigation.Screen +import com.example.jetpacker.ui.navigation.rememberNavigationState +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + handleFeatureFlags() + + setContent { + JetPackerTheme { + val navigationState = rememberNavigationState(startRoute = Screen.MyTrips) + val navigator = remember(navigationState) { Navigator(navigationState) } + + SetupShakeDetection(navigator) + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + JetPackerNavGraph(navigationState = navigationState, navigator = navigator) + } + } + } + } + + private fun handleFeatureFlags() { + val overrideTime = intent?.getLongExtra(FeatureFlags.EXTRA_OVERRIDE_TIME, 0L) ?: 0L + if (overrideTime > 0L) { + FeatureFlags.putLongFlag(this, FeatureFlags.KEY_OVERRIDE_CURRENT_TIME_MILLIS, overrideTime) + } + } + + @Composable + private fun SetupShakeDetection(navigator: Navigator) { + val context = LocalContext.current + val isDebuggable = + remember(context) { + (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + } + + DisposableEffect(isDebuggable) { + if (!isDebuggable) return@DisposableEffect onDispose {} + val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + val shakeDetector = ShakeDetector { + navigator.navigate(Screen.Debug) + } + accelerometer?.let { + sensorManager.registerListener(shakeDetector, it, SensorManager.SENSOR_DELAY_UI) + } + onDispose { sensorManager.unregisterListener(shakeDetector) } + } + } +} diff --git a/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ShakeDetector.kt b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ShakeDetector.kt new file mode 100644 index 00000000..211a0025 --- /dev/null +++ b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ShakeDetector.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker + +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import kotlin.math.abs + +class ShakeDetector( + private val shakeThreshold: Float = ShakeDetector.DEFAULT_SHAKE_THRESHOLD, + private val onShake: () -> Unit, +) : SensorEventListener { + + private data class Acceleration(val x: Float, val y: Float, val z: Float) { + fun distanceTo(other: Acceleration): Float = + abs(x - other.x) + abs(y - other.y) + abs(z - other.z) + } + + private var lastUpdate = System.currentTimeMillis() + private var lastAcc: Acceleration? = null + + override fun onSensorChanged(event: SensorEvent) { + if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) { + val now = System.currentTimeMillis() + val currAcc = Acceleration(event.values[0], event.values[1], event.values[2]) + + if (lastAcc == null) { + lastAcc = currAcc + lastUpdate = now + return + } + + val elapsed = now - lastUpdate + if (elapsed > 100) { + lastUpdate = now + + val speed = currAcc.distanceTo(lastAcc!!) / elapsed * 10000 + if (speed > shakeThreshold) { + onShake() + } + + lastAcc = currAcc + } + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + + companion object { + const val DEFAULT_SHAKE_THRESHOLD = 3000f + } +} diff --git a/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ui/navigation/NavGraph.kt b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ui/navigation/NavGraph.kt new file mode 100644 index 00000000..35f011be --- /dev/null +++ b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ui/navigation/NavGraph.kt @@ -0,0 +1,182 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.ui.navigation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.feature.create_trip.CreateTripScreen +import com.example.jetpacker.feature.detail.FlightDetailScreen +import com.example.jetpacker.feature.detail.HotelDetailScreen +import com.example.jetpacker.feature.detail.MuseumDetailScreen +import com.example.jetpacker.feature.detail.RestaurantDetailScreen +import com.example.jetpacker.feature.detail.TourDetailScreen +import com.example.jetpacker.feature.home.DebugScreen +import com.example.jetpacker.feature.home.HomeScreen +import com.example.jetpacker.feature.itinerary.ItineraryScreen +import com.example.jetpacker.feature.itinerary.ItineraryViewModel +import com.example.jetpacker.feature.expenses.ManageExpensesScreen +import com.example.jetpacker.feature.trip.TripScreen +import com.example.jetpacker.feature.voice_notes.VoiceNotesScreen +import com.example.jetpacker.feature.detail.museum_assistant.ChatbotScreen +import com.example.jetpacker.feature.detail.review.ReviewScreen +import com.example.jetpacker.feature.detail.hotel_chat.HotelSupportChat +import kotlinx.serialization.Serializable + +sealed interface Screen : NavKey { + @Serializable data object CreateTrip : Screen + @Serializable data object Debug : Screen + @Serializable data class EditTrip(val tripId: String) : Screen + @Serializable data class FlightDetail(val eventId: String) : Screen + @Serializable data class HotelDetail(val eventId: String) : Screen + @Serializable data object ManageExpenses : Screen + @Serializable data class MuseumDetail(val eventId: String) : Screen + @Serializable data object MyTrips : Screen + @Serializable data class RestaurantDetail(val eventId: String) : Screen + @Serializable data class Timeline(val tripId: String) : Screen + @Serializable data class TourDetail(val eventId: String) : Screen + @Serializable data class VoiceNotes(val tripId: String) : Screen + @Serializable data class Assistant(val eventId: String) : Screen + @Serializable data class ReviewScreen(val placeId: String, val placeName: String) : Screen + @Serializable data class HotelChat(val hotelName: String, val language: String) : Screen +} + +@Composable +fun JetPackerNavGraph( + navigationState: NavigationState, + navigator: Navigator, +) { + val entryProvider = remember(navigator) { + entryProvider { + entry { + CreateTripScreen( + onBack = { navigator.goBack() }, + onTripCreated = { tripId -> + navigator.goBack() + navigator.navigate(Screen.Timeline(tripId)) + }, + ) + } + entry { DebugScreen(onBack = { navigator.goBack() }) } + entry { key -> + CreateTripScreen( + onBack = { navigator.goBack() }, + onTripCreated = { _ -> + navigator.goBack() + }, + tripIdToEdit = key.tripId, + ) + } + entry { key -> + FlightDetailScreen(eventId = key.eventId, onBack = { navigator.goBack() }) + } + entry { key -> + HotelDetailScreen( + eventId = key.eventId, + onBack = { navigator.goBack() }, + onOpenHotelChat = { hotelName, language -> + navigator.navigate(Screen.HotelChat(hotelName, language)) + } + ) + } + entry { key -> + MuseumDetailScreen( + eventId = key.eventId, + onBack = { navigator.goBack() }, + onOpenAssistant = { eventId -> + navigator.navigate(Screen.Assistant(eventId)) + } + ) + } + entry { + HomeScreen( + onTripClick = { tripId -> navigator.navigate(Screen.Timeline(tripId)) }, + onTripCreated = { tripId -> navigator.navigate(Screen.Timeline(tripId)) }, + onNavigateToDebug = { navigator.navigate(Screen.Debug) }, + onCreateTripClick = { navigator.navigate(Screen.CreateTrip) }, + ) + } + entry { key -> + RestaurantDetailScreen( + eventId = key.eventId, + onBack = { navigator.goBack() }, + onOpenReviewScreen = { placeId, placeName -> + navigator.navigate(Screen.ReviewScreen(placeId, placeName)) + } + ) + } + entry { key -> + val tripId = key.tripId + TripScreen( + tripId = tripId, + onBack = { navigator.goBack() }, + onEditTripClick = { navigator.navigate(Screen.EditTrip(tripId)) }, + onEventClick = { eventId, eventType -> + when (eventType) { + EventType.TRANSPORTATION -> navigator.navigate(Screen.FlightDetail(eventId)) + EventType.FOOD_AND_DRINK -> navigator.navigate(Screen.RestaurantDetail(eventId)) + EventType.ACCOMMODATION -> navigator.navigate(Screen.HotelDetail(eventId)) + EventType.CULTURE -> navigator.navigate(Screen.MuseumDetail(eventId)) + else -> navigator.navigate(Screen.TourDetail(eventId)) + } + }, + onVoiceNotesClick = { navigator.navigate(Screen.VoiceNotes(tripId)) }, + onNavigateToDebug = { navigator.navigate(Screen.Debug) }, + ) + } + entry { key -> + TourDetailScreen(eventId = key.eventId, onBack = { navigator.goBack() }) + } + entry { key -> + ChatbotScreen(eventId = key.eventId, onBack = { navigator.goBack() }) + } + entry { key -> + ReviewScreen(placeId = key.placeId, placeName = key.placeName, onBack = { navigator.goBack() }) + } + entry { key -> + HotelSupportChat(hotelName = key.hotelName, language = key.language, onBack = { navigator.goBack() }) + } + entry { key -> + VoiceNotesScreen( + tripId = key.tripId, + contentPadding = PaddingValues(0.dp), + onBack = { navigator.goBack() }, + ) + } + entry { + ManageExpensesScreen( + contentPadding = PaddingValues(0.dp), + onBack = { navigator.goBack() }, + ) + } + } + } + + NavDisplay( + backStack = navigationState, + onBack = { navigator.goBack() }, + entryProvider = entryProvider, + ) +} diff --git a/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ui/navigation/NavigationState.kt b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ui/navigation/NavigationState.kt new file mode 100644 index 00000000..61d10c97 --- /dev/null +++ b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ui/navigation/NavigationState.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack + +typealias NavigationState = NavBackStack + +@Composable +fun rememberNavigationState(startRoute: Screen): NavigationState { + return rememberNavBackStack(startRoute) +} diff --git a/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ui/navigation/Navigator.kt b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ui/navigation/Navigator.kt new file mode 100644 index 00000000..3cc395ef --- /dev/null +++ b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/ui/navigation/Navigator.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.ui.navigation + +import androidx.navigation3.runtime.NavKey + +class Navigator(val state: NavigationState) { + fun navigate(route: NavKey) { + state.add(route) + } + + fun goBack() { + if (state.size > 1) { + state.removeLastOrNull() + } + } +} diff --git a/jetpacker/android/app/src/main/res/drawable/ic_launcher_background.xml b/jetpacker/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..4088aa13 --- /dev/null +++ b/jetpacker/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/jetpacker/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/jetpacker/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..094d1cc7 --- /dev/null +++ b/jetpacker/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + diff --git a/jetpacker/android/app/src/main/res/drawable/placeholder.png b/jetpacker/android/app/src/main/res/drawable/placeholder.png new file mode 100644 index 00000000..2d955901 Binary files /dev/null and b/jetpacker/android/app/src/main/res/drawable/placeholder.png differ diff --git a/jetpacker/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/jetpacker/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..aa00fa9d --- /dev/null +++ b/jetpacker/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/jetpacker/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/jetpacker/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..dec8f79c Binary files /dev/null and b/jetpacker/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/jetpacker/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/jetpacker/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..05e7f912 Binary files /dev/null and b/jetpacker/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/jetpacker/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/jetpacker/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/jetpacker/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/jetpacker/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/jetpacker/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..4ad761d5 Binary files /dev/null and b/jetpacker/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/jetpacker/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/jetpacker/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..cc88d1fd Binary files /dev/null and b/jetpacker/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/jetpacker/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/jetpacker/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..90d056c7 Binary files /dev/null and b/jetpacker/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/jetpacker/android/app/src/main/res/values-night/colors.xml b/jetpacker/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..bf225f5a --- /dev/null +++ b/jetpacker/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,20 @@ + + + + + #141412 + diff --git a/jetpacker/android/app/src/main/res/values/colors.xml b/jetpacker/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..19351f15 --- /dev/null +++ b/jetpacker/android/app/src/main/res/values/colors.xml @@ -0,0 +1,27 @@ + + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #FCF8E5 + \ No newline at end of file diff --git a/jetpacker/android/app/src/main/res/values/strings.xml b/jetpacker/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..4ff4654e --- /dev/null +++ b/jetpacker/android/app/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + JetPacker + Travel planning and itinerary management. + \ No newline at end of file diff --git a/jetpacker/android/app/src/main/res/values/themes.xml b/jetpacker/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..b572d416 --- /dev/null +++ b/jetpacker/android/app/src/main/res/values/themes.xml @@ -0,0 +1,22 @@ + + + + + \ No newline at end of file diff --git a/jetpacker/android/app/src/main/res/xml/app_metadata.xml b/jetpacker/android/app/src/main/res/xml/app_metadata.xml new file mode 100644 index 00000000..293d0c00 --- /dev/null +++ b/jetpacker/android/app/src/main/res/xml/app_metadata.xml @@ -0,0 +1,8 @@ + + diff --git a/jetpacker/android/app/src/main/res/xml/backup_rules.xml b/jetpacker/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..76e27e69 --- /dev/null +++ b/jetpacker/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,28 @@ + + + + + \ No newline at end of file diff --git a/jetpacker/android/app/src/main/res/xml/data_extraction_rules.xml b/jetpacker/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..04ffb616 --- /dev/null +++ b/jetpacker/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/jetpacker/android/build.gradle.kts b/jetpacker/android/build.gradle.kts new file mode 100644 index 00000000..6466fcd1 --- /dev/null +++ b/jetpacker/android/build.gradle.kts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.android.compose.screenshot) apply false + alias(libs.plugins.hilt.android) apply false + alias(libs.plugins.google.devtools.ksp) apply false + alias(libs.plugins.google.services) apply false + alias(libs.plugins.dependency.license.report) +} + +subprojects { + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + freeCompilerArgs.add("-Xannotation-default-target=param-property") + } + } + tasks.withType().configureEach { + failOnNoDiscoveredTests = false + } + configurations.all { + exclude(group = "com.google.protobuf", module = "protobuf-java") + resolutionStrategy.eachDependency { + if (requested.group == "com.google.firebase" && requested.name == "firebase-ai") { + useVersion("17.10.1") + } + } + } +} + +licenseReport { + outputDir = layout.buildDirectory.dir("reports/dependency-license").get().asFile.absolutePath + projects = subprojects.toTypedArray() + renderers = arrayOf(com.github.jk1.license.render.JsonReportRenderer("licenses.json")) +} + + diff --git a/jetpacker/android/core/flags/build.gradle.kts b/jetpacker/android/core/flags/build.gradle.kts new file mode 100644 index 00000000..9affb404 --- /dev/null +++ b/jetpacker/android/core/flags/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.example.jetpacker.core.flags" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } +} + +kotlin { jvmToolchain(17) } + +dependencies { + implementation(libs.androidx.core.ktx) +} diff --git a/jetpacker/android/core/flags/src/main/kotlin/com/example/jetpacker/core/flags/FeatureFlags.kt b/jetpacker/android/core/flags/src/main/kotlin/com/example/jetpacker/core/flags/FeatureFlags.kt new file mode 100644 index 00000000..fb66dffc --- /dev/null +++ b/jetpacker/android/core/flags/src/main/kotlin/com/example/jetpacker/core/flags/FeatureFlags.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.flags + +import android.content.Context + +object FeatureFlags { + const val KEY_DEMO_LANGUAGE = "demo_language" + const val KEY_OVERRIDE_CURRENT_TIME_MILLIS = "override_current_time_millis" + const val EXTRA_OVERRIDE_TIME = "override_time" + const val KEY_ENABLE_TRIP_SUMMARY_AND_TIPS = "enable_trip_summary_and_tips" + const val KEY_ENABLE_SURPRISE_ME = "enable_surprise_me" + const val KEY_ENABLE_ITINERARY_ENRICHMENT = "enable_itinerary_enrichment" + const val KEY_ENABLE_EXPENSE_MANAGEMENT = "enable_expense_management" + const val KEY_ENABLE_VOICE_NOTES = "enable_voice_notes" + const val KEY_ENABLE_REVIEW_GENERATION = "enable_review_generation" + const val KEY_ENABLE_MUSEUM_ASSISTANT = "enable_museum_assistant" + const val KEY_ENABLE_URL_GROUNDING = "enable_url_grounding" + const val KEY_ENABLE_SEARCH_GROUNDING = "enable_search_grounding" + + private var appContext: Context? = null + + fun initialize(context: Context) { + appContext = context.applicationContext + } + + private fun readFlag(keyName: String, defaultValue: Boolean): Boolean { + val context = appContext ?: return defaultValue + return context + .getSharedPreferences("debug_settings", Context.MODE_PRIVATE) + .getBoolean(keyName, defaultValue) + } + + private fun readStringFlag(keyName: String, defaultValue: String): String { + val context = appContext ?: return defaultValue + return context + .getSharedPreferences("debug_settings", Context.MODE_PRIVATE) + .getString(keyName, defaultValue) ?: defaultValue + } + + // Voice / Demo Language Settings + val DEFAULT_DEMO_LANGUAGE = "nl" + + val DEMO_LANGUAGE: String + get() = readStringFlag(KEY_DEMO_LANGUAGE, DEFAULT_DEMO_LANGUAGE) + + // Existing flags + val ENABLE_TRIP_SUMMARY_AND_TIPS: Boolean + get() = readFlag(KEY_ENABLE_TRIP_SUMMARY_AND_TIPS, true) + + val ENABLE_SURPRISE_ME: Boolean + get() = readFlag(KEY_ENABLE_SURPRISE_ME, false) + + // Offline features + val ENABLE_ITINERARY_ENRICHMENT: Boolean + get() = readFlag(KEY_ENABLE_ITINERARY_ENRICHMENT, true) + + val ENABLE_EXPENSE_MANAGEMENT: Boolean + get() = readFlag(KEY_ENABLE_EXPENSE_MANAGEMENT, true) + + val ENABLE_VOICE_NOTES: Boolean + get() = readFlag(KEY_ENABLE_VOICE_NOTES, true) + + // Online features + val ENABLE_REVIEW_GENERATION: Boolean + get() = readFlag(KEY_ENABLE_REVIEW_GENERATION, true) + + val ENABLE_MUSEUM_ASSISTANT: Boolean + get() = readFlag(KEY_ENABLE_MUSEUM_ASSISTANT, true) + + val ENABLE_URL_GROUNDING: Boolean + get() = readFlag(KEY_ENABLE_URL_GROUNDING, true) + + val ENABLE_SEARCH_GROUNDING: Boolean + get() = readFlag(KEY_ENABLE_SEARCH_GROUNDING, true) + private fun readLongFlag(keyName: String, defaultValue: Long): Long { + val context = appContext ?: return defaultValue + return context + .getSharedPreferences("debug_settings", Context.MODE_PRIVATE) + .getLong(keyName, defaultValue) + } + + val OVERRIDE_CURRENT_TIME_MILLIS: Long? + get() { + val valMillis = readLongFlag(KEY_OVERRIDE_CURRENT_TIME_MILLIS, 0L) + return if (valMillis > 0L) valMillis else null + } + + fun toggleFlag(context: Context, keyName: String, value: Boolean) { + context + .getSharedPreferences("debug_settings", Context.MODE_PRIVATE) + .edit() + .putBoolean(keyName, value) + .apply() + } + + fun putStringFlag(context: Context, keyName: String, value: String) { + context + .getSharedPreferences("debug_settings", Context.MODE_PRIVATE) + .edit() + .putString(keyName, value) + .apply() + } + + fun putLongFlag(context: Context, keyName: String, value: Long) { + context + .getSharedPreferences("debug_settings", Context.MODE_PRIVATE) + .edit() + .putLong(keyName, value) + .apply() + } +} diff --git a/jetpacker/android/core/speech/build.gradle.kts b/jetpacker/android/core/speech/build.gradle.kts new file mode 100644 index 00000000..fe9cac24 --- /dev/null +++ b/jetpacker/android/core/speech/build.gradle.kts @@ -0,0 +1,51 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) +} + +android { + namespace = "com.example.jetpacker.core.speech" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { jvmToolchain(17) } + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) + implementation(libs.mlkit.genai.speech) + implementation(libs.mlkit.translate) + implementation(project(":core:flags")) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.robolectric) +} diff --git a/jetpacker/android/core/speech/src/main/kotlin/com/example/jetpacker/core/speech/VoiceInputManager.kt b/jetpacker/android/core/speech/src/main/kotlin/com/example/jetpacker/core/speech/VoiceInputManager.kt new file mode 100644 index 00000000..f1ea8296 --- /dev/null +++ b/jetpacker/android/core/speech/src/main/kotlin/com/example/jetpacker/core/speech/VoiceInputManager.kt @@ -0,0 +1,712 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.speech + +import android.annotation.SuppressLint +import android.util.Log +import com.example.jetpacker.core.flags.FeatureFlags +import com.google.mlkit.genai.common.DownloadStatus +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.common.audio.AudioSource +import com.google.mlkit.genai.speechrecognition.SpeechRecognition +import com.google.mlkit.genai.speechrecognition.SpeechRecognizer +import com.google.mlkit.genai.speechrecognition.SpeechRecognizerOptions +import com.google.mlkit.genai.speechrecognition.SpeechRecognizerResponse +import com.google.mlkit.genai.speechrecognition.speechRecognizerOptions +import com.google.mlkit.genai.speechrecognition.speechRecognizerRequest +import com.google.mlkit.nl.translate.TranslateLanguage +import com.google.mlkit.nl.translate.Translation +import com.google.mlkit.nl.translate.Translator +import com.google.mlkit.nl.translate.TranslatorOptions +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +@Singleton +@SuppressLint("NewApi", "GlobalCoroutineDispatchers") +class VoiceInputManager @Inject constructor() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val _uiState = MutableStateFlow(VoiceInputState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var speechRecognizer: SpeechRecognizer? = null + private var translator: Translator? = null + private var listenJob: Job? = null + private var hasRecordAudioPermission = false + private var autoStartListeningWhenReady = false + private var queuedOnResult: ((original: String, translated: String) -> Unit)? = null + + init { + initSpeechAndTranslation() + } + + private var initializedLanguage: String? = null + + private fun initSpeechAndTranslation() { + val currentLang = FeatureFlags.DEMO_LANGUAGE + val isTranslatorReady = currentLang == "en" || translator != null + Log.d( + "VoiceInputManager", + "initSpeechAndTranslation: currentLang=$currentLang, isTranslatorReady=$isTranslatorReady, speechRecognizer=${speechRecognizer != null}, initializedLanguage=$initializedLanguage", + ) + + if (isTranslatorReady && speechRecognizer != null && initializedLanguage == currentLang) { + Log.d( + "VoiceInputManager", + "initSpeechAndTranslation: Returning early since clients are already warm and matching current language.", + ) + return + } + + val isLangChange = initializedLanguage != null && initializedLanguage != currentLang + + if (isLangChange) { + Log.d( + "VoiceInputManager", + "initSpeechAndTranslation: Language change detected, resetting ready state to allow download indicator flow.", + ) + _uiState.value = _uiState.value.copy(isReady = false) + try { + translator?.close() + translator = null + } catch (e: Exception) { + Log.e( + "VoiceInputManager", + "initSpeechAndTranslation: Error releasing existing translator.", + e, + ) + } + } + + try { + Log.d( + "VoiceInputManager", + "initSpeechAndTranslation: Closing previous speechRecognizer for fresh rebind.", + ) + speechRecognizer?.close() + speechRecognizer = null + } catch (e: Exception) { + Log.e( + "VoiceInputManager", + "initSpeechAndTranslation: Error releasing existing speechRecognizer.", + e, + ) + } + + initializedLanguage = currentLang + + try { + if (currentLang != "en" && translator == null) { + Log.d( + "VoiceInputManager", + "initSpeechAndTranslation: Constructing Translator for language: $currentLang", + ) + val options = + TranslatorOptions.Builder() + .setSourceLanguage(currentLang) + .setTargetLanguage(TranslateLanguage.ENGLISH) + .build() + val client = Translation.getClient(options) + client.downloadModelIfNeeded().addOnSuccessListener { + Log.d( + "VoiceInputManager", + "initSpeechAndTranslation: Translator model downloaded/cached successfully.", + ) + translator = client + } + } + + Log.d( + "VoiceInputManager", + "initSpeechAndTranslation: Constructing fresh SpeechRecognizer for language: $currentLang", + ) + val speechOptions = speechRecognizerOptions { + locale = Locale(currentLang) + preferredMode = SpeechRecognizerOptions.Mode.MODE_ADVANCED + } + speechRecognizer = SpeechRecognition.getClient(speechOptions) + + if (isLangChange) { + Log.d( + "VoiceInputManager", + "initSpeechAndTranslation: Language changed, checking model status to warm it up.", + ) + checkModelStatus() + } + } catch (e: Exception) { + Log.e( + "VoiceInputManager", + "initSpeechAndTranslation: Failed to build ML Kit clients asynchronously (common in tests).", + e, + ) + } + } + + private fun checkModelStatus() { + val recognizer = + speechRecognizer + ?: run { + Log.w( + "VoiceInputManager", + "checkModelStatus: Bypassed check because speechRecognizer is null.", + ) + return + } + scope.launch { + Log.d("VoiceInputManager", "checkModelStatus: Checking speech model status asynchronously...") + _uiState.value = _uiState.value.copy(statusText = "Checking model status...", isReady = false) + val status = recognizer.checkStatus() + Log.d("VoiceInputManager", "checkModelStatus: Speech model status resolved to: $status") + if (status == FeatureStatus.DOWNLOADABLE) { + Log.d( + "VoiceInputManager", + "checkModelStatus: Speech model is downloadable, starting collectors flow...", + ) + _uiState.value = _uiState.value.copy(statusText = "Downloading model...", isReady = false) + recognizer.download().collect { downloadStatus -> + Log.d("VoiceInputManager", "checkModelStatus collect: downloadStatus=$downloadStatus") + when (downloadStatus) { + is DownloadStatus.DownloadCompleted -> { + Log.d( + "VoiceInputManager", + "checkModelStatus collect: Download completed successfully!", + ) + _uiState.value = _uiState.value.copy(statusText = "Model ready!", isReady = true) + triggerListeningIfQueued() + + // Preemptively warm up ASR model loading in background to displace other concurrent + // isolated AI/LLM models in AICore memory + scope.launch { + Log.d( + "VoiceInputManager", + "checkModelStatus: Preemptively warming up SpeechRecognizer model...", + ) + try { + // recognizer.warmup() + Log.d( + "VoiceInputManager", + "checkModelStatus: SpeechRecognizer model warm up completed successfully.", + ) + } catch (e: Exception) { + Log.e( + "VoiceInputManager", + "checkModelStatus: Preemptive SpeechRecognizer warm up failed.", + e, + ) + } + } + } + is DownloadStatus.DownloadFailed -> { + Log.e("VoiceInputManager", "checkModelStatus collect: Download failed.") + _uiState.value = + _uiState.value.copy( + statusText = + "Download failed. Please check your internet connection and try again.", + isReady = false, + isError = true, + showDialog = true, + ) + } + else -> + _uiState.value = _uiState.value.copy(statusText = "Downloading...", isReady = false) + } + } + } else if (status == FeatureStatus.AVAILABLE) { + Log.d( + "VoiceInputManager", + "checkModelStatus: Speech model is fully available locally. Making ready.", + ) + _uiState.value = _uiState.value.copy(statusText = "Model ready!", isReady = true) + triggerListeningIfQueued() + + // Preemptively warm up ASR model loading in background to displace other concurrent + // isolated AI/LLM models in AICore memory + scope.launch { + Log.d( + "VoiceInputManager", + "checkModelStatus: Preemptively warming up SpeechRecognizer model...", + ) + try { + // recognizer.warmup() + Log.d( + "VoiceInputManager", + "checkModelStatus: SpeechRecognizer model warm up completed successfully.", + ) + } catch (e: Exception) { + Log.e( + "VoiceInputManager", + "checkModelStatus: Preemptive SpeechRecognizer warm up failed.", + e, + ) + } + } + } else { + Log.w( + "VoiceInputManager", + "checkModelStatus: Speech model is completely unavailable on device (Status: $status).", + ) + _uiState.value = + _uiState.value.copy(statusText = "Model not available (Status: $status)", isReady = false) + } + } + } + + private fun triggerListeningIfQueued() { + Log.d( + "VoiceInputManager", + "triggerListeningIfQueued: Checked. autoStartListeningWhenReady=$autoStartListeningWhenReady, speechRecognizer=${speechRecognizer != null}", + ) + if (autoStartListeningWhenReady) { + Log.d( + "VoiceInputManager", + "triggerListeningIfQueued: Launching deferred auto-start listening session.", + ) + autoStartListeningWhenReady = false + val recognizer = speechRecognizer ?: return + val onResult = queuedOnResult + startListening(recognizer, onResult) + } + } + + fun setPermissionGranted(granted: Boolean) { + hasRecordAudioPermission = granted + if (granted) { + initSpeechAndTranslation() + checkModelStatus() + } + } + + fun handleListenToggle( + onPermissionRequired: () -> Unit, + onResult: ((original: String, translated: String) -> Unit)? = null, + ) { + Log.d( + "VoiceInputManager", + "handleListenToggle: Triggered. isListening=${_uiState.value.isListening}, isReady=${_uiState.value.isReady}", + ) + scope.launch { + if (_uiState.value.isListening) { + Log.d("VoiceInputManager", "handleListenToggle: Stopping current active listening session.") + stopListening(onResult) + return@launch + } + + if (!hasRecordAudioPermission) { + Log.w( + "VoiceInputManager", + "handleListenToggle: Bypassed toggle due to missing RECORD_AUDIO permission.", + ) + onPermissionRequired() + return@launch + } + + initSpeechAndTranslation() + + Log.d( + "VoiceInputManager", + "handleListenToggle: Constructing fresh single-use SpeechRecognizer for instant unshared rebind.", + ) + try { + speechRecognizer?.close() + } catch (e: Exception) {} + + val currentLang = FeatureFlags.DEMO_LANGUAGE + try { + val speechOptions = speechRecognizerOptions { + locale = Locale(currentLang) + preferredMode = SpeechRecognizerOptions.Mode.MODE_ADVANCED + } + speechRecognizer = SpeechRecognition.getClient(speechOptions) + } catch (e: Exception) { + Log.e( + "VoiceInputManager", + "handleListenToggle: Failed to build fresh SpeechRecognizer client.", + e, + ) + speechRecognizer = null + } + + val recognizer = + speechRecognizer + ?: run { + Log.e( + "VoiceInputManager", + "handleListenToggle: Failed because speechRecognizer is null.", + ) + _uiState.value = + _uiState.value.copy( + isListening = false, + isError = true, + showDialog = true, + statusText = "Speech recognizer could not be initialized.", + audioLevel = 0f, + ) + return@launch + } + + if (!_uiState.value.isReady) { + Log.d( + "VoiceInputManager", + "handleListenToggle: Mic triggered but model is not ready. Queuing auto-start listener on status completed.", + ) + autoStartListeningWhenReady = true + queuedOnResult = onResult + _uiState.value = + _uiState.value.copy( + showDialog = true, + statusText = "Speech model is not ready. Initializing...", + ) + checkModelStatus() + return@launch + } + Log.d("VoiceInputManager", "handleListenToggle: Model is ready. Starting listening segment.") + startListening(recognizer, onResult) + } + } + + private fun startListening( + recognizer: SpeechRecognizer, + onResult: ((original: String, translated: String) -> Unit)? = null, + ) { + Log.d( + "VoiceInputManager", + "startListening: Waking up microphone and opening SODA continuous streaming collection.", + ) + _uiState.value = + _uiState.value.copy( + isListening = true, + showDialog = true, + statusText = "Warming up mic...", + transcription = "", + partialTranscription = "", + translatedTranscription = "", + audioLevel = 0f, + isError = false, + ) + listenJob = scope.launch { + var retryCount = 0 + val maxRetries = 5 + var success = false + var currentRecognizer = recognizer + + while (retryCount < maxRetries && !success) { + try { + val request = speechRecognizerRequest { audioSource = AudioSource.fromMic() } + Log.d( + "VoiceInputManager", + "startListening: Invoking startRecognition flow... attempt ${retryCount + 1}", + ) + + var errorOccurred = false + var lastError: Throwable? = null + + var lastVoiceActivityTime = System.currentTimeMillis() + val autoStopJob = scope.launch { + while (isActive) { + kotlinx.coroutines.delay(500) + if (System.currentTimeMillis() - lastVoiceActivityTime > 3000L) { + Log.d("VoiceInputManager", "startListening: Auto-stopping due to silence.") + stopListening(onResult) + break + } + } + } + + currentRecognizer.startRecognition(request).collect { response -> + if ( + _uiState.value.statusText == "Warming up mic..." || + _uiState.value.statusText.startsWith("Mic busy, retrying") + ) { + _uiState.value = _uiState.value.copy(statusText = "Listening...") + } + when (response) { + is SpeechRecognizerResponse.PartialTextResponse -> { + success = true + lastVoiceActivityTime = System.currentTimeMillis() + Log.d( + "VoiceInputManager", + "startRecognition collect: PartialTextResponse='${response.text}'", + ) + _uiState.value = + _uiState.value.copy( + statusText = "Transcribing...", + partialTranscription = response.text, + ) + translateText(_uiState.value.transcription + response.text) + } + is SpeechRecognizerResponse.FinalTextResponse -> { + success = true + lastVoiceActivityTime = System.currentTimeMillis() + Log.d( + "VoiceInputManager", + "startRecognition collect: FinalTextResponse='${response.text}'", + ) + val newTranscription = _uiState.value.transcription + response.text + " " + _uiState.value = + _uiState.value.copy(transcription = newTranscription, partialTranscription = "") + translateText(newTranscription) + } + is SpeechRecognizerResponse.ErrorResponse -> { + Log.e( + "VoiceInputManager", + "startRecognition collect: ErrorResponse: Error ${response.e.message}", + response.e, + ) + errorOccurred = true + lastError = response.e + } + is SpeechRecognizerResponse.CompletedResponse -> { + Log.d( + "VoiceInputManager", + "startRecognition collect: CompletedResponse received naturally from SODA stream.", + ) + if (_uiState.value.isListening) { + finishListening(onResult) + } + success = true + } + else -> { + if (response.javaClass.simpleName == "AudioLevelResponse") { + try { + val field = response.javaClass.getDeclaredField("audioLevel") + field.isAccessible = true + val level = field.get(response) as? Number + if (level != null) { + _uiState.value = _uiState.value.copy(audioLevel = level.toFloat()) + } + } catch (e: Exception) { + Log.e("VoiceInputManager", "Failed to read audio level via reflection", e) + } + } else { + Log.d( + "VoiceInputManager", + "startRecognition collect: Unknown response: $response", + ) + } + } + } + } + + autoStopJob.cancel() + + if (errorOccurred) { + val isRetryable = lastError?.message?.contains("ERROR_TYPE_INVALID_REQUEST") == true + if (isRetryable && retryCount < maxRetries - 1) { + retryCount++ + _uiState.value = + _uiState.value.copy(statusText = "Mic busy, retrying... ($retryCount/$maxRetries)") + Log.w( + "VoiceInputManager", + "Releasing speech recognizer and waiting 1500ms to retry due to INVALID_REQUEST model load...", + ) + try { + currentRecognizer.close() + } catch (e: Exception) {} + kotlinx.coroutines.delay(1500) + val currentLang = FeatureFlags.DEMO_LANGUAGE + try { + val speechOptions = speechRecognizerOptions { + locale = Locale(currentLang) + preferredMode = SpeechRecognizerOptions.Mode.MODE_ADVANCED + } + speechRecognizer = SpeechRecognition.getClient(speechOptions) + currentRecognizer = speechRecognizer!! + } catch (e: Exception) { + Log.e("VoiceInputManager", "Failed to recreate speechRecognizer", e) + } + } else { + _uiState.value = + _uiState.value.copy( + isListening = false, + isError = true, + showDialog = true, + statusText = getUserFriendlyErrorMessage(lastError), + audioLevel = 0f, + ) + break + } + } else { + break + } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e( + "VoiceInputManager", + "startListening exception: Continuous recognition loop failed.", + e, + ) + val isRetryable = e.message?.contains("ERROR_TYPE_INVALID_REQUEST") == true + if (isRetryable && retryCount < maxRetries - 1) { + retryCount++ + _uiState.value = + _uiState.value.copy(statusText = "Mic busy, retrying... ($retryCount/$maxRetries)") + try { + currentRecognizer.close() + } catch (ex: Exception) {} + kotlinx.coroutines.delay(1500) + val currentLang = FeatureFlags.DEMO_LANGUAGE + try { + val speechOptions = speechRecognizerOptions { + locale = Locale(currentLang) + preferredMode = SpeechRecognizerOptions.Mode.MODE_ADVANCED + } + speechRecognizer = SpeechRecognition.getClient(speechOptions) + currentRecognizer = speechRecognizer!! + } catch (ex: Exception) {} + } else { + _uiState.value = + _uiState.value.copy( + isListening = false, + isError = true, + showDialog = true, + statusText = getUserFriendlyErrorMessage(e), + audioLevel = 0f, + ) + break + } + } finally { + Log.d( + "VoiceInputManager", + "startListening: stopping recognizer in attempt loop finalizer.", + ) + try { + currentRecognizer.stopRecognition() + } catch (e: Exception) {} + } + } + } + } + + private suspend fun stopListening( + onResult: ((original: String, translated: String) -> Unit)? = null + ) { + _uiState.value = + _uiState.value.copy( + isListening = false, + showDialog = false, + statusText = "Completed.", + audioLevel = 0f, + ) + try { + speechRecognizer?.stopRecognition() + kotlinx.coroutines.delay(200) + } catch (e: Exception) { + Log.e("VoiceInputManager", "Failed to stop recognition gracefully", e) + } + listenJob?.cancel() + finishListening(onResult) + } + + private fun finishListening(onResult: ((original: String, translated: String) -> Unit)? = null) { + val original = (_uiState.value.transcription + _uiState.value.partialTranscription).trim() + val translated = _uiState.value.translatedTranscription.trim() + val finalResult = translated.ifEmpty { original } + + Log.d("VoiceInputManager", "finishListening: finalResult='$finalResult'") + _uiState.value = + _uiState.value.copy( + isListening = false, + showDialog = false, + statusText = "Completed.", + transcriptionResult = finalResult, + isError = false, + ) + onResult?.invoke(original, translated) + } + + private fun translateText(text: String) { + if (text.isNotEmpty()) { + translator?.translate(text)?.addOnSuccessListener { translatedText -> + _uiState.value = _uiState.value.copy(translatedTranscription = translatedText) + } + } else { + _uiState.value = _uiState.value.copy(translatedTranscription = "") + } + } + + fun clearTranscriptionResult() { + _uiState.value = _uiState.value.copy(transcriptionResult = null) + } + + fun cancelListening() { + Log.d("VoiceInputManager", "cancelListening called") + autoStartListeningWhenReady = false + queuedOnResult = null + _uiState.value = + _uiState.value.copy( + isListening = false, + showDialog = false, + statusText = "Initializing...", + audioLevel = 0f, + isError = false, + ) + val recognizer = speechRecognizer + scope.launch { + try { + recognizer?.stopRecognition() + kotlinx.coroutines.delay(200) + } catch (e: Exception) { + Log.e("VoiceInputManager", "Failed to stop recognition during cancel", e) + } + listenJob?.cancel() + } + } + + suspend fun translate(text: String): String { + if (FeatureFlags.DEMO_LANGUAGE == "en") return text + initSpeechAndTranslation() + val currentTranslator = translator ?: return text + return try { + currentTranslator.translate(text).await() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + text + } + } + + private fun getUserFriendlyErrorMessage(e: Throwable?): String { + val msg = e?.message ?: return "An unexpected error occurred." + return when { + msg.contains("ERROR_TYPE_AICORE_NOT_ENABLED_RUNTIME_LIMITS") -> { + "AI Core speech engine runtime limits exceeded. Please wait a few moments and try again." + } + msg.contains("PERMISSION_DENIED") || msg.contains("permission") -> { + "Microphone permission is required for voice input." + } + msg.contains("NETWORK_ERROR") || msg.contains("internet") || msg.contains("timeout") -> { + "Network error. Please check your connection." + } + else -> { + "Speech recognition engine was closed due to an internal error: ${e.message}" + } + } + } + + fun close() { + listenJob?.cancel() + } +} diff --git a/jetpacker/android/core/speech/src/main/kotlin/com/example/jetpacker/core/speech/VoiceInputState.kt b/jetpacker/android/core/speech/src/main/kotlin/com/example/jetpacker/core/speech/VoiceInputState.kt new file mode 100644 index 00000000..476af1a0 --- /dev/null +++ b/jetpacker/android/core/speech/src/main/kotlin/com/example/jetpacker/core/speech/VoiceInputState.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.speech + +data class VoiceInputState( + val isListening: Boolean = false, + val showDialog: Boolean = false, + val statusText: String = "Initializing...", + val transcription: String = "", + val partialTranscription: String = "", + val translatedTranscription: String = "", + val audioLevel: Float = 0f, + val isReady: Boolean = false, + val transcriptionResult: String? = null, + val isError: Boolean = false, +) diff --git a/jetpacker/android/core/speech/src/test/AndroidManifest.xml b/jetpacker/android/core/speech/src/test/AndroidManifest.xml new file mode 100644 index 00000000..3d515158 --- /dev/null +++ b/jetpacker/android/core/speech/src/test/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/jetpacker/android/core/speech/src/test/kotlin/com/example/jetpacker/core/speech/VoiceInputManagerTest.kt b/jetpacker/android/core/speech/src/test/kotlin/com/example/jetpacker/core/speech/VoiceInputManagerTest.kt new file mode 100644 index 00000000..6fca54e2 --- /dev/null +++ b/jetpacker/android/core/speech/src/test/kotlin/com/example/jetpacker/core/speech/VoiceInputManagerTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.speech + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.mlkit.genai.common.GenAiException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +@OptIn(ExperimentalCoroutinesApi::class) +class VoiceInputManagerTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var voiceInputManager: VoiceInputManager + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + // We explicitly DO NOT initialize MlKitContext here. + // This simulates an environment where speech recognition services fail to bind, + // allowing us to verify the manager's robust initialization error handling. + voiceInputManager = VoiceInputManager() + } + + @Test + fun whenSpeechRecognizerIsNull_handleListenToggleSetsError() = runTest { + voiceInputManager.setPermissionGranted(true) + voiceInputManager.handleListenToggle(onPermissionRequired = {}) + advanceUntilIdle() + + val state = voiceInputManager.uiState.value + assertFalse(state.isListening) + assertTrue(state.isError) + assertTrue(state.showDialog) + assertEquals("Speech recognizer could not be initialized.", state.statusText) + } + + @Test + fun cancelListening_clearsErrorState() = runTest { + voiceInputManager.setPermissionGranted(true) + voiceInputManager.handleListenToggle(onPermissionRequired = {}) + advanceUntilIdle() + + var state = voiceInputManager.uiState.value + assertTrue(state.isError) + + voiceInputManager.cancelListening() + state = voiceInputManager.uiState.value + + assertFalse(state.isListening) + assertFalse(state.isError) + assertFalse(state.showDialog) + } + + @Test + fun userFriendlyErrorMapping_translatesExceptionsCorrectly() { + val method = + VoiceInputManager::class + .java + .getDeclaredMethod("getUserFriendlyErrorMessage", Throwable::class.java) + method.isAccessible = true + + val aiCoreException = + GenAiException( + "Speech recognition engine is closed due to internal error: ERROR_TYPE_AICORE_NOT_ENABLED_RUNTIME_LIMITS", + null, + 0, + ) + val mappedAiCore = method.invoke(voiceInputManager, aiCoreException) as String + assertEquals( + "AI Core speech engine runtime limits exceeded. Please wait a few moments and try again.", + mappedAiCore, + ) + + val permissionException = Exception("PERMISSION_DENIED: missing RECORD_AUDIO") + val mappedPermission = method.invoke(voiceInputManager, permissionException) as String + assertEquals("Microphone permission is required for voice input.", mappedPermission) + + val networkException = Exception("timeout or NETWORK_ERROR during connect") + val mappedNetwork = method.invoke(voiceInputManager, networkException) as String + assertEquals("Network error. Please check your connection.", mappedNetwork) + + val genericException = Exception("Some unexpected issue") + val mappedGeneric = method.invoke(voiceInputManager, genericException) as String + assertEquals( + "Speech recognition engine was closed due to an internal error: Some unexpected issue", + mappedGeneric, + ) + } +} diff --git a/jetpacker/android/core/ui/build.gradle.kts b/jetpacker/android/core/ui/build.gradle.kts new file mode 100644 index 00000000..5d5921f2 --- /dev/null +++ b/jetpacker/android/core/ui/build.gradle.kts @@ -0,0 +1,48 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.android.library) +} + +android { + namespace = "com.example.jetpacker.core.ui" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { compose = true } +} + +dependencies { + api(platform(libs.androidx.compose.bom)) + + api(libs.androidx.activity.compose) + api(libs.androidx.compose.material.icons.extended) + api(libs.androidx.compose.material3) + api(libs.androidx.compose.ui) + api(libs.androidx.compose.ui.tooling.preview) + api(libs.androidx.core.ktx) + api(libs.androidx.lifecycle.viewmodel.compose) + api(libs.coil.compose) + + debugImplementation(libs.androidx.compose.ui.tooling) +} + +kotlin { jvmToolchain(17) } diff --git a/jetpacker/android/core/ui/src/main/AndroidManifest.xml b/jetpacker/android/core/ui/src/main/AndroidManifest.xml new file mode 100644 index 00000000..224f5738 --- /dev/null +++ b/jetpacker/android/core/ui/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Color.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Color.kt new file mode 100644 index 00000000..328a6b86 --- /dev/null +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Color.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.ui + +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF72DAEC) +val onPrimaryLight = Color(0xFFF1F2F3F) +val primaryContainerLight = Color(0xFF72DAEC) +val onPrimaryContainerLight = Color(0xFF1F2F3F) +val secondaryLight = Color(0xFF306DAC) +val onSecondaryLight = Color(0xFFEDF1FF) +val secondaryContainerLight = Color(0xFF306DAC) +val onSecondaryContainerLight = Color(0xFFEDF1FF) +val tertiaryLight = Color(0xFFFF5314) +val onTertiaryLight = Color(0xFFfcf8e5) +val tertiaryContainerLight = Color(0xFFFF5314) +val onTertiaryContainerLight = Color(0xFFfcf8e5) +val errorLight = Color(0xFFB51A25) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFd8373b) +val onErrorContainerLight = Color(0xFFfffbff) +val backgroundLight = Color(0xFFf6fafb) +val onBackgroundLight = Color(0xFF171c1d) +val surfaceLight = Color(0xFFFCF8E5) +val onSurfaceLight = Color(0xFF1f2f3f) +val surfaceVariantLight = Color(0xFFe6e2d7) +val onSurfaceVariantLight = Color(0xFF48473f) +val outlineLight = Color(0xFF1f2f3f) +val outlineVariantLight = Color(0xFFb0c4d8) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF1f2f3f) +val inverseOnSurfaceLight = Color(0xFFf4f0ed) +val inversePrimaryLight = Color(0xFF6dd5e7) +val surfaceDimLight = Color(0xFF72daec) +val surfaceBrightLight = Color(0xFF72daec) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFe8dff) +val surfaceContainerLight = Color(0xFFedf1ff) +val surfaceContainerHighLight = Color(0xFFd1dbff) +val surfaceContainerHighestLight = Color(0xFFc6d2ff) + +val primaryDark = Color(0xFF044B57) +val onPrimaryDark = Color(0xFFFAFEFF) +val primaryContainerDark = Color(0xFF72DAEC) +val onPrimaryContainerDark = Color(0xFF005f6a) +val secondaryDark = Color(0xFFa1d0ff) +val onSecondaryDark = Color(0xFF00325A) +val secondaryContainerDark = Color(0xFF306DAC) +val onSecondaryContainerDark = Color(0xFFA1D0FF) +val tertiaryDark = Color(0xFFFFB59F) +val onTertiaryDark = Color(0xFF5E1700) +val tertiaryContainerDark = Color(0xFFF47549FDC2B1) +val onTertiaryContainerDark = Color(0xFF5E1700) +val errorDark = Color(0xFFFFB3AE) +val onErrorDark = Color(0xFF68000C) +val errorContainerDark = Color(0xFFFF5353) +val onErrorContainerDark = Color(0xFF5B0009) +val backgroundDark = Color(0xFF0F1415) +val onBackgroundDark = Color(0xFFDFE3E4) +val surfaceDark = Color(0xFF3C392B) +val onSurfaceDark = Color(0xFFE6E2DF) +val surfaceVariantDark = Color(0xFF48473F) +val onSurfaceVariantDark = Color(0xFFCAC6BC) +val outlineDark = Color(0xFF939187) +val outlineVariantDark = Color(0xFF48473F) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFA2CCF7) +val inverseOnSurfaceDark = Color(0xFF31302F) +val inversePrimaryDark = Color(0xFF006875) +val surfaceDimDark = Color(0xFF121214) +val surfaceBrightDark = Color(0xFF37383a) +val surfaceContainerLowestDark = Color(0xFF0d0d0f) +val surfaceContainerLowDark = Color(0xFF1A1A1C) +val surfaceContainerDark = Color(0xFF1E1E20) +val surfaceContainerHighDark = Color(0xFF28292B) +val surfaceContainerHighestDark = Color(0xFF333436) + +// Semantic colors +val StarRatingGold = Color(0xFFFFB300) +val ShimmerPlaceholderGray = Color(0xFFE0E0E0) +val TimelineNodeBorderGreen = Color(0xFFDDF8D8) +val TimelineAudioBorderBlue = Color(0xFF299EAF) + diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/EventColors.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/EventColors.kt new file mode 100644 index 00000000..979f9edd --- /dev/null +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/EventColors.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.ui + +import androidx.compose.ui.graphics.Color + +data class EventColor(val container: Color, val content: Color) + +object EventColors { + val Flight = EventColor(container = Color(0xFFF3E5F5), content = Color(0xFF8523C2)) + val Hotel = EventColor(container = Color(0xFFE3F2FD), content = Color(0xFF1976D2)) + val Activity = EventColor(container = Color(0xFFFFF3E0), content = Color(0xFFE65100)) + val Museum = EventColor(container = Color(0xFFFFF3E0), content = Color(0xFF8BC34A)) + val Food = EventColor(container = Color(0xFFE8F5E9), content = Color(0xFF2E7D32)) + val Shopping = EventColor(container = Color(0xFFFCE4EC), content = Color(0xFFC2185B)) + val Entertainment = EventColor(container = Color(0xFFFFFDE7), content = Color(0xFFFBC02D)) +} diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Shape.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Shape.kt new file mode 100644 index 00000000..bc0697ac --- /dev/null +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Shape.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.ui + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val Shapes = Shapes( + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(24.dp), // "md" scale: 1.5rem/24dp + large = RoundedCornerShape(32.dp), + extraLarge = RoundedCornerShape(48.dp) // "xl" scale: 3rem/48dp +) diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Theme.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Theme.kt new file mode 100644 index 00000000..ce1a01b2 --- /dev/null +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Theme.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val DarkColorScheme = + darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, + ) + +private val LightColorScheme = + lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, + ) + +@Composable +fun JetPackerTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = + when { + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + shapes = Shapes, + content = content, + ) +} diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Type.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Type.kt new file mode 100644 index 00000000..771ff22b --- /dev/null +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Type.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.ui + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontVariation +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +@OptIn(ExperimentalTextApi::class) +private val GeologicaFontFamily = + FontFamily( + Font( + resId = R.font.gelogica, + weight = FontWeight.Normal, + variationSettings = FontVariation.Settings(FontVariation.weight(FontWeight.Normal.weight)), + ), + Font( + R.font.gelogica, + FontWeight.Medium, + variationSettings = FontVariation.Settings(FontVariation.weight(FontWeight.Medium.weight)), + ), + Font( + R.font.gelogica, + FontWeight.SemiBold, + variationSettings = FontVariation.Settings(FontVariation.weight(FontWeight.SemiBold.weight)), + ), + Font( + R.font.gelogica, + FontWeight.Bold, + variationSettings = FontVariation.Settings(FontVariation.weight(FontWeight.Bold.weight)), + ), + ) + +val SekuyaFontFamily = FontFamily(Font(R.font.sekuya, FontWeight.Normal)) + +// Default Material 3 typography with Google Sans +val Typography = + Typography( + displayLarge = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + headlineLarge = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + titleLarge = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + bodyLarge = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + labelLarge = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = + TextStyle( + fontFamily = GeologicaFontFamily, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + ) diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerExtendedFloatingActionButton.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerExtendedFloatingActionButton.kt new file mode 100644 index 00000000..229c75b9 --- /dev/null +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerExtendedFloatingActionButton.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.ui.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.ui.JetPackerTheme + +@Composable +fun JetPackerExtendedFloatingActionButton( + onClick: () -> Unit, + icon: ImageVector, + label: String, + modifier: Modifier = Modifier, + isListening: Boolean = false, + audioLevel: Float = 0f, + trailingContent: @Composable (RowScope.() -> Unit)? = null, +) { + val shape = CircleShape + val inverseSurface = MaterialTheme.colorScheme.inverseSurface + + Row( + modifier = + modifier + .dropShadow(shape = shape) { + radius = 0f + spread = 0f + offset = Offset(x = 2.dp.toPx(), y = 3.dp.toPx()) + color = inverseSurface + } + .background(MaterialTheme.colorScheme.primary, shape) + .border(1.dp, MaterialTheme.colorScheme.onSurfaceVariant, shape) + .clip(shape) + .padding(horizontal = 8.dp, vertical = 12.dp) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + AnimatedContent( + targetState = isListening, + transitionSpec = { fadeIn().togetherWith(fadeOut()) }, + label = "JetPackerExtendedFloatingActionButtonContent", + ) { listening -> + if (listening) { + VoiceEqualizer( + audioLevel = audioLevel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) + } else { + Row( + modifier = + Modifier.clip(shape).clickable { onClick() }.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + + trailingContent?.invoke(this) + } +} + +@Composable +private fun VoiceEqualizer(audioLevel: Float, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val durations = listOf(450, 600, 500, 700, 550, 650, 400, 750) + val maxScales = listOf(0.8f, 1.0f, 0.7f, 0.9f, 1.0f, 0.8f, 0.6f, 1.0f) + + repeat(durations.size) { index -> + val smoothedAudioLevel by + animateFloatAsState( + targetValue = audioLevel / 100f, + animationSpec = + spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow), + label = "SmoothedAudioLevel", + ) + + val heightScale by + rememberInfiniteTransition(label = "VoiceEqualizer") + .animateFloat( + initialValue = 0.2f, + targetValue = maxScales[index], + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = durations[index], + easing = LinearEasing, + delayMillis = index * 50, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "BarHeight", + ) + + Box( + modifier = + Modifier + .clip(CircleShape) + .width(4.dp) + .height(24.dp) + .graphicsLayer { scaleY = (heightScale * smoothedAudioLevel).coerceAtLeast(0.1f) } + .background(color = MaterialTheme.colorScheme.onPrimary, shape = CircleShape) + ) + } + } +} + +@Preview +@Composable +fun JetPackerExtendedFloatingActionButtonPreview() { + JetPackerTheme { + Box(modifier = Modifier.padding(16.dp)) { + JetPackerExtendedFloatingActionButton( + onClick = {}, + icon = Icons.Rounded.Add, + label = "Create trip", + ) + } + } +} + +@Preview +@Composable +fun JetPackerExtendedFloatingActionButtonListeningPreview() { + JetPackerTheme { + Box(modifier = Modifier.padding(16.dp)) { + JetPackerExtendedFloatingActionButton( + onClick = {}, + icon = Icons.Rounded.Add, + label = "Create trip", + isListening = true, + ) + } + } +} diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerFab.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerFab.kt new file mode 100644 index 00000000..0535f535 --- /dev/null +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerFab.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.ui.JetPackerTheme + +@Composable +fun JetPackerFab( + onClick: () -> Unit, + icon: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.tertiary, + contentColor: Color = MaterialTheme.colorScheme.onTertiary, +) { + val shape = RoundedCornerShape(16.dp) + + Box( + modifier = modifier + .sizeIn(minWidth = 56.dp, minHeight = 56.dp) + .border(1.dp, MaterialTheme.colorScheme.onSurface, shape = shape) + .dropShadow(shape = shape) { + radius = 0f + spread = 0f + offset = Offset(x = 2.dp.toPx(), y = 3.dp.toPx()) + color = Color(0xFF20290A) + } + .background(containerColor, shape) + .clip(shape) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = contentColor + ) + } +} + +@Preview +@Composable +fun JetPackerFabPreview() { + JetPackerTheme { + JetPackerFab( + modifier = Modifier.padding(16.dp), + onClick = {}, + icon = Icons.Rounded.Add, + contentDescription = "Add" + ) + } +} diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerFabConfig.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerFabConfig.kt new file mode 100644 index 00000000..c9048c22 --- /dev/null +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerFabConfig.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.ui.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector + +data class JetPackerFabConfig( + val icon: ImageVector, + val onClick: () -> Unit, + val contentDescription: String? = null, + val containerColor: Color? = null, + val contentColor: Color? = null, +) diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerToolbar.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerToolbar.kt new file mode 100644 index 00000000..acca5fd4 --- /dev/null +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerToolbar.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.core.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateOffsetAsState +import androidx.compose.animation.core.animateSizeAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material.icons.rounded.Wallet +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.ui.JetPackerTheme +import kotlin.math.roundToInt + +internal class ToolbarState { + var toolbarCoords: LayoutCoordinates? by mutableStateOf(null) + var selectedBounds: Rect? by mutableStateOf(null) +} + +internal val LocalToolbarState = compositionLocalOf { null } + +@Composable +fun JetPackerToolbar(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) { + val shape = RoundedCornerShape(32.dp) + val state = remember { ToolbarState() } + + val indicatorOffset by + animateOffsetAsState( + targetValue = state.selectedBounds?.topLeft ?: Offset.Zero, + label = "indicatorOffset", + ) + val indicatorSize by + animateSizeAsState( + targetValue = state.selectedBounds?.size ?: Size.Zero, + label = "indicatorSize", + ) + + Box( + modifier = + modifier + .dropShadow(shape = shape) { + radius = 3.dp.toPx() + offset = Offset(0f, 1.dp.toPx()) + color = Color.Black.copy(alpha = 0.3f) + } + .dropShadow(shape = shape) { + radius = 8.dp.toPx() + offset = Offset(0f, 4.dp.toPx()) + spread = 3.dp.toPx() + color = Color.Black.copy(alpha = 0.15f) + } + .background(MaterialTheme.colorScheme.onSurface, shape) + .onGloballyPositioned { state.toolbarCoords = it } + ) { + if (state.selectedBounds != null) { + Box( + modifier = + Modifier.offset { + IntOffset(indicatorOffset.x.roundToInt(), indicatorOffset.y.roundToInt()) + } + .size( + width = with(LocalDensity.current) { indicatorSize.width.toDp() }, + height = with(LocalDensity.current) { indicatorSize.height.toDp() }, + ) + .background(MaterialTheme.colorScheme.primary, CircleShape) + ) + } + + CompositionLocalProvider(LocalToolbarState provides state) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + } + } +} + +@Composable +fun JetPackerToolbarAction( + icon: ImageVector, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, +) { + val state = LocalToolbarState.current + var currentCoords by remember { mutableStateOf(null) } + + val contentColor by + animateColorAsState( + targetValue = + if (selected) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.surfaceContainer, + label = "contentColor", + ) + + LaunchedEffect(selected, currentCoords, state?.toolbarCoords) { + if ( + selected && + state != null && + currentCoords != null && + currentCoords!!.isAttached && + state.toolbarCoords != null && + state.toolbarCoords!!.isAttached + ) { + state.selectedBounds = state.toolbarCoords!!.localBoundingBoxOf(currentCoords!!) + } + } + + Row( + modifier = + modifier + .height(48.dp) + .onGloballyPositioned { coords -> + currentCoords = coords + if ( + selected && + state != null && + state.toolbarCoords != null && + coords.isAttached && + state.toolbarCoords!!.isAttached + ) { + state.selectedBounds = state.toolbarCoords!!.localBoundingBoxOf(coords) + } + } + .clip(CircleShape) + .clickable { onClick() } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = contentColor, + modifier = Modifier.size(24.dp), + ) + } +} + +@Preview +@Composable +fun JetPackerToolbarPreview() { + JetPackerTheme { + Box(modifier = Modifier.padding(16.dp)) { + JetPackerToolbar { + JetPackerToolbarAction( + icon = Icons.Rounded.Event, + contentDescription = "Today", + onClick = {}, + selected = false, + ) + JetPackerToolbarAction( + icon = Icons.Rounded.ConfirmationNumber, + contentDescription = "Tickets", + onClick = {}, + selected = false, + ) + JetPackerToolbarAction( + icon = Icons.Rounded.Wallet, + contentDescription = "Expenses", + onClick = {}, + selected = true, + ) + JetPackerToolbarAction( + icon = Icons.Rounded.AutoAwesome, + contentDescription = "Genius", + onClick = {}, + selected = false, + ) + } + } + } +} diff --git a/jetpacker/android/core/ui/src/main/res/drawable/avatar_01.webp b/jetpacker/android/core/ui/src/main/res/drawable/avatar_01.webp new file mode 100644 index 00000000..02ce3ddb Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/avatar_01.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/avatar_02.webp b/jetpacker/android/core/ui/src/main/res/drawable/avatar_02.webp new file mode 100644 index 00000000..cee411c9 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/avatar_02.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/avatar_03.webp b/jetpacker/android/core/ui/src/main/res/drawable/avatar_03.webp new file mode 100644 index 00000000..5f80d7b0 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/avatar_03.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/avatar_04.webp b/jetpacker/android/core/ui/src/main/res/drawable/avatar_04.webp new file mode 100644 index 00000000..5a5d6dac Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/avatar_04.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/avatar_05.webp b/jetpacker/android/core/ui/src/main/res/drawable/avatar_05.webp new file mode 100644 index 00000000..75d7672c Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/avatar_05.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/avatar_06.webp b/jetpacker/android/core/ui/src/main/res/drawable/avatar_06.webp new file mode 100644 index 00000000..d2a81d2b Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/avatar_06.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/avatar_07.webp b/jetpacker/android/core/ui/src/main/res/drawable/avatar_07.webp new file mode 100644 index 00000000..1dbd11b3 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/avatar_07.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/avatar_08.webp b/jetpacker/android/core/ui/src/main/res/drawable/avatar_08.webp new file mode 100644 index 00000000..f5be46bd Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/avatar_08.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/avatar_09.webp b/jetpacker/android/core/ui/src/main/res/drawable/avatar_09.webp new file mode 100644 index 00000000..7b69ae29 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/avatar_09.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/avatar_10.webp b/jetpacker/android/core/ui/src/main/res/drawable/avatar_10.webp new file mode 100644 index 00000000..459fef14 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/avatar_10.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/car_reservation_mockup.webp b/jetpacker/android/core/ui/src/main/res/drawable/car_reservation_mockup.webp new file mode 100644 index 00000000..33fae2e4 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/car_reservation_mockup.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/drivers_license_mockup.jpg b/jetpacker/android/core/ui/src/main/res/drawable/drivers_license_mockup.jpg new file mode 100644 index 00000000..ea652113 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/drivers_license_mockup.jpg differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/hotel_reservation_mockup.webp b/jetpacker/android/core/ui/src/main/res/drawable/hotel_reservation_mockup.webp new file mode 100644 index 00000000..906e58b0 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/hotel_reservation_mockup.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_california.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_california.webp new file mode 100644 index 00000000..01389304 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_california.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_dublin.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_dublin.webp new file mode 100644 index 00000000..0a62d0e8 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_dublin.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_dublin_plan.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_dublin_plan.webp new file mode 100644 index 00000000..387764ce Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_dublin_plan.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_dublin_skyline.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_dublin_skyline.webp new file mode 100644 index 00000000..ac9ede40 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_dublin_skyline.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_hotel_lemeurice.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_hotel_lemeurice.webp new file mode 100644 index 00000000..5a805217 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_hotel_lemeurice.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_jules_verne.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_jules_verne.webp new file mode 100644 index 00000000..29a5d95f Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_jules_verne.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_louvre.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_louvre.webp new file mode 100644 index 00000000..919dd950 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_louvre.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_paris.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_paris.webp new file mode 100644 index 00000000..c43f4428 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_paris.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_paris_plan.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_paris_plan.webp new file mode 100644 index 00000000..3d3676d9 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_paris_plan.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_paris_skyline.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_paris_skyline.webp new file mode 100644 index 00000000..af03e8a9 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_paris_skyline.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_rome.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_rome.webp new file mode 100644 index 00000000..98d4f660 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_rome.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_seoul.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_seoul.webp new file mode 100644 index 00000000..ca9aa556 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_seoul.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_tokyo.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_tokyo.webp new file mode 100644 index 00000000..cf0888dc Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_tokyo.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_venice.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_venice.webp new file mode 100644 index 00000000..ef5a933c Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/img_venice.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/passport_mockup.jpg b/jetpacker/android/core/ui/src/main/res/drawable/passport_mockup.jpg new file mode 100644 index 00000000..a7783000 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/passport_mockup.jpg differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/placeholder.png b/jetpacker/android/core/ui/src/main/res/drawable/placeholder.png new file mode 100644 index 00000000..2d955901 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/placeholder.png differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/qr.png b/jetpacker/android/core/ui/src/main/res/drawable/qr.png new file mode 100644 index 00000000..45829ac8 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/drawable/qr.png differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/speech_to_text.xml b/jetpacker/android/core/ui/src/main/res/drawable/speech_to_text.xml new file mode 100644 index 00000000..1a78ed80 --- /dev/null +++ b/jetpacker/android/core/ui/src/main/res/drawable/speech_to_text.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/jetpacker/android/core/ui/src/main/res/font/gelogica.ttf b/jetpacker/android/core/ui/src/main/res/font/gelogica.ttf new file mode 100644 index 00000000..d932898c Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/font/gelogica.ttf differ diff --git a/jetpacker/android/core/ui/src/main/res/font/google_sans_bold.ttf b/jetpacker/android/core/ui/src/main/res/font/google_sans_bold.ttf new file mode 100644 index 00000000..71b847f8 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/font/google_sans_bold.ttf differ diff --git a/jetpacker/android/core/ui/src/main/res/font/google_sans_medium.ttf b/jetpacker/android/core/ui/src/main/res/font/google_sans_medium.ttf new file mode 100644 index 00000000..8b9aebc9 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/font/google_sans_medium.ttf differ diff --git a/jetpacker/android/core/ui/src/main/res/font/google_sans_regular.ttf b/jetpacker/android/core/ui/src/main/res/font/google_sans_regular.ttf new file mode 100644 index 00000000..cc37c3f3 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/font/google_sans_regular.ttf differ diff --git a/jetpacker/android/core/ui/src/main/res/font/google_sans_semi_bold.ttf b/jetpacker/android/core/ui/src/main/res/font/google_sans_semi_bold.ttf new file mode 100644 index 00000000..b80284d2 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/font/google_sans_semi_bold.ttf differ diff --git a/jetpacker/android/core/ui/src/main/res/font/sekuya.ttf b/jetpacker/android/core/ui/src/main/res/font/sekuya.ttf new file mode 100644 index 00000000..8d88c402 Binary files /dev/null and b/jetpacker/android/core/ui/src/main/res/font/sekuya.ttf differ diff --git a/jetpacker/android/core/ui/src/main/res/values/strings.xml b/jetpacker/android/core/ui/src/main/res/values/strings.xml new file mode 100644 index 00000000..bbbd1c9a --- /dev/null +++ b/jetpacker/android/core/ui/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Create Trip + Edit Trip + Save + Cancel + Delete + Confirm + Itinerary + Event Type + Upcoming + Past + diff --git a/jetpacker/android/data/db/build.gradle.kts b/jetpacker/android/data/db/build.gradle.kts new file mode 100644 index 00000000..dc5f7996 --- /dev/null +++ b/jetpacker/android/data/db/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.google.devtools.ksp) +} + +android { + namespace = "com.example.jetpacker.data.db" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } +} + +dependencies { + implementation(project(":data:itinerary")) + implementation(project(":data:trips")) + + implementation(libs.androidx.core.ktx) + "ksp"(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.runtime) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) +} + +kotlin { jvmToolchain(17) } diff --git a/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/DatabaseModule.kt b/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/DatabaseModule.kt new file mode 100644 index 00000000..356828b3 --- /dev/null +++ b/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/DatabaseModule.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.db + +import android.content.Context +import androidx.room.Room +import com.example.jetpacker.data.itinerary.DayThemeDao +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.ExpenseDao +import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.trips.TripDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): JetPackerDatabase { + return Room.databaseBuilder(context, JetPackerDatabase::class.java, "jetpacker_db") + .fallbackToDestructiveMigration(dropAllTables = true) + .build() + } + + @Provides + fun provideTripDao(db: JetPackerDatabase): TripDao { + return db.tripDao() + } + + @Provides + fun provideEventDao(db: JetPackerDatabase): EventDao { + return db.eventDao() + } + + @Provides + fun provideTourDetailDao(db: JetPackerDatabase): TourDetailDao { + return db.tourDetailDao() + } + + @Provides + fun provideDayThemeDao(db: JetPackerDatabase): DayThemeDao { + return db.dayThemeDao() + } + + @Provides + fun provideExpenseDao(db: JetPackerDatabase): ExpenseDao { + return db.expenseDao() + } +} diff --git a/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/JetPackerDatabase.kt b/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/JetPackerDatabase.kt new file mode 100644 index 00000000..b3a9fb7a --- /dev/null +++ b/jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/JetPackerDatabase.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.example.jetpacker.data.itinerary.ActivityDetail +import com.example.jetpacker.data.itinerary.DayTheme +import com.example.jetpacker.data.itinerary.DayThemeDao +import com.example.jetpacker.data.itinerary.DiningDetail +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.Expense +import com.example.jetpacker.data.itinerary.ExpenseDao +import com.example.jetpacker.data.itinerary.FlightDetail +import com.example.jetpacker.data.itinerary.HotelDetail +import com.example.jetpacker.data.itinerary.MuseumDetail +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TourDetail +import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao + +@Database( + entities = + [ + Trip::class, + TimelineEvent::class, + TourDetail::class, + DayTheme::class, + FlightDetail::class, + HotelDetail::class, + DiningDetail::class, + ActivityDetail::class, + MuseumDetail::class, + Expense::class, + VoiceNoteEntity::class, + ], + version = 1, + exportSchema = false, +) +abstract class JetPackerDatabase : RoomDatabase() { + abstract fun tripDao(): TripDao + + abstract fun eventDao(): EventDao + + abstract fun tourDetailDao(): TourDetailDao + + abstract fun dayThemeDao(): DayThemeDao + + abstract fun expenseDao(): ExpenseDao +} diff --git a/jetpacker/android/data/itinerary/build.gradle.kts b/jetpacker/android/data/itinerary/build.gradle.kts new file mode 100644 index 00000000..6c13bcf9 --- /dev/null +++ b/jetpacker/android/data/itinerary/build.gradle.kts @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.google.devtools.ksp) +} + +android { + namespace = "com.example.jetpacker.data.itinerary" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } +} + +dependencies { + implementation(project(":core:flags")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.room.ktx) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.moshi.kotlin) + "ksp"(libs.moshi.kotlin.codegen) +} + +kotlin { jvmToolchain(17) } diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/Converters.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/Converters.kt new file mode 100644 index 00000000..25ac5b84 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/Converters.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.TypeConverter + +class Converters { + @TypeConverter + fun fromListInt(value: List?): String { + return value?.joinToString(",") ?: "" + } + + @TypeConverter + fun toListInt(value: String?): List { + if (value.isNullOrEmpty()) return emptyList() + return value.split(",").mapNotNull { it.toIntOrNull() } + } + + @TypeConverter + fun fromListString(value: List?): String { + return value?.joinToString("||") ?: "" + } + + @TypeConverter + fun toListString(value: String?): List { + if (value.isNullOrEmpty()) return emptyList() + return value.split("||") + } +} diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DayTheme.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DayTheme.kt new file mode 100644 index 00000000..af287269 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DayTheme.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "day_themes") +data class DayTheme( + @PrimaryKey @JvmField val id: String, + @JvmField val tripId: String, + @JvmField val date: String, // YYYY-MM-DD format for easy matching with events grouped by date + @JvmField val theme: String, +) diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DayThemeDao.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DayThemeDao.kt new file mode 100644 index 00000000..4eabec4d --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DayThemeDao.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object (DAO) for managing AI-generated or custom day themes associated with trips. + */ +@Dao +interface DayThemeDao { + @Query("SELECT * FROM day_themes WHERE tripId = :tripId") + fun getThemesForTrip(tripId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTheme(theme: DayTheme) + + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertThemes(themes: List) + + @Query("DELETE FROM day_themes WHERE tripId = :tripId") + suspend fun deleteThemesForTrip(tripId: String) + + @Query("DELETE FROM day_themes") suspend fun deleteAllThemes() +} diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/EventDao.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/EventDao.kt new file mode 100644 index 00000000..f5c20dbb --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/EventDao.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +data class TripSessionIdentifier(@JvmField val tripId: String, @JvmField val sessionId: String?) + +/** + * Data Access Object (DAO) for managing timeline events and their detailed subtypes (flights, hotels, + * dining, museum entries, and voice notes) stored in the Room database. + */ +@Dao +interface EventDao { + @Query("SELECT DISTINCT tripId, sessionId FROM timeline_events WHERE sessionId IS NOT NULL") + fun getAllSessionIds(): Flow> + + @Query("SELECT * FROM timeline_events WHERE tripId = :tripId ORDER BY timestamp ASC") + fun getEventsForTrip(tripId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertEvent(event: TimelineEvent) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertEvents(events: List) + + @Query("DELETE FROM timeline_events WHERE tripId = :tripId") + suspend fun deleteEventsForTrip(tripId: String) + + @Delete suspend fun deleteEvent(event: TimelineEvent) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertFlightDetail(detail: FlightDetail) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertHotelDetail(detail: HotelDetail) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDiningDetail(detail: DiningDetail) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertActivityDetail(detail: ActivityDetail) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMuseumDetail(detail: MuseumDetail) + + @Query("SELECT * FROM flight_details WHERE eventId = :eventId") + fun getFlightDetail(eventId: String): Flow + + @Query("SELECT * FROM hotel_details WHERE eventId = :eventId") + fun getHotelDetail(eventId: String): Flow + + @Query("SELECT * FROM dining_details WHERE eventId = :eventId") + fun getDiningDetail(eventId: String): Flow + + @Query("SELECT * FROM museum_details WHERE eventId = :eventId") + fun getMuseumDetail(eventId: String): Flow + + @Query("SELECT * FROM timeline_events WHERE id = :eventId") + fun getEventById(eventId: String): Flow + + @Query("DELETE FROM timeline_events") suspend fun deleteAllEvents() + + @Query("DELETE FROM flight_details") suspend fun deleteAllFlightDetails() + + @Query("DELETE FROM hotel_details") suspend fun deleteAllHotelDetails() + + @Query("DELETE FROM dining_details") suspend fun deleteAllDiningDetails() + + @Query("DELETE FROM activity_details") suspend fun deleteAllActivityDetails() + + @Query("SELECT * FROM voice_notes WHERE tripId = :tripId ORDER BY timestamp DESC") + fun getVoiceNotesForTrip(tripId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertVoiceNote(note: VoiceNoteEntity) + + @Query("DELETE FROM voice_notes WHERE id = :id") suspend fun deleteVoiceNoteById(id: String) +} diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/EventSubclassEntities.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/EventSubclassEntities.kt new file mode 100644 index 00000000..ab2472b0 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/EventSubclassEntities.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import org.intellij.lang.annotations.Language + +@Entity( + tableName = "flight_details", + foreignKeys = + [ + ForeignKey( + entity = TimelineEvent::class, + parentColumns = ["id"], + childColumns = ["eventId"], + onDelete = ForeignKey.CASCADE, + ) + ], +) +data class FlightDetail( + @PrimaryKey @JvmField val eventId: String, + @JvmField val airline: String = "", + @JvmField val flightNum: String = "", + @JvmField val origin: String = "", + @JvmField val destination: String = "", + @JvmField val gate: String? = null, + @JvmField val seat: String? = null, + @JvmField val departureTerminal: String? = null, + @JvmField val arrivalTerminal: String? = null, + @JvmField val arrivalGate: String? = null, + @JvmField val duration: String? = null, + @JvmField val aircraft: String? = null, + @JvmField val baggageAllowance: String? = null, +) + +@Entity( + tableName = "hotel_details", + foreignKeys = + [ + ForeignKey( + entity = TimelineEvent::class, + parentColumns = ["id"], + childColumns = ["eventId"], + onDelete = ForeignKey.CASCADE, + ) + ], +) +data class HotelDetail( + @PrimaryKey @JvmField val eventId: String, + @JvmField val name: String = "", + @JvmField val address: String = "", + @JvmField val placeId: String? = null, + @JvmField val checkInTime: Long = 0L, + @JvmField val checkOutTime: Long = 0L, + @JvmField val confNumber: String? = null, + @JvmField val rating: String? = null, + @JvmField val ratingCount: String? = null, + @JvmField val pricePerNight: String? = null, + @JvmField val guests: String? = null, + @JvmField val phone: String? = null, + @JvmField val language: String? = null, +) + +@Entity( + tableName = "dining_details", + foreignKeys = + [ + ForeignKey( + entity = TimelineEvent::class, + parentColumns = ["id"], + childColumns = ["eventId"], + onDelete = ForeignKey.CASCADE, + ) + ], +) +data class DiningDetail( + @PrimaryKey @JvmField val eventId: String, + @JvmField val restaurantName: String = "", + @JvmField val address: String = "", + @JvmField val placeId: String? = null, + @JvmField val reservationTime: Long = 0L, + @JvmField val partySize: Int = 1, + @JvmField val rating: String? = null, + @JvmField val reviewCount: String? = null, + @JvmField val priceRange: String? = null, + @JvmField val phone: String? = null, +) + +@Entity( + tableName = "activity_details", + foreignKeys = + [ + ForeignKey( + entity = TimelineEvent::class, + parentColumns = ["id"], + childColumns = ["eventId"], + onDelete = ForeignKey.CASCADE, + ) + ], +) +data class ActivityDetail( + @PrimaryKey @JvmField val eventId: String, + @JvmField val activityName: String = "", + @JvmField val address: String = "", + @JvmField val placeId: String? = null, + @JvmField val durationMinutes: Int = 60, +) + +@Entity( + tableName = "museum_details", + foreignKeys = + [ + ForeignKey( + entity = TimelineEvent::class, + parentColumns = ["id"], + childColumns = ["eventId"], + onDelete = ForeignKey.CASCADE, + ) + ], +) +@TypeConverters(Converters::class) +data class MuseumDetail( + @PrimaryKey @JvmField val eventId: String, + @JvmField val description: String = "", + @JvmField val address: String = "", + @JvmField val openingHours: String = "", + @JvmField val admissionPrice: String = "", + @JvmField val ticketWebsite: String = "", + @JvmField val rating: String = "", + @JvmField val phone: String? = null, + @JvmField val infoUrls: List = emptyList(), +) diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/Expense.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/Expense.kt new file mode 100644 index 00000000..511ccd31 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/Expense.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.UUID + +@Entity(tableName = "expenses") +data class Expense( + @PrimaryKey @JvmField val id: String = UUID.randomUUID().toString(), + @JvmField val title: String, + @JvmField val amount: Double, + @JvmField val currency: String, + @JvmField val category: String, + @JvmField val tripId: String = "", + @JvmField val timestamp: Long = System.currentTimeMillis(), +) diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/ExpenseDao.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/ExpenseDao.kt new file mode 100644 index 00000000..66947785 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/ExpenseDao.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object (DAO) for managing financial expenses logged during trips. + */ +@Dao +interface ExpenseDao { + @Query("SELECT * FROM expenses ORDER BY timestamp DESC") fun getAllExpenses(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertExpense(expense: Expense) + + @Delete suspend fun deleteExpense(expense: Expense) + + @Query("DELETE FROM expenses") suspend fun deleteAllExpenses() + + @Query("SELECT * FROM expenses WHERE tripId = :tripId ORDER BY timestamp DESC") + fun getExpensesForTrip(tripId: String): Flow> +} diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TimelineEvent.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TimelineEvent.kt new file mode 100644 index 00000000..4d954cf2 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TimelineEvent.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters + +enum class EventType { + TRANSPORTATION, + ACCOMMODATION, + FOOD_AND_DRINK, + CAR_RENTAL, + ACTIVITY, + CULTURE, + WORK, +} + +@Entity(tableName = "timeline_events") +@TypeConverters(Converters::class) +data class TimelineEvent( + @PrimaryKey @JvmField val id: String, + @JvmField val tripId: String = "", // Added to link to Trip + @JvmField val type: EventType, + @JvmField val timestamp: Long, + @JvmField val title: String, + @JvmField val location: String, + @JvmField val description: String? = null, + @JvmField val extraInfo: String? = null, + @JvmField val sessionId: String? = null, + @JvmField val imageResList: List = emptyList(), + @JvmField val audioNote: String? = null, + @JvmField val audioNotes: List = emptyList(), + @JvmField val placeId: String? = null, + @JvmField val language: String? = null +) + +@Entity(tableName = "voice_notes") +data class VoiceNoteEntity( + @PrimaryKey @JvmField val id: String, + @JvmField val tripId: String, + @JvmField val title: String, + @JvmField val transcription: String, + @JvmField val timestamp: Long, + @JvmField val matchingEventsJson: String, +) diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TourDetail.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TourDetail.kt new file mode 100644 index 00000000..d15f161c --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TourDetail.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters + +@Entity(tableName = "tour_details") +@TypeConverters(Converters::class) +data class TourDetail( + @PrimaryKey @JvmField val id: String, + @JvmField val eventId: String, // Link to TimelineEvent + @JvmField val title: String, + @JvmField val type: String, + @JvmField val imageRes: Int, + @JvmField val date: String, + @JvmField val time: String, + @JvmField val locationName: String, + @JvmField val locationAddress: String, + @JvmField val locationId: String?, + @JvmField val about: String, + @JvmField val meetingPoint: String, + @JvmField val notes: List = emptyList(), +) diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TourDetailDao.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TourDetailDao.kt new file mode 100644 index 00000000..121fe65e --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TourDetailDao.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object (DAO) for managing custom tour details associated with timeline events. + */ +@Dao +interface TourDetailDao { + @Query("SELECT * FROM tour_details WHERE eventId = :eventId") + fun getTourDetailByEventId(eventId: String): Flow + + @Query("SELECT * FROM tour_details WHERE id = :id") + fun getTourDetailById(id: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTourDetail(tourDetail: TourDetail) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTourDetails(tourDetails: List) + + @Query("DELETE FROM tour_details") suspend fun deleteAllTourDetails() +} diff --git a/jetpacker/android/data/trips/build.gradle.kts b/jetpacker/android/data/trips/build.gradle.kts new file mode 100644 index 00000000..b3a73b5a --- /dev/null +++ b/jetpacker/android/data/trips/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) +} + +android { + namespace = "com.example.jetpacker.data.trips" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } +} + +dependencies { + implementation(project(":core:ui")) + implementation(project(":data:itinerary")) + + implementation(libs.androidx.core.ktx) + "ksp"(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) +} + +kotlin { jvmToolchain(17) } diff --git a/jetpacker/android/data/trips/src/main/kotlin/com/example/jetpacker/data/trips/DummyData.kt b/jetpacker/android/data/trips/src/main/kotlin/com/example/jetpacker/data/trips/DummyData.kt new file mode 100644 index 00000000..3820fe88 --- /dev/null +++ b/jetpacker/android/data/trips/src/main/kotlin/com/example/jetpacker/data/trips/DummyData.kt @@ -0,0 +1,1290 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.trips + +import com.example.jetpacker.core.ui.R +import com.example.jetpacker.data.itinerary.ActivityDetail +import com.example.jetpacker.data.itinerary.DiningDetail +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.Expense +import com.example.jetpacker.data.itinerary.FlightDetail +import com.example.jetpacker.data.itinerary.HotelDetail +import com.example.jetpacker.data.itinerary.MuseumDetail +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TourDetail +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import java.time.Instant + +object DummyData { + + private val dayMillis = 24 * 3600 * 1000L + private val hourMillis = 3600 * 1000L + private val romeBase = Instant.parse("2025-06-10T10:00:00Z").toEpochMilli() + private val seoulBase = Instant.parse("2025-10-15T10:00:00Z").toEpochMilli() + private val ioBaseTime = Instant.parse("2026-05-18T10:00:00Z").toEpochMilli() + private val dublinBase = Instant.parse("2026-08-14T10:00:00Z").toEpochMilli() + private val parisBase = Instant.parse("2026-09-20T10:00:00Z").toEpochMilli() + + val trips = + listOf( + Trip( + "2025-1", + "Roman Holiday", + "Rome, Italy", + romeBase, + romeBase + 8 * dayMillis, + R.drawable.img_rome, + participants = listOf("Alice", "Bob", "Charlie"), + ), + Trip( + "2025-2", + "Scenic Seoul", + "Seoul, South Korea", + seoulBase, + seoulBase + 7 * dayMillis, + R.drawable.img_seoul, + participants = listOf("David", "Eva"), + ), + Trip( + "2026-1", + "California I/O", + "California, USA", + ioBaseTime, + ioBaseTime + 4 * dayMillis, + R.drawable.img_california, + participants = listOf("Frank", "Grace"), + ), + Trip( + "2026-2", + "Emerald Isle Weekend", + "Dublin, Ireland", + dublinBase, + dublinBase + 2 * dayMillis, + R.drawable.img_dublin_skyline, + participants = listOf("Hank", "Ivy"), + ), + Trip( + "2026-3", + "Romantic Paris", + "Paris, France", + parisBase, + parisBase + 7 * dayMillis, + R.drawable.img_paris_skyline, + participants = listOf("Jack", "Kelly", "Liam"), + ), + ) + + val events = mutableListOf() + val tourDetails = mutableListOf() + + val flightDetails = mutableListOf() + val hotelDetails = mutableListOf() + val diningDetails = mutableListOf() + val activityDetails = mutableListOf() + val museumDetails = mutableListOf() + val expenses = mutableListOf() + val voiceNotes = mutableListOf() + + init { + // 1. California work (2026-1) + + events.addAll( + listOf( + // Day 1: Flight in from Amsterdam, car rental pickup and hotel check-in + TimelineEvent( + id = "io_1", + tripId = "2026-1", + type = EventType.TRANSPORTATION, + timestamp = ioBaseTime + 9 * hourMillis, + title = "AMS > SFO", + location = "SFO Airport", + description = "Direct inbound international flight from Schiphol to San Francisco.", + ), + TimelineEvent( + id = "io_2", + tripId = "2026-1", + type = EventType.CAR_RENTAL, + timestamp = ioBaseTime + 11 * hourMillis, + title = "Car pickup", + location = "Zephyr Wheels Rental Center", + description = "Picking up pre-booked premium sedan @ Zephyr Wheels.", + ), + TimelineEvent( + id = "io_3", + tripId = "2026-1", + type = EventType.ACCOMMODATION, + timestamp = ioBaseTime + 15 * hourMillis, + title = "Check-in @ The Redwood Grove Hotel", + location = "The Redwood Grove Hotel", + language = "English", + description = "Settling in and resting up before the bustling conference days begin.", + ), + // Day 2: Sightseeing, SFO boat trip and museum visit + TimelineEvent( + id = "io_4", + tripId = "2026-1", + type = EventType.ACTIVITY, + timestamp = ioBaseTime + dayMillis + 10 * hourMillis, + title = "SFO Boat Trip", + location = "Pier 39, San Francisco", + description = "Scenic bay area navigation straight under the Golden Gate bridge.", + ), + TimelineEvent( + id = "io_5", + tripId = "2026-1", + type = EventType.CULTURE, + timestamp = ioBaseTime + dayMillis + 14 * hourMillis, + title = "SFMOMA Visit", + location = "San Francisco Museum of Modern Art", + description = "Exploring breathtaking modern and contemporary masterworks.", + ), + // Day 3: Keynotes, Lunch, Sessions, Dinner + TimelineEvent( + id = "io_6", + tripId = "2026-1", + type = EventType.WORK, + timestamp = ioBaseTime + 2 * dayMillis + 9 * hourMillis, + title = "Google I/O keynote sessions", + location = "Shoreline Amphitheatre", + description = "Main sundar's keynote covering all major Gemini and Android announcements.", + ), + TimelineEvent( + id = "io_7", + tripId = "2026-1", + type = EventType.FOOD_AND_DRINK, + timestamp = ioBaseTime + 2 * dayMillis + 13 * hourMillis, + title = "Lunch with team", + location = "Shoreline Amphitheatre", + description = "Bonding over healthy conference catering options and catching up.", + ), + TimelineEvent( + id = "io_8", + tripId = "2026-1", + type = EventType.WORK, + timestamp = ioBaseTime + 2 * dayMillis + 14 * hourMillis, + title = "Afternoon sessions", + location = "Shoreline Amphitheatre", + description = "Attending insightful mobile and GenAI integrations talks.", + ), + TimelineEvent( + id = "io_9", + tripId = "2026-1", + type = EventType.FOOD_AND_DRINK, + timestamp = ioBaseTime + 2 * dayMillis + 19 * hourMillis, + title = "Dinner @ Quantum Bites", + location = "Quantum Bites", + description = "Celebrating a fruitful day with delicious, contemporary dining.", + ), + // Day 4: Sessions all day + TimelineEvent( + id = "io_10", + tripId = "2026-1", + type = EventType.WORK, + timestamp = ioBaseTime + 3 * dayMillis + 9 * hourMillis, + title = "Technical deep-dive sessions", + location = "Shoreline Amphitheatre", + description = "Advanced platform engineering and UI architecture workshops.", + ), + // Day 5: Hotel checkout, car rental return, flight out + TimelineEvent( + id = "io_11", + tripId = "2026-1", + type = EventType.ACCOMMODATION, + timestamp = ioBaseTime + 4 * dayMillis + 9 * hourMillis, + title = "Hotel Checkout", + location = "The Redwood Grove Hotel", + language = "English", + description = "Packing and handing off room keys at the express checkout.", + ), + TimelineEvent( + id = "io_12", + tripId = "2026-1", + type = EventType.CAR_RENTAL, + timestamp = ioBaseTime + 4 * dayMillis + 11 * hourMillis, + title = "Car Rental Return", + location = "Zephyr Wheels Rental Center", + description = "Returning the vehicle and signing off the rental agreement.", + ), + TimelineEvent( + id = "io_13", + tripId = "2026-1", + type = EventType.TRANSPORTATION, + timestamp = ioBaseTime + 4 * dayMillis + 14 * hourMillis, + title = "SFO > AMS", + location = "SFO Airport", + description = "Heading home on an overnight outbound flight back to Amsterdam.", + ), + ) + ) + + tourDetails.add( + TourDetail( + "io_detail_1", + "io_6", + "Google I/O Keynotes", + "CONFERENCE", + R.drawable.img_california, + "Tuesday, May 19", + "09:00 AM - 12:00 PM", + "Shoreline Amphitheatre", + "1 Amphitheatre Pkwy, Mountain View, CA 94043", + null, + "The main event of Google I/O where all the major announcements are made. Join Sundar and team to hear about the latest in AI, Android, and more.", + "Main entrance of Shoreline Amphitheatre.", + listOf("Arrive 1 hour early", "Bring your badge"), + ) + ) + + tourDetails.add( + TourDetail( + "io_detail_car", + "io_2", + "Zephyr Wheels Car Rental", + "CAR_RENTAL", + R.drawable.img_california, + "Sunday, May 17", + "11:00 AM", + "Zephyr Wheels Rental Center", + "SFO Airport Rental Car Center, San Francisco, CA", + null, + "Premium Sedan car pickup. Rental includes unlimited mileage, GPS navigation, and full insurance coverage.", + "Main pickup desk at Zephyr Wheels Center.", + listOf("Bring your valid driver license", "Credit card required for deposit"), + ) + ) + + tourDetails.addAll( + listOf( + TourDetail( + "io_detail_boat", + "io_4", + "SFO Golden Gate Boat Trip", + "ACTIVITY", + R.drawable.img_california, + "Monday, May 18", + "10:00 AM", + "Pier 39", + "San Francisco, CA", + null, + "Enjoy a scenic pass around Alcatraz and straight under the magnificent Golden Gate Bridge. Fully guided boat experience.", + "Main ticket kiosk at Pier 39.", + listOf("Wear layers, can get windy", "Bring a camera"), + ), + TourDetail( + "io_detail_sess", + "io_8", + "Advanced Tech & AI Sessions", + "CONFERENCE", + R.drawable.img_california, + "Tuesday, May 19", + "02:00 PM", + "Google I/O Tents", + "Shoreline Amphitheatre", + null, + "Deep dives into Gemini, Adk, and new LLM agent frameworks for mobile integrations.", + "Main sessions tent entrance.", + listOf("Bring your laptop", "Fully charged battery recommended"), + ), + TourDetail( + "io_detail_check", + "io_11", + "The Redwood Grove - Checkout", + "ACCOMMODATION", + R.drawable.img_california, + "Thursday, May 21", + "09:00 AM", + "Front Desk Express", + "The Redwood Grove Hotel", + null, + "Finalizing all auxiliary room charges and handing off room credentials.", + "Hotel lobby front desk.", + listOf("Drop keys in express box"), + ), + TourDetail( + "io_detail_ret", + "io_12", + "Zephyr Wheels - Car Return", + "CAR_RENTAL", + R.drawable.img_california, + "Thursday, May 21", + "11:00 AM", + "Rental Drop Area", + "Zephyr Center Drop-off, SFO", + null, + "Returning vehicle and finalizing mileage check.", + "Main express return aisle.", + listOf("Ensure fuel is topped off"), + ), + TourDetail( + "par_detail_eiffel", + "par_5", + "Eiffel Tower Visit", + "ACTIVITY", + R.drawable.img_paris_skyline, + "Monday, Dec 07", + "10:00 AM", + "Eiffel Tower Entrance", + "Champ de Mars, 5 Av. Anatole France, 75007 Paris", + null, + "Ascend Gustave Eiffel's iconic, 324-meter wrought-iron tower. Offers magnificent, sweeping views across the Paris cityscape and the River Seine.", + "Pillar South Ticket Office", + listOf("Bring printed or digital tickets", "Arrive 30 mins prior for security"), + ), + TourDetail( + "dub_detail_moher", + "dub_4", + "Cliffs of Moher Coastal Drive", + "ACTIVITY", + R.drawable.img_dublin_skyline, + "Sunday, Sep 06", + "10:00 AM", + "Wild Atlantic Way", + "Cliffs of Moher, Liscannor, Co. Clare, Ireland", + null, + "Experience the majestic Cliffs of Moher rising 700 feet over the Atlantic Ocean. An essential, scenic coastal drive along Ireland's stunning Wild Atlantic Way.", + "Visitor Center Car Park", + listOf("Wear comfortable walking shoes", "Prepare for windy coastal weather"), + ), + TourDetail( + "rome_detail_walk", + "rome_7", + "Trevi Fountain & Pantheon Walk", + "ACTIVITY", + R.drawable.img_rome, + "Wednesday, Jan 15", + "10:00 AM", + "Historic Rome Center", + "Piazza della Rotonda, 00186 Roma", + null, + "Enjoy a guided morning stroll through Rome’s cobblestone alleys traversing past the magnificent Trevi Foundation directly to the historic Pantheon.", + "Piazza della Rotonda Obelisk", + listOf("Bring coins to make a wish at the fountain"), + ), + TourDetail( + "rome_detail_tivoli", + "rome_8", + "Tivoli (Villa d'Este) Day Trip", + "ACTIVITY", + R.drawable.img_rome, + "Thursday, Jan 16", + "10:00 AM", + "Villa d'Este Entrance", + "Piazza Trento, 1, 00019 Tivoli RM", + null, + "Visit the enchanting 16th-century Villa d'Este famous for its hillside terraced Italian Renaissance garden and mesmerizing array of elegant fountains.", + "Villa d'Este Ticket Office", + listOf("Comfortable shoes highly recommended"), + ), + ) + ) + + museumDetails.add( + MuseumDetail( + eventId = "io_5", + description = + "San Francisco Museum of Modern Art (SFMOMA) is a modern art museum in San Francisco, California.", + address = "151 3rd St, San Francisco, CA 94103", + openingHours = "Mon-Tue: 10AM-5PM, Thu: 1PM-8PM, Fri-Sun: 10AM-5PM", + admissionPrice = "$25", + ticketWebsite = "https://www.sfmoma.org/tickets/", + rating = "4.6", + phone = "+1 415 555 0142", + ) + ) + + // 2. Dublin (2026-2) - 3 days + events.addAll( + listOf( + TimelineEvent( + id = "dub_1", + tripId = "2026-2", + type = EventType.TRANSPORTATION, + timestamp = dublinBase, + title = "Flight to Dublin", + location = "JFK Terminal 4", + extraInfo = "EI 104", + ), + TimelineEvent( + id = "dub_2", + tripId = "2026-2", + type = EventType.ACCOMMODATION, + timestamp = dublinBase + 4 * hourMillis, + title = "Check-in: The Shelbourne", + location = "St Stephen's Green", + language = "English", + ), + TimelineEvent( + id = "dub_3", + tripId = "2026-2", + type = EventType.FOOD_AND_DRINK, + timestamp = dublinBase + 10 * hourMillis, + title = "Dinner at Fade Street Social", + location = "Fade St", + ), + TimelineEvent( + id = "dub_4", + tripId = "2026-2", + type = EventType.ACTIVITY, + timestamp = dublinBase + 24 * hourMillis + 2 * hourMillis, + title = "Day Tour: Cliffs of Moher", + location = "Pickup from hotel", + ), + TimelineEvent( + id = "dub_5", + tripId = "2026-2", + type = EventType.FOOD_AND_DRINK, + timestamp = dublinBase + 24 * hourMillis + 10 * hourMillis, + title = "Dinner at Temple Bar", + location = "Temple Bar District", + ), + TimelineEvent( + id = "dub_6", + tripId = "2026-2", + type = EventType.TRANSPORTATION, + timestamp = dublinBase + 48 * hourMillis + 5 * hourMillis, + title = "Flight Out", + location = "Dublin Airport", + extraInfo = "EI 105", + ), + ) + ) + + flightDetails.addAll( + listOf( + FlightDetail( + eventId = "dub_1", + airline = "Aer Lingus", + flightNum = "EI 104", + origin = "JFK", + destination = "DUB", + gate = "B22", + seat = "12A", + departureTerminal = "Terminal 4", + arrivalTerminal = "Terminal 2", + arrivalGate = "408", + duration = "6h 55m", + aircraft = "Airbus A330-300", + baggageAllowance = "1 x 23kg Checked, 1 Carry-on", + ), + FlightDetail( + eventId = "dub_6", + airline = "Aer Lingus", + flightNum = "EI 105", + origin = "DUB", + destination = "JFK", + gate = "408", + seat = "14C", + departureTerminal = "Terminal 2", + arrivalTerminal = "Terminal 4", + arrivalGate = "B22", + duration = "7h 15m", + aircraft = "Airbus A330-300", + baggageAllowance = "1 x 23kg Checked, 1 Carry-on", + ), + FlightDetail( + eventId = "io_1", + airline = "United Airlines", + flightNum = "UA 991", + origin = "AMS", + destination = "SFO", + gate = "G22", + seat = "32B", + departureTerminal = "Terminal 2", + arrivalTerminal = "International Terminal", + arrivalGate = "G94", + duration = "10h 25m", + aircraft = "Boeing 777-300ER", + baggageAllowance = "1 x 23kg Checked, 1 Carry-on", + ), + FlightDetail( + eventId = "io_13", + airline = "United Airlines", + flightNum = "UA 992", + origin = "SFO", + destination = "AMS", + gate = "G94", + seat = "34A", + departureTerminal = "International Terminal", + arrivalTerminal = "Terminal 2", + arrivalGate = "G22", + duration = "9h 55m", + aircraft = "Boeing 777-300ER", + baggageAllowance = "1 x 23kg Checked, 1 Carry-on", + ), + FlightDetail( + eventId = "rome_1", + airline = "ITA Airways", + flightNum = "AZ 611", + origin = "JFK", + destination = "FCO", + gate = "A14", + seat = "18C", + departureTerminal = "Terminal 8", + arrivalTerminal = "Terminal 3", + arrivalGate = "D12", + duration = "8h 30m", + aircraft = "Airbus A350-900", + baggageAllowance = "1 x 23kg Checked, 1 Carry-on", + ), + FlightDetail( + eventId = "rome_13", + airline = "ITA Airways", + flightNum = "AZ 610", + origin = "FCO", + destination = "JFK", + gate = "D12", + seat = "19A", + departureTerminal = "Terminal 3", + arrivalTerminal = "Terminal 8", + arrivalGate = "A14", + duration = "9h 10m", + aircraft = "Airbus A350-900", + baggageAllowance = "1 x 23kg Checked, 1 Carry-on", + ), + FlightDetail( + eventId = "seo_1", + airline = "Korean Air", + flightNum = "KE 82", + origin = "JFK", + destination = "ICN", + gate = "B28", + seat = "28H", + departureTerminal = "Terminal 4", + arrivalTerminal = "Terminal 2", + arrivalGate = "242", + duration = "14h 30m", + aircraft = "Boeing 747-8I", + baggageAllowance = "2 x 23kg Checked, 1 Carry-on", + ), + FlightDetail( + eventId = "seo_10", + airline = "Korean Air", + flightNum = "KE 81", + origin = "ICN", + destination = "JFK", + gate = "242", + seat = "29C", + departureTerminal = "Terminal 2", + arrivalTerminal = "Terminal 4", + arrivalGate = "B28", + duration = "13h 45m", + aircraft = "Boeing 747-8I", + baggageAllowance = "2 x 23kg Checked, 1 Carry-on", + ), + FlightDetail( + eventId = "par_1", + airline = "Air France", + flightNum = "AF 7", + origin = "JFK", + destination = "CDG", + gate = "A22", + seat = "24F", + departureTerminal = "Terminal 8", + arrivalTerminal = "Terminal 2E", + arrivalGate = "M42", + duration = "7h 25m", + aircraft = "Boeing 777-300ER", + baggageAllowance = "1 x 23kg Checked, 1 Carry-on", + ), + FlightDetail( + eventId = "par_10", + airline = "Air France", + flightNum = "AF 8", + origin = "CDG", + destination = "JFK", + gate = "M42", + seat = "25E", + departureTerminal = "Terminal 2E", + arrivalTerminal = "Terminal 8", + arrivalGate = "A22", + duration = "8h 15m", + aircraft = "Boeing 777-300ER", + baggageAllowance = "1 x 23kg Checked, 1 Carry-on", + ), + ) + ) + + hotelDetails.addAll( + listOf( + HotelDetail( + eventId = "io_3", + name = "The Redwood Grove Hotel", + address = "123 Sequoia Ave, Mountain View, CA 94043", + checkInTime = ioBaseTime + 15 * hourMillis, + checkOutTime = ioBaseTime + 4 * dayMillis + 9 * hourMillis, + confNumber = "RED-11223", + rating = "4.7", + ratingCount = "850", + pricePerNight = "$250/night", + guests = "1 Adult", + phone = "+1 650 555 0134", + ), + HotelDetail( + eventId = "dub_2", + name = "The Shelbourne", + address = "27 St Stephen's Green, Dublin", + checkInTime = dublinBase + 4 * hourMillis, + checkOutTime = dublinBase + 48 * hourMillis, + confNumber = "SHEL-99882", + rating = "4.8", + ratingCount = "1.2k", + pricePerNight = "$320/night", + guests = "2 Adults", + phone = "+353 20 911 1234", + ), + HotelDetail( + eventId = "rome_2", + name = "Hotel Artemide", + address = "Via Nazionale, 22, 00184 Roma RM", + checkInTime = romeBase + 10 * hourMillis, + checkOutTime = romeBase + 192 * hourMillis + 5 * hourMillis, + confNumber = "ART-33445", + rating = "4.9", + ratingCount = "2.1k", + pricePerNight = "€180/night", + guests = "2 Guests", + phone = "+39 06 496 0123", + ), + HotelDetail( + eventId = "seo_2", + name = "Four Seasons Hotel", + address = "Gwanghwamun, Seoul", + checkInTime = seoulBase + 8 * hourMillis, + checkOutTime = seoulBase + 168 * hourMillis + 5 * hourMillis, + confNumber = "FSS-55667", + rating = "4.8", + ratingCount = "1.8k", + pricePerNight = "₩450,000/night", + guests = "2 Guests", + phone = "+82 2 555 0123", + ), + HotelDetail( + eventId = "par_2", + name = "Hotel Le Meurice", + address = "Rue de Rivoli, 75001 Paris", + checkInTime = parisBase + 4 * hourMillis, + checkOutTime = parisBase + 168 * hourMillis + 5 * hourMillis, + confNumber = "LM-77889", + rating = "4.9", + ratingCount = "1.5k", + pricePerNight = "€950/night", + guests = "2 Adults", + phone = "+33 1 99 00 12 34", + ), + ) + ) + + diningDetails.addAll( + listOf( + DiningDetail( + eventId = "dub_3", + restaurantName = "Fade Street Social", + address = "4-6 Fade St, Dublin", + reservationTime = dublinBase + 10 * hourMillis, + partySize = 2, + rating = "4.5", + reviewCount = "324", + priceRange = "$$ - Moderate", + phone = "+353 20 911 4321", + ), + DiningDetail( + eventId = "dub_5", + restaurantName = "The Temple Bar Pub", + address = "47-48 Temple Bar, Dublin", + reservationTime = dublinBase + 24 * hourMillis + 10 * hourMillis, + partySize = 4, + rating = "4.7", + reviewCount = "1,280", + priceRange = "$$ - Moderate", + phone = "+353 20 911 8765", + ), + DiningDetail( + eventId = "io_7", + restaurantName = "Shoreline Cafe", + address = "Shoreline Amphitheatre, Mountain View", + reservationTime = ioBaseTime + 2 * dayMillis + 13 * hourMillis, + partySize = 4, + rating = "4.2", + reviewCount = "150", + priceRange = "$$ - Moderate", + phone = "+1 650 555 0198", + ), + DiningDetail( + eventId = "io_9", + restaurantName = "Quantum Bites", + address = "Quantum Bites Restaurant, Mountain View", + reservationTime = ioBaseTime + 2 * dayMillis + 19 * hourMillis, + partySize = 2, + rating = "4.6", + reviewCount = "89", + priceRange = "$$$ - Expensive", + phone = "+1 650 555 0199", + ), + DiningDetail( + eventId = "rome_4", + restaurantName = "Da Enzo al 29", + address = "Via dei Vascellari, 29, 00153 Roma", + reservationTime = romeBase + 24 * hourMillis + 10 * hourMillis, + partySize = 2, + rating = "4.8", + reviewCount = "1,500", + priceRange = "$ - Inexpensive", + phone = "+39 06 581 2345", + ), + DiningDetail( + eventId = "rome_6", + restaurantName = "Pizzarium Bonci", + address = "Via della Meloria, 43, 00136 Roma", + reservationTime = romeBase + 48 * hourMillis + 5 * hourMillis, + partySize = 1, + rating = "4.7", + reviewCount = "2,000", + priceRange = "$ - Inexpensive", + phone = "+39 06 3974 5416", + ), + DiningDetail( + eventId = "rome_10", + restaurantName = "Armando al Pantheon", + address = "Salita de' Crescenzi, 31, 00186 Roma", + reservationTime = romeBase + 120 * hourMillis + 10 * hourMillis, + partySize = 2, + rating = "4.6", + reviewCount = "1,200", + priceRange = "$$ - Moderate", + phone = "+39 06 6880 3034", + ), + DiningDetail( + eventId = "seo_3", + restaurantName = "Jungsik", + address = "11 Seolleung-ro 158-gil, Gangnam-gu, Seoul", + reservationTime = seoulBase + 11 * hourMillis, + partySize = 2, + rating = "4.9", + reviewCount = "600", + priceRange = "$$$$ - Very Expensive", + phone = "+82 2 517 4654", + ), + DiningDetail( + eventId = "par_4", + restaurantName = "Le Jules Verne", + address = "Eiffel Tower, Avenue Gustave Eiffel, 75007 Paris", + reservationTime = parisBase + 24 * hourMillis + 10 * hourMillis, + partySize = 2, + rating = "4.7", + reviewCount = "1,800", + priceRange = "$$$$ - Very Expensive", + phone = "+33 1 45 55 61 44", + ), + DiningDetail( + eventId = "par_9", + restaurantName = "Pierre Hermé", + address = "Le Marais, Paris", + reservationTime = parisBase + 144 * hourMillis + 5 * hourMillis, + partySize = 2, + rating = "4.8", + reviewCount = "2,500", + priceRange = "$$ - Moderate", + phone = "+33 1 43 54 47 77", + ), + ) + ) + + activityDetails.add( + ActivityDetail( + eventId = "dub_4", + activityName = "Cliffs of Moher Coastal Drive", + address = "Wild Atlantic Way", + durationMinutes = 480, + ) + ) + + // 3. Rome (2025-1) - 9 days + events.addAll( + listOf( + TimelineEvent( + id = "rome_1", + tripId = "2025-1", + type = EventType.TRANSPORTATION, + timestamp = romeBase, + title = "Flight to Rome (FCO)", + location = "JFK Terminal 8", + ), + TimelineEvent( + id = "rome_2", + tripId = "2025-1", + type = EventType.ACCOMMODATION, + timestamp = romeBase + 10 * hourMillis, + title = "Check-in: Hotel Artemide", + location = "Via Nazionale", + language = "Italian", + ), + TimelineEvent( + id = "rome_3", + tripId = "2025-1", + type = EventType.CULTURE, + timestamp = romeBase + 24 * hourMillis + 1 * hourMillis, + title = "Colosseum & Roman Forum Tour", + location = "Piazza del Colosseo", + audioNotes = listOf("The underground area was fascinating to see."), + ), + TimelineEvent( + id = "rome_4", + tripId = "2025-1", + type = EventType.FOOD_AND_DRINK, + timestamp = romeBase + 24 * hourMillis + 10 * hourMillis, + title = "Dinner at Da Enzo al 29", + location = "Trastevere", + ), + TimelineEvent( + id = "rome_5", + tripId = "2025-1", + type = EventType.CULTURE, + timestamp = romeBase + 48 * hourMillis + 1 * hourMillis, + title = "Vatican Museums & Sistine Chapel", + location = "Vatican City", + ), + TimelineEvent( + id = "rome_6", + tripId = "2025-1", + type = EventType.FOOD_AND_DRINK, + timestamp = romeBase + 48 * hourMillis + 5 * hourMillis, + title = "Lunch at Pizzarium Bonci", + location = "Via della Meloria", + ), + TimelineEvent( + id = "rome_7", + tripId = "2025-1", + type = EventType.ACTIVITY, + timestamp = romeBase + 72 * hourMillis + 1 * hourMillis, + title = "Trevi Fountain & Pantheon Walk", + location = "Rome Center", + audioNotes = + listOf( + "Found the best gelato place near Trevi Fountain with incredible pistachio flavor." + ), + ), + TimelineEvent( + id = "rome_8", + tripId = "2025-1", + type = EventType.ACTIVITY, + timestamp = romeBase + 96 * hourMillis + 1 * hourMillis, + title = "Day trip to Tivoli (Villa d'Este)", + location = "Tivoli", + ), + TimelineEvent( + id = "rome_9", + tripId = "2025-1", + type = EventType.CULTURE, + timestamp = romeBase + 120 * hourMillis + 1 * hourMillis, + title = "Borghese Gallery", + location = "Villa Borghese", + ), + TimelineEvent( + id = "rome_10", + tripId = "2025-1", + type = EventType.FOOD_AND_DRINK, + timestamp = romeBase + 120 * hourMillis + 10 * hourMillis, + title = "Dinner at Armando al Pantheon", + location = "Salita de' Crescenzi", + ), + TimelineEvent( + id = "rome_11", + tripId = "2025-1", + type = EventType.ACTIVITY, + timestamp = romeBase + 144 * hourMillis + 1 * hourMillis, + title = "Trastevere Neighborhood Walk", + location = "Trastevere", + ), + TimelineEvent( + id = "rome_12", + tripId = "2025-1", + type = EventType.ACTIVITY, + timestamp = romeBase + 168 * hourMillis + 1 * hourMillis, + title = "Appian Way Bike Tour", + location = "Via Appia Antica", + ), + TimelineEvent( + id = "rome_13", + tripId = "2025-1", + type = EventType.TRANSPORTATION, + timestamp = romeBase + 192 * hourMillis + 5 * hourMillis, + title = "Flight Out", + location = "FCO Airport", + ), + ) + ) + + // 4. Seoul (2025-2) - 8 days + events.addAll( + listOf( + TimelineEvent( + id = "seo_1", + tripId = "2025-2", + type = EventType.TRANSPORTATION, + timestamp = seoulBase, + title = "Flight to Seoul (ICN)", + location = "JFK Terminal 4", + ), + TimelineEvent( + id = "seo_2", + tripId = "2025-2", + type = EventType.ACCOMMODATION, + timestamp = seoulBase + 8 * hourMillis, + title = "Check-in: Four Seasons Hotel", + location = "Gwanghwamun", + language = "Korean", + ), + TimelineEvent( + id = "seo_3", + tripId = "2025-2", + type = EventType.FOOD_AND_DRINK, + timestamp = seoulBase + 11 * hourMillis, + title = "Dinner at Jungsik", + location = "Cheongdam-dong", + ), + TimelineEvent( + id = "seo_4", + tripId = "2025-2", + type = EventType.CULTURE, + timestamp = seoulBase + 24 * hourMillis + 1 * hourMillis, + title = "Gyeongbokgung Palace Tour", + location = "Jongno-gu", + ), + TimelineEvent( + id = "seo_5", + tripId = "2025-2", + type = EventType.ACTIVITY, + timestamp = seoulBase + 48 * hourMillis + 1 * hourMillis, + title = "N Seoul Tower & Bukchon", + location = "Namsan", + ), + TimelineEvent( + id = "seo_6", + tripId = "2025-2", + type = EventType.ACTIVITY, + timestamp = seoulBase + 72 * hourMillis + 1 * hourMillis, + title = "Myeongdong Shopping", + location = "Myeongdong", + ), + TimelineEvent( + id = "seo_7", + tripId = "2025-2", + type = EventType.ACTIVITY, + timestamp = seoulBase + 96 * hourMillis + 1 * hourMillis, + title = "DMZ Day Tour", + location = "DMZ", + ), + TimelineEvent( + id = "seo_8", + tripId = "2025-2", + type = EventType.ACTIVITY, + timestamp = seoulBase + 120 * hourMillis + 1 * hourMillis, + title = "Dongdaemun Design Plaza", + location = "Eulji-ro", + ), + TimelineEvent( + id = "seo_9", + tripId = "2025-2", + type = EventType.ACTIVITY, + timestamp = seoulBase + 144 * hourMillis + 1 * hourMillis, + title = "Gangnam District Explore", + location = "Gangnam", + ), + TimelineEvent( + id = "seo_10", + tripId = "2025-2", + type = EventType.TRANSPORTATION, + timestamp = seoulBase + 168 * hourMillis + 5 * hourMillis, + title = "Flight Out", + location = "ICN Airport", + ), + ) + ) + + // 5. Paris (2026-3) - 8 days + events.addAll( + listOf( + TimelineEvent( + id = "par_1", + tripId = "2026-3", + type = EventType.TRANSPORTATION, + timestamp = parisBase, + title = "Flight to Paris (CDG)", + location = "JFK Terminal 8", + ), + TimelineEvent( + id = "par_2", + tripId = "2026-3", + type = EventType.ACCOMMODATION, + timestamp = parisBase + 4 * hourMillis, + title = "Check-in: Hotel Le Meurice", + location = "Rue de Rivoli", + language = "French", + ), + TimelineEvent( + id = "par_3", + tripId = "2026-3", + type = EventType.CULTURE, + timestamp = parisBase + 24 * hourMillis + 1 * hourMillis, + title = "Visit to the Louvre", + location = "Rue de Rivoli", + ), + TimelineEvent( + id = "par_4", + tripId = "2026-3", + type = EventType.FOOD_AND_DRINK, + timestamp = parisBase + 24 * hourMillis + 10 * hourMillis, + title = "Dinner at Le Jules Verne", + location = "Eiffel Tower", + placeId = "ChIJl_7p8uFv5kcRj5ZGEf31ILM", + ), + TimelineEvent( + id = "par_5", + tripId = "2026-3", + type = EventType.ACTIVITY, + timestamp = parisBase + 48 * hourMillis + 1 * hourMillis, + title = "Eiffel Tower Visit", + location = "Champ de Mars", + ), + TimelineEvent( + id = "par_6", + tripId = "2026-3", + type = EventType.CULTURE, + timestamp = parisBase + 72 * hourMillis + 1 * hourMillis, + title = "Versailles Palace Tour", + location = "Versailles", + ), + TimelineEvent( + id = "par_7", + tripId = "2026-3", + type = EventType.ACTIVITY, + timestamp = parisBase + 96 * hourMillis + 1 * hourMillis, + title = "Montmartre Walk", + location = "Sacre-Coeur", + ), + TimelineEvent( + id = "par_8", + tripId = "2026-3", + type = EventType.ACTIVITY, + timestamp = parisBase + 120 * hourMillis + 1 * hourMillis, + title = "Seine River Cruise", + location = "Pont de l Alma", + ), + TimelineEvent( + id = "par_9", + tripId = "2026-3", + type = EventType.FOOD_AND_DRINK, + timestamp = parisBase + 144 * hourMillis + 5 * hourMillis, + title = "Pastry and Macaron Tasting", + location = "Le Marais", + ), + TimelineEvent( + id = "par_10", + tripId = "2026-3", + type = EventType.TRANSPORTATION, + timestamp = parisBase + 168 * hourMillis + 5 * hourMillis, + title = "Flight Out", + location = "CDG Airport", + ), + ) + ) + museumDetails.add( + MuseumDetail( + eventId = "par_3", + description = + "The Louvre, or the Louvre Museum, is the world's most-visited museum and a historic monument in Paris, France.", + address = "Rue de Rivoli, 75001 Paris, France", + openingHours = "Mon, Wed, Thu, Sat, Sun: 9AM–6PM, Fri: 9AM–9:45PM, Tue: Closed", + admissionPrice = "€17", + ticketWebsite = "https://www.louvre.fr/en", + rating = "4.7", + phone = "+33 1 99 00 56 78", + infoUrls = listOf("https://www.louvre.fr/en/visit/faq"), + ) + ) + + expenses.addAll( + listOf( + Expense( + title = "SFO Taxi", + amount = 45.0, + currency = "USD", + category = "Transport", + tripId = "2026-1", + timestamp = 1000L, + ), + Expense( + title = "SFMOMA Ticket", + amount = 25.0, + currency = "USD", + category = "Activity", + tripId = "2026-1", + timestamp = 2000L, + ), + Expense( + title = "Dinner @ Quantum Bites", + amount = 120.0, + currency = "USD", + category = "Food", + tripId = "2026-1", + timestamp = 3000L, + ), + Expense( + title = "Colosseum Ticket", + amount = 16.0, + currency = "EUR", + category = "Culture", + tripId = "2025-1", + timestamp = romeBase + 24 * hourMillis + 1 * hourMillis, + ), + Expense( + title = "Gelato", + amount = 5.5, + currency = "EUR", + category = "Food", + tripId = "2025-1", + timestamp = romeBase + 30 * hourMillis, + ), + Expense( + title = "Myeongdong Street Food", + amount = 15000.0, + currency = "KRW", + category = "Food", + tripId = "2025-2", + timestamp = seoulBase + 72 * hourMillis + 1 * hourMillis, + ), + Expense( + title = "N Seoul Tower", + amount = 12000.0, + currency = "KRW", + category = "Activity", + tripId = "2025-2", + timestamp = seoulBase + 24 * hourMillis + 1 * hourMillis, + ), + ) + ) + + voiceNotes.addAll( + listOf( + VoiceNoteEntity( + id = "rome_vn_1", + tripId = "2025-1", + title = "Colosseum Visit", + transcription = + "The Colosseum was amazing. We saw the underground area and it was fascinating.", + timestamp = romeBase + 24 * hourMillis + 10 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "rome_vn_2", + tripId = "2025-1", + title = "Gelato near Trevi", + transcription = + "Found the best gelato place near Trevi Fountain. Pistachio flavor was incredible.", + timestamp = romeBase + 48 * hourMillis + 10 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "rome_vn_3", + tripId = "2025-1", + title = "Vatican Museums", + transcription = "The Sistine Chapel was breathtaking. So crowded though, but worth it.", + timestamp = romeBase + 72 * hourMillis + 10 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "rome_vn_4", + tripId = "2025-1", + title = "Pantheon", + transcription = + "The dome of the Pantheon is a marvel. Light coming through the oculus was beautiful.", + timestamp = romeBase + 96 * hourMillis + 10 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "rome_vn_5", + tripId = "2025-1", + title = "Trastevere Dinner", + transcription = + "Had amazing pasta in Trastevere. The atmosphere there at night is so lively.", + timestamp = romeBase + 120 * hourMillis + 19 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "rome_vn_6", + tripId = "2025-1", + title = "Borghese Gallery", + transcription = + "Bernini sculptures at Borghese are unbelievable. Apollo and Daphne looked so real.", + timestamp = romeBase + 144 * hourMillis + 10 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "rome_vn_7", + tripId = "2025-1", + title = "Appian Way Bike", + transcription = + "Biking on the ancient Appian Way was a bit bumpy but a great experience.", + timestamp = romeBase + 168 * hourMillis + 10 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "seoul_vn_1", + tripId = "2025-2", + title = "Gyeongbokgung Palace", + transcription = "The changing of the guard ceremony was cool. Palace grounds are huge.", + timestamp = seoulBase + 24 * hourMillis + 10 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "seoul_vn_2", + tripId = "2025-2", + title = "N Seoul Tower View", + transcription = "Great view of the city from N Seoul Tower. Sunset was beautiful.", + timestamp = seoulBase + 48 * hourMillis + 18 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "seoul_vn_3", + tripId = "2025-2", + title = "Bukchon Hanok Village", + transcription = + "Walking through the traditional houses was nice. Very quiet and peaceful.", + timestamp = seoulBase + 72 * hourMillis + 11 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "seoul_vn_4", + tripId = "2025-2", + title = "Myeongdong Street Food", + transcription = "Tried so many street foods in Myeongdong. The egg bread was delicious.", + timestamp = seoulBase + 96 * hourMillis + 19 * hourMillis, + matchingEventsJson = "[]", + ), + VoiceNoteEntity( + id = "seoul_vn_5", + tripId = "2025-2", + title = "Gangnam District", + transcription = + "Gangnam is so modern and bustling. Lots of shopping and high-tech stuff.", + timestamp = seoulBase + 120 * hourMillis + 15 * hourMillis, + matchingEventsJson = "[]", + ), + ) + ) + } +} diff --git a/jetpacker/android/data/trips/src/main/kotlin/com/example/jetpacker/data/trips/Trip.kt b/jetpacker/android/data/trips/src/main/kotlin/com/example/jetpacker/data/trips/Trip.kt new file mode 100644 index 00000000..646500eb --- /dev/null +++ b/jetpacker/android/data/trips/src/main/kotlin/com/example/jetpacker/data/trips/Trip.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.trips + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.example.jetpacker.data.itinerary.Converters + +@Entity(tableName = "trips") +@TypeConverters(Converters::class) +data class Trip( + @PrimaryKey @JvmField val id: String, + @JvmField val title: String, + @JvmField val location: String, + @JvmField val startDate: Long, + @JvmField val endDate: Long, + @JvmField val imageRes: Int? = null, + @JvmField val imageUri: String? = null, + @JvmField val participants: List = emptyList(), + @JvmField val tripSummaryAndTips: String? = null, +) diff --git a/jetpacker/android/data/trips/src/main/kotlin/com/example/jetpacker/data/trips/TripDao.kt b/jetpacker/android/data/trips/src/main/kotlin/com/example/jetpacker/data/trips/TripDao.kt new file mode 100644 index 00000000..e2ab7aca --- /dev/null +++ b/jetpacker/android/data/trips/src/main/kotlin/com/example/jetpacker/data/trips/TripDao.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.trips + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object (DAO) for creating, reading, updating, and deleting trip items. + */ +@Dao +interface TripDao { + @Query("SELECT * FROM trips") fun getAllTrips(): Flow> + + @Query("SELECT * FROM trips WHERE id = :tripId") fun getTripById(tripId: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTrip(trip: Trip) + + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTrips(trips: List) + + @Delete suspend fun deleteTrip(trip: Trip) + + @Query("DELETE FROM trips") suspend fun deleteAllTrips() +} diff --git a/jetpacker/android/feature/appfunctions/build.gradle.kts b/jetpacker/android/feature/appfunctions/build.gradle.kts new file mode 100644 index 00000000..ba7f5697 --- /dev/null +++ b/jetpacker/android/feature/appfunctions/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) +} + +android { + namespace = "com.example.jetpacker.feature.appfunctions" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + implementation(project(":data:trips")) + implementation(project(":data:itinerary")) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appfunctions) + "ksp"(libs.androidx.appfunctions.compiler) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.core) +} + +kotlin { jvmToolchain(17) } diff --git a/jetpacker/android/feature/appfunctions/src/main/AndroidManifest.xml b/jetpacker/android/feature/appfunctions/src/main/AndroidManifest.xml new file mode 100644 index 00000000..622a83fd --- /dev/null +++ b/jetpacker/android/feature/appfunctions/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/JetPackerAppFunctionService.kt b/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/JetPackerAppFunctionService.kt new file mode 100644 index 00000000..d3653cec --- /dev/null +++ b/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/JetPackerAppFunctionService.kt @@ -0,0 +1,333 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.appfunctions + +import androidx.annotation.RequiresApi +import androidx.appfunctions.AppFunction +import androidx.appfunctions.AppFunctionService +import androidx.appfunctions.AppFunctionServiceEntryPoint +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +import com.example.jetpacker.data.itinerary.DayTheme +import com.example.jetpacker.data.itinerary.DayThemeDao +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.Expense +import com.example.jetpacker.data.itinerary.ExpenseDao +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +@RequiresApi(36) +@AndroidEntryPoint +@AppFunctionServiceEntryPoint( + serviceName = "JetPackerAppFunctionService", + appFunctionXmlFileName = "jetpacker_app_function_service" +) +abstract class BaseJetPackerAppFunctionService : AppFunctionService() { + @Inject internal lateinit var tripDao: TripDao + @Inject internal lateinit var expenseDao: ExpenseDao + @Inject internal lateinit var eventDao: EventDao + @Inject internal lateinit var dayThemeDao: DayThemeDao + + /** + * Looks for trips based on optional filters like id, title (name), location, and dates. + * + * @param id The unique identifier of the trip. + * @param title The title or name of the trip. + * @param location The destination location. + * @param startDate The minimum start date in milliseconds. + * @param endDate The maximum end date in milliseconds. + * @return A list of trips matching the filters. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun searchTrip( + id: String? = null, + title: String? = null, + location: String? = null, + startDate: Long? = null, + endDate: Long? = null + ): List { + return withContext(Dispatchers.IO) { + tripDao.getAllTrips().first() + .filter { trip -> + (id.isNullOrEmpty() || trip.id == id) && + (title.isNullOrEmpty() || trip.title.contains(title, ignoreCase = true)) && + (location.isNullOrEmpty() || trip.location.contains(location, ignoreCase = true)) && + (startDate == null || trip.startDate >= startDate) && + (endDate == null || trip.endDate <= endDate) + } + .map { it.toSerializable() } + } + } + + /** + * Creates a new trip. + * + * @param title The title of the trip. + * @param location The destination location. + * @param startDate The start date in milliseconds. + * @param endDate The end date in milliseconds. + * @return The created trip. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun createTrip( + title: String, + location: String, + startDate: Long, + endDate: Long + ): TripSerializable { + val trip = Trip( + id = UUID.randomUUID().toString(), + title = title, + location = location, + startDate = startDate, + endDate = endDate + ) + return withContext(Dispatchers.IO) { + tripDao.insertTrip(trip) + trip.toSerializable() + } + } + + /** + * Adds an expense to a trip. + * + * @param tripId The ID of the trip to add the expense to. + * @param title The title of the expense. + * @param amount The amount of the expense. + * @param currency The currency of the expense. + * @param category The category of the expense. + * @return The added expense. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun addExpense( + tripId: String, + title: String, + amount: Double, + currency: String, + category: String + ): ExpenseSerializable { + val expense = Expense( + tripId = tripId, + title = title, + amount = amount, + currency = currency, + category = category + ) + return withContext(Dispatchers.IO) { + expenseDao.insertExpense(expense) + expense.toSerializable() + } + } + + /** + * Lists all expenses for a specific trip. + * + * @param tripId The ID of the trip. + * @return A list of expenses for the trip. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getExpenses(tripId: String): List { + return withContext(Dispatchers.IO) { + expenseDao.getExpensesForTrip(tripId).first().map { it.toSerializable() } + } + } + + /** + * Retrieves the itinerary events for a specific trip. + * + * @param tripId The ID of the trip. + * @return A list of events in the itinerary. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getItinerary(tripId: String): List { + return withContext(Dispatchers.IO) { + eventDao.getEventsForTrip(tripId).first().map { it.toSerializable() } + } + } + + /** + * Adds a new event to a trip's itinerary. + * + * @param tripId The ID of the trip. + * @param title The title of the event. + * @param type The type of event (e.g., ACTIVITY, FOOD_AND_DRINK). + * @param timestamp The time of the event. + * @param location The location where the event takes place. + * @param description An optional description of the event. + * @return The added event. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun addItineraryEvent( + tripId: String, + title: String, + type: String, + timestamp: Long, + location: String, + description: String? + ): TimelineEventSerializable { + val eventType = try { + EventType.valueOf(type.uppercase()) + } catch (_: IllegalArgumentException) { + EventType.ACTIVITY + } + val event = TimelineEvent( + id = UUID.randomUUID().toString(), + tripId = tripId, + type = eventType, + timestamp = timestamp, + title = title, + location = location, + description = description + ) + return withContext(Dispatchers.IO) { + eventDao.insertEvent(event) + event.toSerializable() + } + } + + /** + * Retrieves voice notes associated with a trip. + * + * @param tripId The ID of the trip. + * @return A list of voice notes. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getVoiceNotes(tripId: String): List { + return withContext(Dispatchers.IO) { + eventDao.getVoiceNotesForTrip(tripId).first().map { it.toSerializable() } + } + } + + /** + * Adds a voice note transcription to a trip. + * + * @param tripId The ID of the trip. + * @param title The title of the voice note. + * @param transcription The text content of the voice note. + * @return The added voice note. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun addVoiceNote( + tripId: String, + title: String, + transcription: String + ): VoiceNoteSerializable { + val note = VoiceNoteEntity( + id = UUID.randomUUID().toString(), + tripId = tripId, + title = title, + transcription = transcription, + timestamp = System.currentTimeMillis(), + matchingEventsJson = "[]" + ) + return withContext(Dispatchers.IO) { + eventDao.insertVoiceNote(note) + note.toSerializable() + } + } + + /** + * Retrieves day themes for a specific trip. + * + * @param tripId The ID of the trip. + * @return A list of day themes. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getDayThemes(tripId: String): List { + return withContext(Dispatchers.IO) { + dayThemeDao.getThemesForTrip(tripId).first().map { it.toSerializable() } + } + } + + /** + * Sets a theme for a specific day in a trip. + * + * @param tripId The ID of the trip. + * @param date The date for the theme (e.g., "2026-05-15"). + * @param theme The theme text. + * @return The updated or added day theme. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun setDayTheme( + tripId: String, + date: String, + theme: String + ): DayThemeSerializable { + val dayTheme = DayTheme( + id = UUID.randomUUID().toString(), + tripId = tripId, + date = date, + theme = theme + ) + return withContext(Dispatchers.IO) { + dayThemeDao.insertTheme(dayTheme) + dayTheme.toSerializable() + } + } + + private fun Trip.toSerializable() = TripSerializable( + id = id, + title = title, + location = location, + startDate = startDate, + endDate = endDate, + participants = participants + ) + + private fun Expense.toSerializable() = ExpenseSerializable( + id = id, + tripId = tripId, + title = title, + amount = amount, + currency = currency, + category = category, + timestamp = timestamp + ) + + private fun TimelineEvent.toSerializable() = TimelineEventSerializable( + id = id, + tripId = tripId, + type = type.name, + timestamp = timestamp, + title = title, + location = location, + description = description + ) + + private fun VoiceNoteEntity.toSerializable() = VoiceNoteSerializable( + id = id, + tripId = tripId, + title = title, + transcription = transcription, + timestamp = timestamp + ) + + private fun DayTheme.toSerializable() = DayThemeSerializable( + id = id, + tripId = tripId, + date = date, + theme = theme + ) +} diff --git a/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/TripSerializable.kt b/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/TripSerializable.kt new file mode 100644 index 00000000..e8197598 --- /dev/null +++ b/jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/TripSerializable.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.appfunctions + +import androidx.appfunctions.AppFunctionSerializable + +/** + * Represents a trip. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class TripSerializable( + /** The trip's unique identifier. */ + val id: String, + /** The trip's title. */ + val title: String, + /** The trip's destination location. */ + val location: String, + /** The trip's start date in milliseconds. */ + val startDate: Long, + /** The trip's end date in milliseconds. */ + val endDate: Long, + /** A list of participants. */ + val participants: List, +) + +/** + * Represents an expense associated with a trip. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class ExpenseSerializable( + /** The expense's unique identifier. */ + val id: String, + /** The trip ID this expense is associated with. */ + val tripId: String, + /** The expense title or description. */ + val title: String, + /** The amount spent. */ + val amount: Double, + /** The currency code (e.g., USD, EUR). */ + val currency: String, + /** The expense category (e.g., Food, Transport). */ + val category: String, + /** The timestamp of the expense. */ + val timestamp: Long, +) + +/** + * Represents an event in a trip itinerary. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class TimelineEventSerializable( + /** The event's unique identifier. */ + val id: String, + /** The trip ID this event belongs to. */ + val tripId: String, + /** The type of event (e.g., TRANSPORTATION, ACCOMMODATION, FOOD_AND_DRINK). */ + val type: String, + /** The timestamp of the event. */ + val timestamp: Long, + /** The title of the event. */ + val title: String, + /** The location of the event. */ + val location: String, + /** A description of the event. */ + val description: String?, +) + +/** + * Represents a voice note or memo associated with a trip. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class VoiceNoteSerializable( + /** The voice note's unique identifier. */ + val id: String, + /** The trip ID this voice note is linked to. */ + val tripId: String, + /** The title of the voice note. */ + val title: String, + /** The transcription text of the voice note. */ + val transcription: String, + /** The timestamp when it was recorded. */ + val timestamp: Long, +) + +/** + * Represents a theme assigned to a specific day of a trip. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class DayThemeSerializable( + /** The theme's unique identifier. */ + val id: String, + /** The trip ID this theme belongs to. */ + val tripId: String, + /** The date in YYYY-MM-DD format. */ + val date: String, + /** The descriptive theme for the day. */ + val theme: String, +) diff --git a/jetpacker/android/feature/create_trip/build.gradle.kts b/jetpacker/android/feature/create_trip/build.gradle.kts new file mode 100644 index 00000000..45c967ea --- /dev/null +++ b/jetpacker/android/feature/create_trip/build.gradle.kts @@ -0,0 +1,80 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.create_trip" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { compose = true } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + + implementation(project(":core:flags")) + implementation(project(":core:ui")) + implementation(project(":data:trips")) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.coil.compose) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.mlkit.genai.prompt) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) + + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripPanel.kt b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripPanel.kt new file mode 100644 index 00000000..f8c01607 --- /dev/null +++ b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripPanel.kt @@ -0,0 +1,442 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.create_trip + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.ExperimentalLayoutApi +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ImageNotSupported +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.RemoveCircleOutline +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.core.ui.SekuyaFontFamily +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun CreateTripPanelContent( + uiState: CreateTripUiState, + viewModel: CreateTripViewModel, + onCollapse: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val photoPickerLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.PickVisualMedia()) { + uri: Uri? -> + uri?.let { + context.contentResolver.takePersistableUriPermission( + it, + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } + viewModel.onImageUriChange(uri?.toString()) + } + + var showStartDatePicker by remember { mutableStateOf(false) } + var showEndDatePicker by remember { mutableStateOf(false) } + var participantName by remember { mutableStateOf("") } + + val dateFormatter = remember { DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.getDefault()) } + + if (showStartDatePicker) { + DatePickerModal( + onDateSelected = { selectedDate -> + selectedDate?.let { millis -> + val date = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate() + viewModel.onStartDateChange(dateFormatter.format(date)) + } + }, + onDismiss = { showStartDatePicker = false }, + ) + } + + if (showEndDatePicker) { + DatePickerModal( + onDateSelected = { selectedDate -> + selectedDate?.let { millis -> + val date = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate() + viewModel.onEndDateChange(dateFormatter.format(date)) + } + }, + onDismiss = { showEndDatePicker = false }, + ) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + Row( + modifier = + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton( + onClick = onCollapse, + modifier = Modifier.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp), + ) { Icon(Icons.Rounded.Close, contentDescription = "Back") } + Text( + if (uiState.isEditing) "EDIT TRIP" else "CREATE TRIP", + style = + MaterialTheme.typography.titleLarge.copy( + fontSize = 22.sp, + fontFamily = SekuyaFontFamily, + ), + color = MaterialTheme.colorScheme.secondary, + ) + } + }, + bottomBar = { + Box( + modifier = + Modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 24.dp, vertical = 12.dp) + ) { + Button( + onClick = { viewModel.createTrip() }, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = CircleShape, + enabled = + uiState.title.isNotBlank() && + uiState.startDate.isNotBlank() && + uiState.endDate.isNotBlank() && + !uiState.isLoading && + !uiState.isGenerating, + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text(if (uiState.isEditing) "Save Changes" else "Create Trip") + } + } + } + }, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + Column( + modifier = + Modifier.fillMaxSize().padding(horizontal = 24.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(8.dp)) + + // Image Section + Card( + modifier = Modifier.fillMaxWidth().height(160.dp), + shape = MaterialTheme.shapes.large, + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + enabled = !uiState.isGenerating, + onClick = { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + if (uiState.imageUri != null) { + AsyncImage( + model = uiState.imageUri, + contentDescription = "Selected Picture", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Filled.ImageNotSupported, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + Spacer(Modifier.height(16.dp)) + Text("Tap to pick a picture", style = MaterialTheme.typography.labelMedium) + } + } + } + } + + TextField( + value = uiState.title, + onValueChange = { viewModel.onTitleChange(it) }, + placeholder = { Text("Title") }, + maxLines = 1, + enabled = !uiState.isGenerating, + shape = CircleShape, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + modifier = Modifier.fillMaxWidth(), + ) + + TextField( + value = uiState.location, + onValueChange = { viewModel.onLocationChange(it) }, + placeholder = { Text("Location") }, + maxLines = 1, + enabled = !uiState.isGenerating, + shape = CircleShape, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier.weight(1f).defaultMinSize(minHeight = 48.dp).clip(CircleShape).clickable(enabled = !uiState.isGenerating) { + showStartDatePicker = true + } + ) { + TextField( + value = uiState.startDate, + onValueChange = {}, + placeholder = { Text("Start Date") }, + maxLines = 1, + enabled = false, + shape = CircleShape, + colors = + TextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledIndicatorColor = Color.Transparent, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + Box( + modifier = + Modifier.weight(1f).defaultMinSize(minHeight = 48.dp).clip(CircleShape).clickable(enabled = !uiState.isGenerating) { + showEndDatePicker = true + } + ) { + TextField( + value = uiState.endDate, + onValueChange = {}, + placeholder = { Text("End Date") }, + maxLines = 1, + enabled = false, + shape = CircleShape, + colors = + TextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledIndicatorColor = Color.Transparent, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + } + + // Participants Section + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Participants", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + uiState.participants.forEach { participant -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(participant, modifier = Modifier.weight(1f)) + IconButton( + onClick = { viewModel.onRemoveParticipant(participant) }, + enabled = !uiState.isGenerating, + ) { + Icon( + Icons.Rounded.RemoveCircleOutline, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + + TextField( + value = participantName, + onValueChange = { participantName = it }, + placeholder = { Text("New Participant") }, + maxLines = 1, + enabled = !uiState.isGenerating, + shape = CircleShape, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + trailingIcon = { + IconButton( + onClick = { + if (participantName.isNotBlank()) { + viewModel.onAddParticipant(participantName) + participantName = "" + } + }, + enabled = participantName.isNotBlank() && !uiState.isGenerating, + ) { + Icon(Icons.Default.Add, contentDescription = "Add") + } + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(Modifier.height(24.dp)) + } + if (uiState.isGenerating) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DatePickerModal(onDateSelected: (Long?) -> Unit, onDismiss: () -> Unit) { + val datePickerState = + rememberDatePickerState( + selectableDates = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + val todayUtc = + FeatureFlags.OVERRIDE_CURRENT_TIME_MILLIS?.let { + Instant.ofEpochMilli(it) + .atZone(ZoneOffset.UTC) + .toLocalDate() + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + } + ?: LocalDate.now(ZoneOffset.UTC) + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + return utcTimeMillis >= todayUtc + } + } + ) + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onDateSelected(datePickerState.selectedDateMillis) + onDismiss() + } + ) { + Text("OK") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) { + DatePicker(state = datePickerState) + } +} diff --git a/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripScreen.kt b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripScreen.kt new file mode 100644 index 00000000..6c055890 --- /dev/null +++ b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripScreen.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.create_trip + +import android.annotation.SuppressLint +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.ExperimentalMediaQueryApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.derivedMediaQuery +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +/** + * Composable screen for creating a new trip or editing an existing trip. + * Manages form fields and coordinates lifecycle events with [CreateTripViewModel]. + */ +@SuppressLint("ContextCastToActivity") +@OptIn(ExperimentalMediaQueryApi::class) +@Composable +fun CreateTripScreen( + onBack: () -> Unit, + onTripCreated: (String) -> Unit, + tripIdToEdit: String? = null, + viewModel: CreateTripViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(tripIdToEdit) { + if (tripIdToEdit != null) { + viewModel.loadTripForEditing(tripIdToEdit) + } + } + + LaunchedEffect(uiState.isSuccess) { + if (uiState.isSuccess) { + onTripCreated(uiState.tripId) + } + } + + DisposableEffect(Unit) { onDispose { viewModel.resetState() } } + + BackHandler { onBack() } + + val tabletBreakpoint by derivedMediaQuery { windowWidth >= 1200.dp } + val horizontalPadding = if (tabletBreakpoint) 128.dp else 0.dp + + Scaffold( + modifier = + Modifier.fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + containerColor = MaterialTheme.colorScheme.surface, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + CreateTripPanelContent( + uiState = uiState, + viewModel = viewModel, + onCollapse = onBack, + modifier = Modifier.padding(horizontal = horizontalPadding), + ) + } + } +} diff --git a/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripUiState.kt b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripUiState.kt new file mode 100644 index 00000000..a94cc9bf --- /dev/null +++ b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripUiState.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.create_trip + +data class CreateTripUiState( + val title: String = "", + val location: String = "", + val startDate: String = "", + val endDate: String = "", + val participants: List = emptyList(), + val imageUri: String? = null, + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val tripId: String = "", + val isGenerating: Boolean = false, + val isEditing: Boolean = false, + val editingTripId: String? = null, +) diff --git a/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripViewModel.kt b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripViewModel.kt new file mode 100644 index 00000000..96e73ab9 --- /dev/null +++ b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripViewModel.kt @@ -0,0 +1,339 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.create_trip + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.ModelPreference +import com.google.mlkit.genai.prompt.ModelReleaseStage +import com.google.mlkit.genai.prompt.TextPart +import com.google.mlkit.genai.prompt.generateContentRequest +import com.google.mlkit.genai.prompt.generationConfig +import com.google.mlkit.genai.prompt.modelConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +@HiltViewModel +open class CreateTripViewModel +@Inject +constructor( + savedStateHandle: SavedStateHandle, + private val tripDao: TripDao, + @param:ApplicationContext private val context: Context? = null, +) : ViewModel() { + private val _uiState = MutableStateFlow(CreateTripUiState()) + open val uiState: StateFlow = _uiState.asStateFlow() + + init { + val tripId: String? = savedStateHandle["tripId"] + if (!tripId.isNullOrBlank()) { + loadTripForEditing(tripId) + } + } + + open fun loadTripForEditing(tripId: String) { + viewModelScope.launch { + tripDao.getTripById(tripId).collect { trip -> + if (trip != null) { + val dateFormatter = + DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.US) + val startStr = + Instant.ofEpochMilli(trip.startDate) + .atZone(ZoneOffset.UTC) + .toLocalDate() + .format(dateFormatter) + val endStr = + Instant.ofEpochMilli(trip.endDate) + .atZone(ZoneOffset.UTC) + .toLocalDate() + .format(dateFormatter) + _uiState.update { current -> + current.copy( + isEditing = true, + editingTripId = trip.id, + title = trip.title, + location = trip.location, + startDate = startStr, + endDate = endStr, + participants = trip.participants, + imageUri = trip.imageUri, + ) + } + } + } + } + } + + private val generativeModel by lazy { + val previewFastConfig = generationConfig { + modelConfig = modelConfig { + releaseStage = ModelReleaseStage.PREVIEW + preference = ModelPreference.FAST + } + } + Generation.getClient(previewFastConfig) + } + + /** + * Processes the voice input text, extracts trip details using the on-device ML model, + * and updates the corresponding fields in the UI state. + * + * @param text The transcribed text from the voice note. + */ + open fun processVoiceInput(text: String) { + if (text.isBlank()) return + _uiState.update { it.copy(isGenerating = true) } + viewModelScope.launch { + try { + val status = generativeModel.checkStatus() + if (status == FeatureStatus.AVAILABLE) { + val currentDateStr = + (FeatureFlags.OVERRIDE_CURRENT_TIME_MILLIS?.let { + Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()).toLocalDate() + } ?: LocalDate.now()) + .toString() + val prompt = getVoiceInputPrompt(text, currentDateStr) + + val request = generateContentRequest(TextPart(prompt)) {} + val response = generativeModel.generateContent(request) + val responseText = response.candidates.firstOrNull()?.text?.trim() ?: "" + + parseAndApplyVoiceInput(responseText) + } else { + Log.e("CreateTripViewModel", "ML Kit model not available (status $status)") + } + } catch (e: Exception) { + Log.e("CreateTripViewModel", "Error processing voice input", e) + } finally { + _uiState.update { it.copy(isGenerating = false) } + } + } + } + + private fun getVoiceInputPrompt(text: String, currentDateStr: String): String { + return """ + Given the voice note transcription: "$text" + Extract the following fields for a trip form: + - Title (inferred or direct) + - Location + - Start Date (YYYY-MM-DD) + - End Date (YYYY-MM-DD) + - Participants (comma separated list) + + Context: + - Today's Date: $currentDateStr + + Date Extraction Instructions: + - If the user uses vague date expressions like "later this year", "this summer", "in a few months", estimate a reasonable future date range within the current year (relative to $currentDateStr) rather than leaving them empty. + - If they specify a duration (e.g., "a week", "two weeks"), ensure the interval between the estimated Start Date and End Date matches that duration (e.g., exactly 7 days for a week). + + Your response must be a valid JSON object strictly matching this structure: + { + "title": "...", + "location": "...", + "startDate": "...", + "endDate": "...", + "participants": ["...", "..."] + } + If a field cannot be extracted or is unknown, return an empty string "" for that field instead of null. + Respond strictly in valid JSON. + """.trimIndent() + } + + private fun parseAndApplyVoiceInput(responseText: String) { + try { + val json = JSONObject(responseText.removeSurrounding("```json", "```").trim()) + + val title = if (json.isNull("title")) "" else json.optString("title", "") + val location = if (json.isNull("location")) "" else json.optString("location", "") + val startDate = if (json.isNull("startDate")) "" else json.optString("startDate", "") + val endDate = if (json.isNull("endDate")) "" else json.optString("endDate", "") + val participants = json.optJSONArray("participants") + + if (title.isNotEmpty() && title != "null") onTitleChange(title) + if (location.isNotEmpty() && location != "null") onLocationChange(location) + + val formatterIn = SimpleDateFormat("yyyy-MM-dd", Locale.US) + val formatterOut = SimpleDateFormat("MMM dd, yyyy", Locale.US) + val formatStr: (String) -> String = { str -> + try { + formatterOut.format(formatterIn.parse(str)!!) + } catch (e: Exception) { + str + } + } + + if (startDate.isNotEmpty() && startDate != "null") onStartDateChange(formatStr(startDate)) + if (endDate.isNotEmpty() && endDate != "null") onEndDateChange(formatStr(endDate)) + + if (participants != null) { + for (i in 0 until participants.length()) { + onAddParticipant(participants.getString(i)) + } + } + } catch (e: Exception) { + Log.e("CreateTripViewModel", "Failed to parse or apply voice input JSON", e) + } + } + + open fun onTitleChange(newTitle: String) { + _uiState.update { it.copy(title = newTitle) } + } + + open fun onLocationChange(newLocation: String) { + _uiState.update { it.copy(location = newLocation) } + } + + open fun onStartDateChange(newDate: String) { + _uiState.update { it.copy(startDate = newDate) } + } + + open fun onEndDateChange(newDate: String) { + _uiState.update { it.copy(endDate = newDate) } + } + + open fun onAddParticipant(name: String) { + if (name.isBlank()) return + _uiState.update { current -> + val currentParticipants = current.participants.toMutableList() + currentParticipants.add(name) + current.copy(participants = currentParticipants) + } + } + + open fun onRemoveParticipant(name: String) { + _uiState.update { current -> + val currentParticipants = current.participants.toMutableList() + currentParticipants.remove(name) + current.copy(participants = currentParticipants) + } + } + + open fun onImageUriChange(newUri: String?) { + _uiState.update { it.copy(imageUri = newUri) } + } + + open fun createTrip() { + if ( + _uiState.value.title.isBlank() || + _uiState.value.startDate.isBlank() || + _uiState.value.endDate.isBlank() + ) + return + + _uiState.update { it.copy(isLoading = true) } + viewModelScope.launch { + val parseDate: (String) -> Long = { dateStr -> + var parsedTime = 0L + for (pattern in listOf("MMM dd, yyyy", "yyyy-MM-dd")) { + try { + val d = SimpleDateFormat(pattern, Locale.US).parse(dateStr) + if (d != null) { + parsedTime = d.time + break + } + } catch (e: Exception) { + // continue to next pattern + } + } + parsedTime + } + _uiState.update { current -> + val startTs = parseDate(current.startDate) + val endTs = parseDate(current.endDate) + + val newTrip = + Trip( + id = current.editingTripId ?: UUID.randomUUID().toString(), + title = current.title, + location = current.location, + startDate = startTs, + endDate = endTs, + participants = current.participants, + imageUri = current.imageUri, + ) + tripDao.insertTrip(newTrip) + current.copy( + isLoading = false, + isSuccess = true, + tripId = newTrip.id, + title = "", + location = "", + startDate = "", + endDate = "", + participants = emptyList(), + imageUri = null, + ) + } + } + } + + open fun generateAiImage() { + Log.d("CreateTripViewModel", "Cloud AI image generation is disabled in basic release.") + } + + open fun resetState() { + _uiState.update { CreateTripUiState() } + } + + private suspend fun saveBitmapToCache(bitmap: Bitmap): String? = + withContext(Dispatchers.IO) { + val ctx = context ?: return@withContext null + try { + val cacheFile = + File(ctx.cacheDir, "generated_trip_${UUID.randomUUID()}.jpg") + FileOutputStream(cacheFile).use { stream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream) + stream.flush() + } + Uri.fromFile(cacheFile).toString() + } catch (e: Exception) { + Log.e("CreateTripViewModel", "Failed to save bitmap to cache", e) + null + } + } +} diff --git a/jetpacker/android/feature/create_trip/src/screenshotTest/kotlin/com/example/jetpacker/feature/create_trip/CreateTripScreenshotTest.kt b/jetpacker/android/feature/create_trip/src/screenshotTest/kotlin/com/example/jetpacker/feature/create_trip/CreateTripScreenshotTest.kt new file mode 100644 index 00000000..a62cb5ed --- /dev/null +++ b/jetpacker/android/feature/create_trip/src/screenshotTest/kotlin/com/example/jetpacker/feature/create_trip/CreateTripScreenshotTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.create_trip + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.SavedStateHandle +import com.android.tools.screenshot.PreviewTest +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.data.trips.DummyData +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf + +class CreateTripScreenshotTest { + @PreviewTest + @Preview(showBackground = true) + @Composable + fun CreateTripScreenPreview() { + val fakeTripDao = + object : TripDao { + override fun getAllTrips() = flowOf(emptyList()) + override fun getTripById(tripId: String) = flowOf(null) + override suspend fun insertTrip(trip: Trip) {} + override suspend fun insertTrips(trips: List) {} + override suspend fun deleteTrip(trip: Trip) {} + override suspend fun deleteAllTrips() {} + } + + val fakeViewModel = + object : CreateTripViewModel(SavedStateHandle(), fakeTripDao) { + private val dummyTrip = DummyData.trips.first() + private val state = + MutableStateFlow( + CreateTripUiState( + title = dummyTrip.title, + location = dummyTrip.location, + startDate = "May 15, 2026", + endDate = "May 22, 2026", + imageUri = null, + ) + ) + override val uiState: StateFlow = state + + override fun onTitleChange(title: String) {} + + override fun onLocationChange(location: String) {} + + override fun onStartDateChange(date: String) {} + + override fun onEndDateChange(date: String) {} + + override fun onImageUriChange(uri: String?) {} + + override fun createTrip() {} + } + + val uiState by fakeViewModel.uiState.collectAsState() + JetPackerTheme { + CreateTripPanelContent(uiState = uiState, viewModel = fakeViewModel, onCollapse = {}) + } + } +} diff --git a/jetpacker/android/feature/create_trip/src/screenshotTestDebug/reference/com/example/jetpacker/feature/create_trip/CreateTripScreenshotTest/CreateTripScreenPreview_748aa731_0.png b/jetpacker/android/feature/create_trip/src/screenshotTestDebug/reference/com/example/jetpacker/feature/create_trip/CreateTripScreenshotTest/CreateTripScreenPreview_748aa731_0.png new file mode 100644 index 00000000..e3960af4 Binary files /dev/null and b/jetpacker/android/feature/create_trip/src/screenshotTestDebug/reference/com/example/jetpacker/feature/create_trip/CreateTripScreenshotTest/CreateTripScreenPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/create_trip/src/test/AndroidManifest.xml b/jetpacker/android/feature/create_trip/src/test/AndroidManifest.xml new file mode 100644 index 00000000..3fbaac06 --- /dev/null +++ b/jetpacker/android/feature/create_trip/src/test/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + diff --git a/jetpacker/android/feature/create_trip/src/test/kotlin/com/example/jetpacker/feature/create_trip/CreateTripScreenTest.kt b/jetpacker/android/feature/create_trip/src/test/kotlin/com/example/jetpacker/feature/create_trip/CreateTripScreenTest.kt new file mode 100644 index 00000000..270f50c3 --- /dev/null +++ b/jetpacker/android/feature/create_trip/src/test/kotlin/com/example/jetpacker/feature/create_trip/CreateTripScreenTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.create_trip + +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.lifecycle.SavedStateHandle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = android.app.Application::class, qualifiers = "+w411dp-h891dp-mdpi", sdk = [33]) +class CreateTripScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun testCreateTripScreen_showsImage() { + composeTestRule.mainClock.autoAdvance = false + + val fakeDao = + object : TripDao { + override fun getAllTrips(): Flow> = emptyFlow() + + override fun getTripById(tripId: String): Flow = emptyFlow() + + override suspend fun insertTrip(trip: Trip) {} + + override suspend fun insertTrips(trips: List) {} + + override suspend fun deleteTrip(trip: Trip) {} + + override suspend fun deleteAllTrips() {} + } + + // Stub class since we just want to display + val fakeViewModel = + object : CreateTripViewModel(SavedStateHandle(), fakeDao) { + private val state = + MutableStateFlow( + CreateTripUiState( + title = "Tokyo Explorer", + location = "Tokyo, Japan", + startDate = "Oct 15, 2026", + endDate = "Oct 30, 2026", + imageUri = "content://fake/uri/image.png", + ) + ) + override val uiState: StateFlow = state + + override fun onImageUriChange(uri: String?) {} + + override fun createTrip() {} + } + + composeTestRule.setContent { + val uiState by fakeViewModel.uiState.collectAsState() + JetPackerTheme { + CreateTripPanelContent(uiState = uiState, viewModel = fakeViewModel, onCollapse = {}) + } + } + } +} diff --git a/jetpacker/android/feature/detail/build.gradle.kts b/jetpacker/android/feature/detail/build.gradle.kts new file mode 100644 index 00000000..22878288 --- /dev/null +++ b/jetpacker/android/feature/detail/build.gradle.kts @@ -0,0 +1,65 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.detail" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { compose = true } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + + implementation(project(":core:flags")) + implementation(project(":core:ui")) + implementation(project(":data:itinerary")) + implementation(project(":data:trips")) + + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + + debugImplementation(libs.androidx.compose.ui.tooling) + + screenshotTestImplementation(project(":data:trips")) + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/detail/hotel_chat/build.gradle.kts b/jetpacker/android/feature/detail/hotel_chat/build.gradle.kts new file mode 100644 index 00000000..fee9142e --- /dev/null +++ b/jetpacker/android/feature/detail/hotel_chat/build.gradle.kts @@ -0,0 +1,75 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.detail.hotel_chat" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(project(":core:ui")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.mlkit.genai.prompt) + implementation(libs.mlkit.language.id) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.ai) + implementation(libs.firebase.ai.ondevice) + implementation(libs.firebase.firestore) + + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.robolectric) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/detail/hotel_chat/src/main/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChat.kt b/jetpacker/android/feature/detail/hotel_chat/src/main/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChat.kt new file mode 100644 index 00000000..6eb061a1 --- /dev/null +++ b/jetpacker/android/feature/detail/hotel_chat/src/main/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChat.kt @@ -0,0 +1,365 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail.hotel_chat + +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.Surface +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import com.example.jetpacker.feature.detail.hotel_chat.HotelSupportChatViewModel.SupportChatMessage +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HotelSupportChat( + onBack: () -> Unit = {}, + hotelName: String = "Hotel", + language: String = "English", + viewModel: HotelSupportChatViewModel = viewModel { HotelSupportChatViewModel(hotelName, language) }, +) { + val messages by viewModel.messages.collectAsStateWithLifecycle() + val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() + val translations by viewModel.translations.collectAsStateWithLifecycle() + val selectedLanguage by viewModel.selectedLanguage.collectAsStateWithLifecycle() + + HotelSupportChatContent( + hotelName = hotelName, + messages = messages, + currentUserId = currentUserId, + translations = translations, + selectedLanguage = selectedLanguage, + onSendMessage = { viewModel.sendMessage(it) }, + onTranslateMessage = { viewModel.translateMessage(it) }, + onSelectLanguage = { viewModel.setSelectedLanguage(it) }, + onBack = onBack + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HotelSupportChatContent( + hotelName: String, + messages: List, + currentUserId: String, + translations: Map, + selectedLanguage: String, + onSendMessage: (String) -> Unit, + onTranslateMessage: (SupportChatMessage) -> Unit, + onSelectLanguage: (String) -> Unit, + onBack: () -> Unit = {} +) { + var inputText by remember { mutableStateOf("") } + var showMenu by remember { mutableStateOf(false) } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + title = { + Text( + text = if (currentUserId == "UserA") "Customer support" else hotelName, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = TextPrimary, + ) + }, + actions = { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "More options") + } + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + val languages = + listOf( + "English", + "German", + "Dutch", + "Spanish", + "French", + "한국어", + "中文", + "日本語", + "українська", + ) + languages.forEach { lang -> + DropdownMenuItem( + text = { + Text( + text = lang, + fontWeight = + if (lang == selectedLanguage) FontWeight.Bold else FontWeight.Normal, + ) + }, + onClick = { + onSelectLanguage(lang) + showMenu = false + }, + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors(), + ) + }, + bottomBar = { + Column { + InputBar( + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + text = inputText, + onTextChange = { inputText = it }, + onSend = { + onSendMessage(inputText) + inputText = "" + }, + ) + } + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(paddingValues).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { Spacer(modifier = Modifier.height(8.dp)) } + + itemsIndexed(messages) { index, msg: SupportChatMessage -> + val isLatest = index == messages.size - 1 + val translation = translations[msg.id] + FirestoreMessageRow( + msg, + currentUserId, + isLatest, + translation, + selectedLanguage, + onTranslate = { onTranslateMessage(msg) }, + ) + } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } + } +} + +@Composable +fun FirestoreMessageRow( + message: SupportChatMessage, + currentUserId: String, + isLatest: Boolean, + translation: String?, + selectedLanguage: String, + onTranslate: () -> Unit, +) { + val isCurrentUser = message.senderId == currentUserId + Column(modifier = Modifier.fillMaxWidth()) { + if (isCurrentUser) { + UserMessageRowFixed(message) + } else { + BotMessageRowFixed(message, translation) + } + + if (translation == null && isLatest && !isCurrentUser) { + Text( + text = "Translate to $selectedLanguage", + style = MaterialTheme.typography.labelLarge, + color = PrimaryBlue, + modifier = Modifier.padding(start = 40.dp).clickable { onTranslate() }.padding(top = 4.dp), + ) + } + } +} + +@Composable +fun UserMessageRowFixed(message: SupportChatMessage) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End) { + Text( + text = message.senderName, + style = MaterialTheme.typography.labelSmall, + color = TextSecondary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.End) { + Box( + modifier = + Modifier.weight(1f, fill = false) + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp)) + .background(UserBubble) + .padding(16.dp) + ) { + Text(text = message.text, color = Color.White, style = MaterialTheme.typography.bodyMedium) + } + Spacer(modifier = Modifier.width(8.dp)) + Icon( + Icons.Filled.AccountCircle, + contentDescription = "User", + tint = TextSecondary, + modifier = Modifier.size(32.dp), + ) + } + } +} + +@Composable +fun BotMessageRowFixed( + message: SupportChatMessage, + translation: String? = null, +) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { + Icon( + Icons.Filled.Face, + contentDescription = "Other", + tint = TextSecondary, + modifier = + Modifier.size(32.dp).clip(CircleShape).background(Color(0xFFE2E8F0)).padding(4.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = message.senderName, + style = MaterialTheme.typography.labelSmall, + color = TextSecondary, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.Start) { + Spacer(modifier = Modifier.width(40.dp)) + Box( + modifier = + Modifier.clip(RoundedCornerShape(topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp)) + .background(BotBubble) + .padding(16.dp) + ) { + Column { + Text( + text = message.text, + color = TextPrimary, + style = MaterialTheme.typography.bodyMedium, + ) + if (translation != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = translation, + color = PrimaryBlue, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + } +} + +val PrimaryBlue = Color(0xFF0F172A) +val UserBubble = Color(0xFF0F172A) +val BotBubble = Color(0xFFF1F5F9) +val TextPrimary = Color(0xFF1E293B) +val TextSecondary = Color(0xFF64748B) + +@Composable +fun InputBar( + text: String, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + modifier: Modifier = Modifier +) { + Surface(modifier = Modifier.fillMaxWidth().then(modifier).navigationBarsPadding()) { + Row( + modifier = + Modifier.padding(16.dp) + .clip(RoundedCornerShape(32.dp)) + .background(Color(0xFFF1F5F9)) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = text, + onValueChange = onTextChange, + placeholder = { Text("Type a message...", color = TextSecondary) }, + modifier = Modifier.weight(1f), + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedTextColor = TextPrimary, + ), + ) + + if (text.isNotBlank()) { + IconButton(onClick = onSend) { + Icon(Icons.Filled.Send, contentDescription = "Send", tint = PrimaryBlue) + } + } + } + } +} + diff --git a/jetpacker/android/feature/detail/hotel_chat/src/main/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChatViewModel.kt b/jetpacker/android/feature/detail/hotel_chat/src/main/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChatViewModel.kt new file mode 100644 index 00000000..44740714 --- /dev/null +++ b/jetpacker/android/feature/detail/hotel_chat/src/main/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChatViewModel.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail.hotel_chat + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.Firebase +import com.google.firebase.Timestamp +import com.google.firebase.ai.InferenceMode +import com.google.firebase.ai.OnDeviceConfig +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.content +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.CancellationException +import com.google.android.gms.tasks.Tasks +import com.google.mlkit.nl.languageid.LanguageIdentification + + +class HotelSupportChatViewModel(val hotelName: String = "Hotel", val language: String = "English") : + ViewModel() { + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _currentUserId = MutableStateFlow("User") + val currentUserId: StateFlow = _currentUserId.asStateFlow() + + private val _translations = MutableStateFlow>(emptyMap()) + val translations: StateFlow> = _translations.asStateFlow() + + private val _selectedLanguage = MutableStateFlow("English") + val selectedLanguage: StateFlow = _selectedLanguage.asStateFlow() + + fun setSelectedLanguage(language: String) { + _selectedLanguage.value = language + } + + // ML Kit for Language Identification + private val languageIdentifier = LanguageIdentification.getClient() + + // ML Kit / Gemini Nano for on-device translation + @OptIn(PublicPreviewAPI::class) + private val hybridTranslationModel = + Firebase.ai(backend = GenerativeBackend.googleAI()) + .generativeModel( + modelName = "gemini-3-flash-preview", + onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_ON_DEVICE), + ) + + // Cloud translator model (TranslateGemma fallback) + @OptIn(PublicPreviewAPI::class) + private val cloudTranslationModel = + Firebase.ai(backend = GenerativeBackend.googleAI()) + .generativeModel( + modelName = "gemini-3-flash-preview", // Represents cloud TranslateGemma + ) + + // Firebase AI for chat + private val generativeModel = + Firebase.ai(backend = GenerativeBackend.googleAI()) + .generativeModel( + systemInstruction = + content { + text( + "You are a helpful hotel receptionist at $hotelName only speaking $language. Answer politely in $language. The bar closes at 10pm and breakfast is from 7am to 10am. There's someone at the desk 24/7. You can retrieve your luggage from the storage room at the back of the lobby at any time." + ) + }, + modelName = "gemini-3-flash-preview", + ) + + private val chat = generativeModel.startChat() + + fun translateMessage(message: SupportChatMessage) { + if (message.id.isEmpty()) return + viewModelScope.launch { + try { + _translations.update { current -> current + (message.id to "\n\nTranslating...") } + val lang = _selectedLanguage.value + + // 1. Detect language using ML Kit Language Identification + val sourceLang = try { + Tasks.await(languageIdentifier.identifyLanguage(message.text)) + } catch (e: Exception) { + "und" + } + + // 2. Custom routing: + // We route to TranslateGemma in the Cloud for specific languages (e.g. French 'fr'), + // and run on-device Gemini Nano for others (e.g. English, Dutch, or unrecognized). + val routeToCloud = sourceLang == "fr" + + val prompt = + "Translate the following text to $lang. Just return the translated sentence: ${message.text}." + + val (translatedText, routePrefix) = if (routeToCloud) { + val result = cloudTranslationModel.generateContent(prompt) + result.text to "[Cloud / TranslateGemma]" + } else { + val result = hybridTranslationModel.generateContent(prompt) + result.text to "[On-Device / Gemini Nano]" + } + + if (translatedText != null) { + _translations.update { current -> + current + (message.id to "\n\n$routePrefix: $translatedText") + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("HotelSupportChatViewModel", "Custom Hybrid Translation Error", e) + _translations.update { current -> + current + (message.id to "\n\nError: ${e.message}") + } + } + } + } + + fun setUserId(userId: String) { + // Do nothing + } + + fun sendMessage(text: String) { + if (text.isBlank()) return + val botMsgId = UUID.randomUUID().toString() + _messages.update { current -> + val userMessage = + SupportChatMessage( + id = UUID.randomUUID().toString(), + text = text, + senderId = "User", + senderName = "You", + timestamp = Timestamp.now(), + ) + val botMessage = + SupportChatMessage( + id = botMsgId, + text = "Typing...", + senderId = "AI", + senderName = "Hotel Support", + timestamp = Timestamp.now(), + ) + current + userMessage + botMessage + } + + viewModelScope.launch { + try { + val response = chat.sendMessage(text) + _messages.update { current -> + current.map { msg -> + if (msg.id == botMsgId) { + msg.copy(text = response.text?.trim() ?: "No response") + } else { + msg + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("HotelSupportChatViewModel", "Chat Error", e) + _messages.update { current -> + current.map { msg -> + if (msg.id == botMsgId) { + msg.copy(text = "Error: ${e.message}") + } else { + msg + } + } + } + } + } + } + + data class SupportChatMessage( + val id: String = "", + val text: String = "", + val senderId: String = "", + val senderName: String = "", + val timestamp: Timestamp? = null, + ) +} diff --git a/jetpacker/android/feature/detail/hotel_chat/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChatScreenshotTest.kt b/jetpacker/android/feature/detail/hotel_chat/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChatScreenshotTest.kt new file mode 100644 index 00000000..6f98a1da --- /dev/null +++ b/jetpacker/android/feature/detail/hotel_chat/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChatScreenshotTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail.hotel_chat + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.feature.detail.hotel_chat.HotelSupportChatViewModel.SupportChatMessage + +class HotelSupportChatScreenshotTest { + + @PreviewTest + @Preview(showBackground = true) + @Composable + fun HotelSupportChatScreenshotPreview() { + val messages = listOf( + SupportChatMessage(id = "1", text = "Hi, when does breakfast start?", senderId = "User", senderName = "You"), + SupportChatMessage(id = "2", text = "Breakfast is served from 7am to 10am at the main restaurant.", senderId = "AI", senderName = "Hotel Support") + ) + val translations = mapOf( + "2" to "Frühstück wird von 7 bis 10 Uhr im Hauptrestaurant serviert." + ) + JetPackerTheme { + HotelSupportChatContent( + hotelName = "JetPacker Resort", + messages = messages, + currentUserId = "User", + translations = translations, + selectedLanguage = "German", + onSendMessage = {}, + onTranslateMessage = {}, + onSelectLanguage = {}, + onBack = {} + ) + } + } +} diff --git a/jetpacker/android/feature/detail/museum_assistant/build.gradle.kts b/jetpacker/android/feature/detail/museum_assistant/build.gradle.kts new file mode 100644 index 00000000..ce83a156 --- /dev/null +++ b/jetpacker/android/feature/detail/museum_assistant/build.gradle.kts @@ -0,0 +1,74 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.detail.museum_assistant" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(project(":core:ui")) + implementation(project(":core:flags")) + implementation(project(":data:itinerary")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.mlkit.genai.prompt) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.ai) + + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.robolectric) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/detail/museum_assistant/src/main/kotlin/com/example/jetpacker/feature/detail/museum_assistant/ChatViewModel.kt b/jetpacker/android/feature/detail/museum_assistant/src/main/kotlin/com/example/jetpacker/feature/detail/museum_assistant/ChatViewModel.kt new file mode 100644 index 00000000..ef187f3a --- /dev/null +++ b/jetpacker/android/feature/detail/museum_assistant/src/main/kotlin/com/example/jetpacker/feature/detail/museum_assistant/ChatViewModel.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail.museum_assistant + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.MuseumDetail +import com.google.firebase.Firebase +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.Tool +import com.google.firebase.ai.type.content +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.CancellationException + +@HiltViewModel +class ChatViewModel +@Inject +constructor(savedStateHandle: SavedStateHandle, private val eventDao: EventDao) : ViewModel() { + + private val urlList = mutableListOf() + + private var toolList = mutableListOf() + + init { + savedStateHandle.get("eventId")?.let { loadDetail(it) } + if (FeatureFlags.ENABLE_SEARCH_GROUNDING) { + toolList.add(Tool.googleSearch()) + } + if (FeatureFlags.ENABLE_URL_GROUNDING) { + toolList.add(Tool.urlContext()) + } + } + + private fun loadDetail(eventId: String) { + viewModelScope.launch { + eventDao.getMuseumDetail(eventId).collectLatest { museumDetail -> + museumDetail?.let { m -> urlList.addAll(m.infoUrls) } + } + } + } + + private val generativeModel = + Firebase.ai(backend = GenerativeBackend.googleAI()) + .generativeModel( + systemInstruction = + content { + text( + "You are a helpful museum assistant, answering questions about a museum. Never use markdown, use plain text." + ) + }, + modelName = "gemini-3.1-flash-lite", + tools = toolList, + ) + + private val chat = generativeModel.startChat() + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + fun sendMessage(text: String) { + if (text.isBlank()) return + + val botMsgId = _messages.value.size + 2 + _messages.update { current -> + val userMsg = + ChatMessage(id = current.size + 1, text = text, isUser = true, sender = "You") + val botMsg = + ChatMessage(id = current.size + 2, text = "Thinking...", isUser = false, sender = "Museum Assistant") + current + userMsg + botMsg + } + + viewModelScope.launch { + try { + val prompt = + "$text ${if (FeatureFlags.ENABLE_URL_GROUNDING) "\n\n If the following is message above about the rules and terms to visit Le Louvre, if needed answer this urls ${urlList.joinToString()}" else ""}" + var response = + chat.sendMessage( + prompt + ) + _messages.update { current -> + current.map { msg -> + if (msg.id == botMsgId) { + msg.copy(text = response.text?.trim() ?: "") + } else { + msg + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + _messages.update { current -> + current.map { msg -> + if (msg.id == botMsgId) { + msg.copy(text = "Error: ${e.message}") + } else { + msg + } + } + } + } + } + } +} diff --git a/jetpacker/android/feature/detail/museum_assistant/src/main/kotlin/com/example/jetpacker/feature/detail/museum_assistant/ChatbotScreen.kt b/jetpacker/android/feature/detail/museum_assistant/src/main/kotlin/com/example/jetpacker/feature/detail/museum_assistant/ChatbotScreen.kt new file mode 100644 index 00000000..043cb81e --- /dev/null +++ b/jetpacker/android/feature/detail/museum_assistant/src/main/kotlin/com/example/jetpacker/feature/detail/museum_assistant/ChatbotScreen.kt @@ -0,0 +1,296 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail.museum_assistant + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +val PrimaryBlue = Color(0xFF0F172A) +val UserBubble = Color(0xFF0F172A) +val BotBubble = Color(0xFFF1F5F9) +val TextPrimary = Color(0xFF1E293B) +val TextSecondary = Color(0xFF64748B) + +data class ChatMessage(val id: Int, val text: String, val isUser: Boolean, val sender: String) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatbotScreen( + eventId: String?, + viewModel: ChatViewModel = hiltViewModel(), + onBack: (() -> Unit)? = null +) { + val messages by viewModel.messages.collectAsStateWithLifecycle() + ChatbotContent( + messages = messages, + onSendMessage = { viewModel.sendMessage(it) }, + onBack = onBack + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatbotContent( + messages: List, + onSendMessage: (String) -> Unit, + onBack: (() -> Unit)? = null +) { + var inputText by remember { mutableStateOf("") } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + }, + title = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Museum Assistant", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) + }, + bottomBar = { + Column { + InputBar( + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + text = inputText, + onTextChange = { inputText = it }, + onSend = { + onSendMessage(inputText) + inputText = "" + }, + ) + } + }, + ) { paddingValues -> + LazyColumn( + modifier = + Modifier.fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { Spacer(modifier = Modifier.height(8.dp)) } + + items(messages) { msg -> MessageRow(msg) } + + item { Spacer(modifier = Modifier.height(16.dp)) } + } + } +} + +@Composable +fun MessageRow(message: ChatMessage) { + if (message.isUser) { + UserMessageRow(message) + } else { + BotMessageRow(message) + } +} + +@Composable +fun UserMessageRow(message: ChatMessage) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) { + Text(text = message.sender, style = MaterialTheme.typography.labelSmall) + } + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.End) { + Box( + modifier = + Modifier.weight(1f, fill = false) + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp)) + .background(UserBubble) + .padding(16.dp) + ) { + Text(text = message.text, color = Color.White, style = MaterialTheme.typography.bodyMedium) + } + Spacer(modifier = Modifier.width(8.dp)) + Icon(Icons.Filled.AccountCircle, contentDescription = "User", modifier = Modifier.size(32.dp)) + } + } +} + +@Composable +fun BotMessageRow(message: ChatMessage) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { + Icon( + Icons.Filled.Face, + contentDescription = "Bot", + modifier = + Modifier.size(32.dp) + .clip(CircleShape) + .padding(4.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = message.sender, style = MaterialTheme.typography.labelSmall) + } + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.Start) { + Spacer(modifier = Modifier.width(40.dp)) + Column { + Box( + modifier = + Modifier.clip( + RoundedCornerShape(topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp) + ) + .background(BotBubble) + .padding(16.dp) + ) { + Text(text = message.text, style = MaterialTheme.typography.bodyMedium, color = TextPrimary) + } + } + } + } +} + +@Composable +fun InputBar( + text: String, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + modifier: Modifier = Modifier +) { + Surface(modifier = Modifier.fillMaxWidth().then(modifier).navigationBarsPadding()) { + Row( + modifier = + Modifier.padding(16.dp) + .clip(RoundedCornerShape(32.dp)) + .background(Color(0xFFF1F5F9)) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = text, + onValueChange = onTextChange, + placeholder = { Text("Type a message...", color = TextSecondary) }, + modifier = Modifier.weight(1f), + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedTextColor = TextPrimary, + ), + ) + + if (text.isNotBlank()) { + IconButton(onClick = onSend) { + Icon(Icons.Filled.Send, contentDescription = "Send", tint = PrimaryBlue) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun UserMessageRowPreview() { + MaterialTheme { + MessageRow( + message = + ChatMessage( + id = 1, + text = "Hello! Where is the Louvre located?", + isUser = true, + sender = "Sarah J. Chen", + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun BotMessageRowPreview() { + MaterialTheme { + MessageRow( + message = + ChatMessage( + id = 2, + text = "The Louvre is located in the center of Paris, on the right bank of the Seine.", + isUser = false, + sender = "Museum Assistant", + ) + ) + } +} diff --git a/jetpacker/android/feature/detail/museum_assistant/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/museum_assistant/MuseumAssistantScreenshotTest.kt b/jetpacker/android/feature/detail/museum_assistant/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/museum_assistant/MuseumAssistantScreenshotTest.kt new file mode 100644 index 00000000..6f5fe859 --- /dev/null +++ b/jetpacker/android/feature/detail/museum_assistant/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/museum_assistant/MuseumAssistantScreenshotTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail.museum_assistant + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.example.jetpacker.core.ui.JetPackerTheme + +class MuseumAssistantScreenshotTest { + + @PreviewTest + @Preview(showBackground = true) + @Composable + fun ChatbotScreenScreenshotPreview() { + val messages = listOf( + ChatMessage(id = 1, text = "Hello! Can I ask about Louvre opening hours?", isUser = true, sender = "You"), + ChatMessage(id = 2, text = "Yes, of course! The Louvre is open from 9 AM to 6 PM every day except Tuesday.", isUser = false, sender = "Museum Assistant"), + ChatMessage(id = 3, text = "Are there any cafes inside?", isUser = true, sender = "You"), + ChatMessage(id = 4, text = "Thinking...", isUser = false, sender = "Museum Assistant") + ) + JetPackerTheme { + ChatbotContent( + messages = messages, + onSendMessage = {}, + onBack = {} + ) + } + } +} diff --git a/jetpacker/android/feature/detail/review/build.gradle.kts b/jetpacker/android/feature/detail/review/build.gradle.kts new file mode 100644 index 00000000..af51b52a --- /dev/null +++ b/jetpacker/android/feature/detail/review/build.gradle.kts @@ -0,0 +1,73 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.detail.review" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(project(":core:ui")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.mlkit.genai.prompt) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.ai) + implementation(libs.firebase.ai.ondevice) + + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.robolectric) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/detail/review/src/main/kotlin/com/example/jetpacker/feature/detail/review/ReviewScreen.kt b/jetpacker/android/feature/detail/review/src/main/kotlin/com/example/jetpacker/feature/detail/review/ReviewScreen.kt new file mode 100644 index 00000000..2dd67acd --- /dev/null +++ b/jetpacker/android/feature/detail/review/src/main/kotlin/com/example/jetpacker/feature/detail/review/ReviewScreen.kt @@ -0,0 +1,319 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail.review + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.ThumbDown +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.jetpacker.core.ui.EventColors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReviewScreen( + placeName: String, + placeId: String, + onBack: () -> Unit = {}, + viewModel: ReviewScreenViewModel = hiltViewModel() +) { + val selectedTopics by viewModel.selectedTopics.collectAsStateWithLifecycle() + val isGenerating by viewModel.isGenerating.collectAsStateWithLifecycle() + val generatedReviewText by viewModel.generatedReviewText.collectAsStateWithLifecycle() + + ReviewScreenContent( + placeName = placeName, + topics = viewModel.topics, + selectedTopics = selectedTopics, + isGenerating = isGenerating, + generatedReviewText = generatedReviewText, + onTopicSelected = { topic, positive -> viewModel.toggleTopic(topic, positive) }, + onGenerateReview = { viewModel.generateReview(placeName) }, + onGeneratedReviewTextChange = { viewModel.onGeneratedReviewTextChange(it) }, + onPostReview = { context -> viewModel.addReview(placeId, context) }, + onBack = onBack + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReviewScreenContent( + placeName: String, + topics: List, + selectedTopics: Set, + isGenerating: Boolean, + generatedReviewText: String, + onTopicSelected: (String, Boolean) -> Unit, + onGenerateReview: () -> Unit, + onGeneratedReviewTextChange: (String) -> Unit, + onPostReview: (android.content.Context) -> Unit, + onBack: () -> Unit = {} +) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + CenterAlignedTopAppBar( + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "REVIEW", + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + placeName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + contentPadding = innerPadding, + ) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.elevatedCardColors(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = "Review Topics", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Select topics to include in your AI generated review:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + + TopicSelection( + topics = topics, + selectedTopics = selectedTopics, + onTopicSelected = onTopicSelected, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onGenerateReview, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = MaterialTheme.shapes.medium, + enabled = selectedTopics.isNotEmpty() && !isGenerating, + ) { + if (isGenerating) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon(Icons.Filled.AutoAwesome, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Generate Review") + } + } + + AnimatedVisibility(visible = generatedReviewText.isNotBlank() || isGenerating) { + Column { + Spacer(modifier = Modifier.height(24.dp)) + OutlinedTextField( + value = generatedReviewText, + onValueChange = onGeneratedReviewTextChange, + modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)), + placeholder = { Text("AI generated review...") }, + shape = MaterialTheme.shapes.medium, + ) + Spacer(modifier = Modifier.height(16.dp)) + + val context = LocalContext.current + Button( + onClick = { onPostReview(context) }, + modifier = Modifier.align(Alignment.End), + shape = MaterialTheme.shapes.medium, + ) { + Text("Post Review") + } + } + } + } + } + Spacer(modifier = Modifier.height(42.dp)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopicSelection( + topics: List, + selectedTopics: Set, + onTopicSelected: (String, Boolean) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { + topics.forEach { topic -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = topic, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val isPositiveSelected = selectedTopics.any { it.name == topic && it.positiveOpinion } + val isNegativeSelected = selectedTopics.any { it.name == topic && !it.positiveOpinion } + + val negativeColors = EventColors.Shopping + val positiveColors = EventColors.Food + + FilterChip( + selected = isNegativeSelected, + onClick = { onTopicSelected(topic, false) }, + label = { + Icon( + Icons.Filled.ThumbDown, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + }, + shape = CircleShape, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = negativeColors.container, + selectedLabelColor = negativeColors.content, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + containerColor = Color.Transparent, + ), + border = + FilterChipDefaults.filterChipBorder( + enabled = true, + selected = isNegativeSelected, + borderColor = MaterialTheme.colorScheme.outlineVariant, + selectedBorderColor = negativeColors.content, + borderWidth = 1.dp, + ), + ) + + FilterChip( + selected = isPositiveSelected, + onClick = { onTopicSelected(topic, true) }, + label = { + Icon(Icons.Filled.ThumbUp, contentDescription = null, modifier = Modifier.size(18.dp)) + }, + shape = CircleShape, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = positiveColors.container, + selectedLabelColor = positiveColors.content, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + containerColor = Color.Transparent, + ), + border = + FilterChipDefaults.filterChipBorder( + enabled = true, + selected = isPositiveSelected, + borderColor = MaterialTheme.colorScheme.outlineVariant, + selectedBorderColor = positiveColors.content, + borderWidth = 1.dp, + ), + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun TopicSelectionPreview() { + MaterialTheme { + TopicSelection( + topics = listOf("Crowds", "Exhibits", "Guides", "Audio"), + selectedTopics = + setOf( + Topic(name = "Crowds", positiveOpinion = false), + Topic(name = "Exhibits", positiveOpinion = true), + ), + onTopicSelected = { _, _ -> }, + ) + } +} diff --git a/jetpacker/android/feature/detail/review/src/main/kotlin/com/example/jetpacker/feature/detail/review/ReviewScreenViewModel.kt b/jetpacker/android/feature/detail/review/src/main/kotlin/com/example/jetpacker/feature/detail/review/ReviewScreenViewModel.kt new file mode 100644 index 00000000..47f4578d --- /dev/null +++ b/jetpacker/android/feature/detail/review/src/main/kotlin/com/example/jetpacker/feature/detail/review/ReviewScreenViewModel.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail.review + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.Firebase +import com.google.firebase.ai.InferenceMode +import com.google.firebase.ai.OnDeviceConfig +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.PublicPreviewAPI +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.CancellationException + +@HiltViewModel +class ReviewScreenViewModel @Inject constructor(savedStateHandle: SavedStateHandle) : ViewModel() { + val topics = listOf("Accessibility", "Location", "Food Quality", "Service", "Ambiance", "Value") + + private val _selectedTopics = MutableStateFlow>(emptySet()) + val selectedTopics: StateFlow> = _selectedTopics.asStateFlow() + + private val _isGenerating = MutableStateFlow(false) + val isGenerating: StateFlow = _isGenerating.asStateFlow() + + private val _generatedReviewText = MutableStateFlow("") + val generatedReviewText: StateFlow = _generatedReviewText.asStateFlow() + + fun toggleTopic(topicName: String, positiveOpinion: Boolean) { + val topic = Topic(topicName, positiveOpinion) + val oppositeTopic = Topic(topicName, !positiveOpinion) + _selectedTopics.update { current -> + if (current.contains(topic)) { + current - topic + } else { + (current - oppositeTopic) + topic + } + } + } + + fun onGeneratedReviewTextChange(newText: String) { + _generatedReviewText.value = newText + } + + @OptIn(PublicPreviewAPI::class) + private val generativeModel = + Firebase.ai(backend = GenerativeBackend.googleAI()) + .generativeModel( + modelName = "gemini-2.5-flash-lite", + onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_ON_DEVICE), + ) + + fun generateReview(placeName: String) { + if (_selectedTopics.value.isEmpty()) return + + viewModelScope.launch { + _isGenerating.value = true + try { + val topicsString = _selectedTopics.value.joinToString(", ") { + "${it.name} (${if (it.positiveOpinion) "positive" else "negative"})" + } + val prompt = + "Generate a very short review for ${placeName}, based on these topics: $topicsString. Don't generate a title, just the body of the review. Don't use markdown. Don't extrapolate on the topics, just say if it's good or bad." + val response = generativeModel.generateContent(prompt) + _generatedReviewText.value = response.text?.trim() ?: "" + } catch (e: Exception) { + if (e is CancellationException) throw e + // In a real app, handle error UI + } finally { + _isGenerating.value = false + } + } + } + + fun addReview(placeId: String, context: Context) { + val text = _generatedReviewText.value + if (text.isBlank()) return + _generatedReviewText.value = "" + _selectedTopics.value = emptySet() + + copyAndOpenMapsReview(context, text, placeId) + } + + private fun copyAndOpenMapsReview(context: Context, reviewText: String, placeId: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("User Review", reviewText) + clipboard.setPrimaryClip(clip) + + val uri = Uri.parse("https://search.google.com/local/writereview/mobile?placeid=$placeId") + + val intent = + Intent(Intent.ACTION_VIEW, uri).apply { + setPackage("com.google.android.apps.maps") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + } else { + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } + } +} + +data class PlaceReviewed(val name: String, val placeId: String) + +data class Topic(val name: String, val positiveOpinion: Boolean) diff --git a/jetpacker/android/feature/detail/review/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/review/ReviewScreenshotTest.kt b/jetpacker/android/feature/detail/review/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/review/ReviewScreenshotTest.kt new file mode 100644 index 00000000..50deaf8f --- /dev/null +++ b/jetpacker/android/feature/detail/review/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/review/ReviewScreenshotTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail.review + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import com.example.jetpacker.core.ui.JetPackerTheme + +class ReviewScreenshotTest { + + @PreviewTest + @Preview(showBackground = true) + @Composable + fun ReviewScreenScreenshotPreview() { + val topics = listOf("Accessibility", "Location", "Food Quality", "Service", "Ambiance", "Value") + val selectedTopics = setOf( + Topic(name = "Food Quality", positiveOpinion = true), + Topic(name = "Service", positiveOpinion = false), + Topic(name = "Value", positiveOpinion = true) + ) + JetPackerTheme { + ReviewScreenContent( + placeName = "Le Café Marly", + topics = topics, + selectedTopics = selectedTopics, + isGenerating = false, + generatedReviewText = "Food quality was excellent and represented great value, though the service was a bit slow.", + onTopicSelected = { _, _ -> }, + onGenerateReview = {}, + onGeneratedReviewTextChange = {}, + onPostReview = {}, + onBack = {} + ) + } + } +} diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/FlightDetailScreen.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/FlightDetailScreen.kt new file mode 100644 index 00000000..e7d0d5e9 --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/FlightDetailScreen.kt @@ -0,0 +1,624 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Flight +import androidx.compose.material.icons.rounded.Build +import androidx.compose.material.icons.rounded.Luggage +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily + +/** + * Composable screen representing detailed boarding pass, flight times, gates, and seat assignment + * for a specific flight event. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FlightDetailScreen( + eventId: String? = null, + onBack: () -> Unit, + viewModel: FlightDetailViewModel = hiltViewModel(), +) { + LaunchedEffect(eventId) { eventId?.let { viewModel.loadDetail(it) } } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + FlightDetailScreen(onBack = onBack, uiState = uiState) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FlightDetailScreen(onBack: () -> Unit, uiState: FlightDetailUiState) { + var showFullScreenQr by remember { mutableStateOf(false) } + val flightNumber = uiState.flightNumber ?: "" + val route = uiState.route ?: "" + val departureCode = uiState.departureCode ?: "" + val departureCity = uiState.departureCity ?: "" + val departureTime = uiState.departureTime ?: "" + val arrivalCode = uiState.arrivalCode ?: "" + val arrivalCity = uiState.arrivalCity ?: "" + val arrivalTime = uiState.arrivalTime ?: "" + val date = uiState.date ?: "" + val duration = uiState.duration ?: "" + val departureTerminal = uiState.departureTerminal ?: "" + val departureGate = uiState.departureGate ?: "" + val arrivalTerminal = uiState.arrivalTerminal ?: "" + val arrivalGate = uiState.arrivalGate ?: "" + val boardingTime = uiState.boardingTime ?: "" + val passenger = uiState.passenger ?: "" + val seat = uiState.seat ?: "" + val cabin = uiState.cabin ?: "" + val bookingRef = uiState.bookingRef ?: "" + val aircraft = uiState.aircraft ?: "" + val baggageAllowance = uiState.baggageAllowance ?: "" + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + flightNumber, + fontWeight = FontWeight.ExtraBold, + fontSize = 20.sp, + fontFamily = SekuyaFontFamily, + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.tertiary, + ), + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { padding -> + val outlineVariantColor = MaterialTheme.colorScheme.outlineVariant + Column( + modifier = + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp) + ) { + // Top Flight Header + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + // Airline & Number + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Box( + modifier = + Modifier.size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Flight, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(28.dp), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + flightNumber, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + route, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Flight Path + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(horizontalAlignment = Alignment.Start) { + Text( + departureCode, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + departureCity, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + departureTime, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + // Dotted path with plane + Box( + modifier = Modifier.weight(1f).padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.fillMaxWidth().height(2.dp)) { + drawLine( + color = outlineVariantColor, + start = Offset(0f, size.height / 2), + end = Offset(size.width, size.height / 2), + strokeWidth = 4f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f), + ) + } + Icon( + imageVector = Icons.Default.Flight, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = + Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 8.dp), + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + arrivalCode, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + arrivalCity, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + arrivalTime, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + date, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + duration, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Flight Information Details + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Flight, + contentDescription = "Flight Information", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "FLIGHT INFORMATION", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text( + "DEPARTURE", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + departureTerminal, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Gate", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + departureGate, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("", style = MaterialTheme.typography.labelSmall, color = Color.Transparent) + Text("", style = MaterialTheme.typography.bodyMedium, color = Color.Transparent) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Boarding", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + boardingTime, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) { + Text( + "ARRIVAL", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + arrivalTerminal, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Gate", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + arrivalGate, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(2f)) { + Text( + "PASSENGER", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + passenger, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + "Seat", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.Bottom) { + Text( + seat, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + cabin, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + "BOOKING REF: $bookingRef", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Aircraft & Amenities + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.Build, + contentDescription = "Aircraft & Amenities", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "AIRCRAFT & AMENITIES", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + aircraft, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "• In-flight Wi-Fi Available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "• Individual Power Outlets & USB Ports", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "• Complimentary Snacks & Non-Alcoholic Beverages", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Baggage Information + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.Luggage, + contentDescription = "Baggage Allowance", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "BAGGAGE ALLOWANCE", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + baggageAllowance, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Checked baggage cannot exceed 158cm in overall dimensions (length + width + height). Hand luggage must be capable of fitting into the overhead bin.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Boarding Pass QR + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = CoreUiR.drawable.qr), + contentDescription = "QR Code", + modifier = Modifier.size(120.dp).clickable { showFullScreenQr = true }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + "BOARDING PASS", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = SekuyaFontFamily, + ) + Text( + "Scan at gate", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + if (showFullScreenQr) { + Dialog(onDismissRequest = { showFullScreenQr = false }) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.size(320.dp).clickable { showFullScreenQr = false }, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(id = CoreUiR.drawable.qr), + contentDescription = "QR Code", + modifier = Modifier.size(260.dp), + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun FlightDetailScreenPreview() { + JetPackerTheme { + FlightDetailScreen( + onBack = {}, + uiState = + FlightDetailUiState( + flightNumber = "AF 1234", + route = "Paris (CDG) to New York (JFK)", + departureCode = "CDG", + departureCity = "Paris", + departureTime = "10:30 AM", + arrivalCode = "JFK", + arrivalCity = "New York", + arrivalTime = "1:15 PM", + date = "May 15, 2026", + duration = "7h 45m", + departureTerminal = "Terminal 2E", + departureGate = "M42", + arrivalTerminal = "Terminal 4", + arrivalGate = "B23", + boardingTime = "09:45 AM", + passenger = "Sarah J. Chen", + seat = "12A", + cabin = "Business", + bookingRef = "VERNE123", + ), + ) + } +} diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/FlightDetailViewModel.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/FlightDetailViewModel.kt new file mode 100644 index 00000000..c17062a8 --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/FlightDetailViewModel.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.EventDao +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class FlightDetailUiState( + val flightNumber: String? = null, + val route: String? = null, + val departureCode: String? = null, + val departureCity: String? = null, + val departureTime: String? = null, + val arrivalCode: String? = null, + val arrivalCity: String? = null, + val arrivalTime: String? = null, + val date: String? = null, + val duration: String? = null, + val departureTerminal: String? = null, + val departureGate: String? = null, + val arrivalTerminal: String? = null, + val arrivalGate: String? = null, + val boardingTime: String? = null, + val passenger: String? = null, + val seat: String? = null, + val cabin: String? = null, + val bookingRef: String? = null, + val aircraft: String? = null, + val baggageAllowance: String? = null, +) + +/** + * ViewModel for loading and exposing the state of a specific flight event's details. + */ +@HiltViewModel +class FlightDetailViewModel +@Inject +constructor(savedStateHandle: SavedStateHandle, private val eventDao: EventDao) : ViewModel() { + + private val _uiState = MutableStateFlow(FlightDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var eventId: String = savedStateHandle["eventId"] ?: "" + + init { + loadDetail(eventId) + } + + fun loadDetail(id: String) { + if (id.isNotEmpty()) { + eventId = id + } + viewModelScope.launch { + if (eventId.isNotEmpty()) { + kotlinx.coroutines.flow + .combine(eventDao.getEventById(eventId), eventDao.getFlightDetail(eventId)) { + event, + detail -> + Pair(event, detail) + } + .collectLatest { (e, d) -> + if (e != null && d != null) { + val departureInstant = Instant.ofEpochMilli(e.timestamp) + val formatter = + DateTimeFormatter.ofPattern("hh:mm a", Locale.US).withZone(ZoneId.systemDefault()) + val dateFormatter = + DateTimeFormatter.ofPattern("EEE, MMM d, yyyy", Locale.US) + .withZone(ZoneId.systemDefault()) + + _uiState.update { + FlightDetailUiState( + flightNumber = "${d.airline} ${d.flightNum}", + route = "${d.origin} • ${d.destination}", + departureCode = d.origin, + departureCity = e.location, // Using location as city + departureTime = formatter.format(departureInstant), + arrivalCode = d.destination, + arrivalCity = "Destination", // Dummy + arrivalTime = + formatter.format(departureInstant.plusSeconds(7200)), // Dummy 2h later + date = dateFormatter.format(departureInstant).uppercase(), + departureGate = d.gate ?: "", + boardingTime = + formatter.format(departureInstant.minusSeconds(1800)), // 30m before + seat = d.seat ?: "", + bookingRef = e.sessionId ?: "JS123", + departureTerminal = d.departureTerminal ?: "Terminal 1", + arrivalTerminal = d.arrivalTerminal ?: "Terminal 2", + arrivalGate = d.arrivalGate ?: "", + duration = d.duration ?: "2h 00m", + aircraft = d.aircraft ?: "Airbus A320", + baggageAllowance = d.baggageAllowance ?: "1 x 23kg Checked", + ) + } + } + } + } + } + } +} diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/HotelDetailScreen.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/HotelDetailScreen.kt new file mode 100644 index 00000000..02a22e61 --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/HotelDetailScreen.kt @@ -0,0 +1,459 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.EventNote +import androidx.compose.material.icons.filled.Hotel +import androidx.compose.material.icons.filled.KingBed +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.Pool +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.SekuyaFontFamily + +/** + * Composable screen showing detailed hotel reservation details, including address, + * ratings, room types, check-in/out times, and support triggers. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HotelDetailScreen( + eventId: String? = null, + onBack: () -> Unit, + onOpenHotelChat: (hotelName: String, language: String) -> Unit = { _, _ -> }, + viewModel: HotelDetailViewModel = hiltViewModel(), +) { + LaunchedEffect(eventId) { eventId?.let { viewModel.loadDetail(it) } } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + HotelDetailScreen(onBack = onBack, onOpenHotelChat = onOpenHotelChat, uiState = uiState) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HotelDetailScreen( + onBack: () -> Unit, + onOpenHotelChat: (hotelName: String, language: String) -> Unit = { _, _ -> }, + uiState: HotelDetailUiState, +) { + val hotelName = uiState.hotelName ?: "" + val location = uiState.location ?: "" + val rating = uiState.rating ?: "" + val ratingCount = uiState.ratingCount ?: "" + val pricePerNight = uiState.pricePerNight ?: "" + val checkInDate = uiState.checkInDate ?: "" + val checkInTime = uiState.checkInTime ?: "" + val checkOutDate = uiState.checkOutDate ?: "" + val checkOutTime = uiState.checkOutTime ?: "" + val roomType = uiState.roomType ?: "" + val guests = uiState.guests ?: "" + val address = uiState.address ?: "" + val phone = uiState.phone ?: "" + val language = uiState.language ?: "" + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + hotelName, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontFamily = SekuyaFontFamily, + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { padding -> + Column( + modifier = + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp) + ) { + // Top Header Card + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Box( + modifier = + Modifier.size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Hotel, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(28.dp), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + hotelName, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = location, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Quick Stats Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Star, + contentDescription = "Rating", + tint = Color(0xFFFFB300), + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + rating, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + " ($ratingCount)", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + ) + } + Box( + modifier = + Modifier.clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.tertiary) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + "Booked", + color = MaterialTheme.colorScheme.onTertiary, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + pricePerNight, + fontSize = 20.sp, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Reservation Details + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.AutoMirrored.Filled.EventNote, + contentDescription = "Reservation Details", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "RESERVATION DETAILS", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + HotelReservationItem( + icon = Icons.AutoMirrored.Filled.EventNote, + label = "Check-in", + value = "$checkInDate\n($checkInTime)", + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(1f), + ) + HotelReservationItem( + icon = Icons.AutoMirrored.Filled.EventNote, + label = "Check-out", + value = "$checkOutDate\n($checkOutTime)", + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + HotelReservationItem( + icon = Icons.Default.KingBed, + label = "Room Type", + value = roomType, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(2f), + ) + HotelReservationItem( + icon = Icons.Default.Person, + label = "Guests", + value = guests, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(1f), + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Interactive Location + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Map, + contentDescription = "Location", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "LOCATION & CONTACT", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier.size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.LocationOn, + contentDescription = "Location", + tint = MaterialTheme.colorScheme.tertiary, + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + address, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Contact / Manage + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(24.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Phone, + contentDescription = "Call", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(phone, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface) + } + Button( + onClick = { onOpenHotelChat(hotelName, language) }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + ), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + Text("Chat with staff") + } + } + } + } + } +} + +@Composable +fun HotelReservationItem( + icon: ImageVector, + label: String, + value: String, + color: Color, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier, verticalAlignment = Alignment.Top) { + Box( + modifier = + Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(color.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + value, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + lineHeight = 20.sp, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun HotelDetailScreenPreview() { + HotelDetailScreen( + onBack = {}, + uiState = + HotelDetailUiState( + hotelName = "JetPacker Resort", + location = "Paris", + rating = "4.9", + ratingCount = "1.2k", + pricePerNight = "$350/night", + checkInDate = "May 10", + checkInTime = "2:00 PM", + checkOutDate = "May 15", + checkOutTime = "11:00 AM", + roomType = "Deluxe King Suite", + guests = "2 Adults", + address = "123 Champs-Élysées, Paris", + phone = "+33 1 99 00 51 12", + language = "", + ), + ) +} diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/HotelDetailViewModel.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/HotelDetailViewModel.kt new file mode 100644 index 00000000..e23fd7e2 --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/HotelDetailViewModel.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.EventDao +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class HotelDetailUiState( + val hotelName: String? = null, + val location: String? = null, + val rating: String? = null, + val ratingCount: String? = null, + val pricePerNight: String? = null, + val checkInDate: String? = null, + val checkInTime: String? = null, + val checkOutDate: String? = null, + val checkOutTime: String? = null, + val roomType: String? = null, + val guests: String? = null, + val address: String? = null, + val phone: String? = null, + val language: String? = null, +) + +/** + * ViewModel for loading and exposing the state of a specific hotel accommodation event's details. + */ +@HiltViewModel +class HotelDetailViewModel +@Inject +constructor(savedStateHandle: SavedStateHandle, private val eventDao: EventDao) : ViewModel() { + + private val _uiState = MutableStateFlow(HotelDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var eventId: String = savedStateHandle["eventId"] ?: "" + + init { + loadDetail(eventId) + } + + fun loadDetail(id: String) { + if (id.isNotEmpty()) { + eventId = id + } + viewModelScope.launch { + if (eventId.isNotEmpty()) { + kotlinx.coroutines.flow + .combine(eventDao.getEventById(eventId), eventDao.getHotelDetail(eventId)) { event, detail + -> + Pair(event, detail) + } + .collectLatest { (e, d) -> + if (e != null && d != null) { + val checkInInstant = Instant.ofEpochMilli(d.checkInTime) + val checkOutInstant = Instant.ofEpochMilli(d.checkOutTime) + val formatter = + DateTimeFormatter.ofPattern("HH:mm", Locale.US).withZone(ZoneId.systemDefault()) + val dateFormatter = + DateTimeFormatter.ofPattern("EEE, MMM d", Locale.US) + .withZone(ZoneId.systemDefault()) + + _uiState.update { + HotelDetailUiState( + hotelName = d.name, + location = e.location, + rating = d.rating ?: "", + ratingCount = d.ratingCount ?: "", + pricePerNight = d.pricePerNight ?: "", + checkInDate = dateFormatter.format(checkInInstant), + checkInTime = formatter.format(checkInInstant), + checkOutDate = dateFormatter.format(checkOutInstant), + checkOutTime = formatter.format(checkOutInstant), + guests = d.guests ?: "", + phone = d.phone ?: "", + address = d.address, + roomType = "Booked Room", // Dummy + language = e.language, + ) + } + } + } + } + } + } +} diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailScreen.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailScreen.kt new file mode 100644 index 00000000..5e79c190 --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailScreen.kt @@ -0,0 +1,390 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +import androidx.compose.foundation.Image +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.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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.filled.Museum +import androidx.compose.material.icons.rounded.CalendarToday +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.LocationOn +import androidx.compose.material.icons.rounded.Map +import androidx.compose.material.icons.rounded.Payments +import androidx.compose.material.icons.rounded.Phone +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.core.ui.SekuyaFontFamily + +@OptIn(ExperimentalMaterial3Api::class) + +/** + * Composable screen showing detailed museum visit information, including exhibits, + * address, operating hours, prices, ratings, and triggers for an on-device AI assistant. + */ +@Composable +fun MuseumDetailScreen( + eventId: String? = null, + onBack: () -> Unit = {}, + onOpenAssistant: (String) -> Unit = { _ -> }, + viewModel: MuseumDetailViewModel = hiltViewModel(), +) { + LaunchedEffect(eventId) { eventId?.let { viewModel.loadDetail(it) } } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + uiState.title ?: "", + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontFamily = SekuyaFontFamily, + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { innerPadding -> + Column( + modifier = + Modifier.fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Top Image Card + if ((uiState.imageRes ?: 0) != 0) { + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Image( + painter = painterResource(id = uiState.imageRes ?: 0), + contentDescription = uiState.title ?: "", + modifier = Modifier.fillMaxWidth().height(200.dp), + contentScale = ContentScale.Crop, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + // Main Info Card + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Box( + modifier = + Modifier.size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Museum, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(28.dp), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + uiState.title ?: "", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "MUSEUM", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Date and Time Row + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.CalendarToday, + contentDescription = "Date", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + uiState.date ?: "", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + uiState.time ?: "", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // About Card + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + "ABOUT THE MUSEUM", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = uiState.description ?: "", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + lineHeight = 24.sp, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Extra details + if (!uiState.rating.isNullOrEmpty()) { + InfoRow( + icon = Icons.Rounded.Star, + label = "Rating", + value = uiState.rating ?: "", + iconColor = Color(0xFFFFB300), + ) + } + if (!uiState.openingHours.isNullOrEmpty()) { + InfoRow( + icon = Icons.Rounded.Schedule, + label = "Opening Hours", + value = uiState.openingHours ?: "", + ) + } + if (!uiState.admissionPrice.isNullOrEmpty()) { + InfoRow( + icon = Icons.Rounded.Payments, + label = "Admission", + value = uiState.admissionPrice ?: "", + ) + } + if (!uiState.ticketWebsite.isNullOrEmpty()) { + InfoRow( + icon = Icons.Rounded.Language, + label = "Tickets", + value = uiState.ticketWebsite ?: "", + ) + } + if (!uiState.phone.isNullOrEmpty()) { + InfoRow(icon = Icons.Rounded.Phone, label = "Phone", value = uiState.phone ?: "") + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Location Card + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.LocationOn, + contentDescription = "Location", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "LOCATION", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = + Modifier.fillMaxWidth() + .height(120.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFFE0E0E0)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Rounded.Map, + contentDescription = "Map Placeholder", + tint = Color.Gray, + modifier = Modifier.size(48.dp), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + uiState.location ?: "", + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + uiState.address ?: "", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (FeatureFlags.ENABLE_MUSEUM_ASSISTANT && (uiState.infoUrls?.isNotEmpty() == true)) { + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { onOpenAssistant(uiState.eventId ?: "") }, + modifier = Modifier.height(56.dp).fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Text("Open assistant") + } + } + } + } +} + +@Composable +fun InfoRow( + icon: ImageVector, + label: String, + value: String, + iconColor: Color = MaterialTheme.colorScheme.tertiary, +) { + Row( + modifier = Modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + value, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun MuseumDetailScreenPreview() { + JetPackerTheme { MuseumDetailScreen() } +} diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailUiState.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailUiState.kt new file mode 100644 index 00000000..69379c7b --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailUiState.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +data class MuseumDetailUiState( + val eventId: String? = null, + val title: String? = null, + val timestamp: Long? = null, + val date: String? = null, + val time: String? = null, + val location: String? = null, + val description: String? = null, + val address: String? = null, + val openingHours: String? = null, + val admissionPrice: String? = null, + val ticketWebsite: String? = null, + val rating: String? = null, + val phone: String? = null, + val imageRes: Int? = null, + val infoUrls: List? = null +) diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailViewModel.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailViewModel.kt new file mode 100644 index 00000000..bf6ef7b5 --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailViewModel.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.EventDao +import dagger.hilt.android.lifecycle.HiltViewModel +import java.text.SimpleDateFormat +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * ViewModel for loading and exposing the state of a specific museum visit event. + */ +@HiltViewModel +class MuseumDetailViewModel +@Inject +constructor(savedStateHandle: SavedStateHandle, private val eventDao: EventDao) : ViewModel() { + + private var eventId: String = savedStateHandle["eventId"] ?: "" + + private val _uiState = MutableStateFlow(MuseumDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadDetail(eventId) + } + + fun loadDetail(id: String) { + if (id.isNotEmpty()) { + eventId = id + } + viewModelScope.launch { + if (eventId.isNotEmpty()) { + combine(eventDao.getEventById(eventId), eventDao.getMuseumDetail(eventId)) { event, detail + -> + if (event != null) { + val dateStr = SimpleDateFormat("EEEE, MMM dd", Locale.US).format(event.timestamp) + val timeStr = SimpleDateFormat("h:mm a", Locale.US).format(event.timestamp) + + MuseumDetailUiState( + eventId = event.id, + title = event.title, + timestamp = event.timestamp, + date = dateStr, + time = timeStr, + location = event.location, + description = detail?.description ?: event.description ?: "", + address = detail?.address ?: event.location, + openingHours = detail?.openingHours ?: "", + admissionPrice = detail?.admissionPrice ?: "", + ticketWebsite = detail?.ticketWebsite ?: "", + rating = detail?.rating ?: "", + phone = detail?.phone ?: "", + imageRes = event.imageResList.firstOrNull() ?: 0, + infoUrls = detail?.infoUrls + ) + } else { + MuseumDetailUiState() + } + } + .collect { state -> _uiState.update { state } } + } + } + } +} diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/RestaurantDetailScreen.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/RestaurantDetailScreen.kt new file mode 100644 index 00000000..16eb4f0c --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/RestaurantDetailScreen.kt @@ -0,0 +1,430 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +import android.util.Log +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.EventSeat +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.core.ui.SekuyaFontFamily + +/** + * Composable screen showing detailed restaurant reservation details, including address, + * ratings, cuisines, reservation time, party size, and review triggers. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RestaurantDetailScreen( + eventId: String?, + onBack: () -> Unit, + onOpenReviewScreen: (String, String) -> Unit = { _, _ -> }, + viewModel: RestaurantDetailViewModel = hiltViewModel(), +) { + LaunchedEffect(eventId) { eventId?.let { viewModel.loadDetail(it) } } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + RestaurantDetailScreen( + onBack = onBack, + onOpenReviewScreen = onOpenReviewScreen, + uiState = uiState, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RestaurantDetailScreen( + onBack: () -> Unit, + onOpenReviewScreen: (String, String) -> Unit = { _, _ -> }, + uiState: RestaurantDetailUiState, +) { + val restaurantName = uiState.restaurantName ?: "" + val cuisineType = uiState.cuisineType ?: "" + val rating = uiState.rating ?: "" + val reviewCount = uiState.reviewCount ?: "" + val priceRange = uiState.priceRange ?: "" + val date = uiState.date ?: "" + val time = uiState.time ?: "" + val guests = uiState.guests ?: "" + val reservationName = uiState.reservationName ?: "" + val address = uiState.address ?: "" + val placeId = uiState.placeId ?: "" + val phone = uiState.phone ?: "" + val primaryGreen = MaterialTheme.colorScheme.tertiary + val surfaceLightGreen = MaterialTheme.colorScheme.surface + + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + restaurantName, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontFamily = SekuyaFontFamily, + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { padding -> + Column( + modifier = + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp) + ) { + // Top Header Card + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Box( + modifier = + Modifier.size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Restaurant, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(28.dp), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + restaurantName, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + cuisineType, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Quick Stats Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Star, + contentDescription = "Rating", + tint = Color(0xFFFFB300), + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + rating, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + " ($reviewCount)", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + ) + } + Text( + priceRange, + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.Medium, + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Reservation Details + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.AutoMirrored.Filled.MenuBook, + contentDescription = "Reservation Details", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "RESERVATION DETAILS", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + ReservationItem( + icon = Icons.Default.Schedule, + label = "Date & Time", + value = "$date\n$time", + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(1f), + ) + ReservationItem( + icon = Icons.Default.Person, + label = "Guests", + value = guests, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + ReservationItem( + icon = Icons.Default.EventSeat, + label = "Reserved Under", + value = reservationName, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Interactive Info Map/Location + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Place, + contentDescription = "Location", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + address, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { /* Open maps */ }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Open in Maps") + } + + if (FeatureFlags.ENABLE_REVIEW_GENERATION) { + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { onOpenReviewScreen(placeId, restaurantName) }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Write a review") + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Contact / Menu + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(24.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Phone, + contentDescription = "Call", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(phone, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface) + } + Button( + onClick = { /* Call */ }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + ), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + ) { + Text("Call Now") + } + } + } + } + } +} + +@Composable +fun ReservationItem( + icon: ImageVector, + label: String, + value: String, + color: Color, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier, verticalAlignment = Alignment.Top) { + Box( + modifier = + Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(color.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + value, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + lineHeight = 20.sp, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun RestaurantDetailScreenPreview() { + RestaurantDetailScreen( + onBack = {}, + uiState = + RestaurantDetailUiState( + restaurantName = "Le Parfait", + cuisineType = "French Fine Dining", + rating = "4.8", + reviewCount = "850", + priceRange = "$$$$", + date = "May 12, 2026", + time = "7:30 PM", + guests = "2", + reservationName = "Sarah J. Chen", + address = "45 Rue de la Paix, Paris", + phone = "+33 1 55 66 77 88", + ), + ) +} diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/RestaurantDetailViewModel.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/RestaurantDetailViewModel.kt new file mode 100644 index 00000000..3ad6b79b --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/RestaurantDetailViewModel.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.EventDao +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class RestaurantDetailUiState( + val restaurantName: String? = null, + val cuisineType: String? = null, + val rating: String? = null, + val reviewCount: String? = null, + val priceRange: String? = null, + val date: String? = null, + val time: String? = null, + val guests: String? = null, + val reservationName: String? = null, + val address: String? = null, + val placeId: String? = null, + val phone: String? = null, +) + +/** + * ViewModel for loading and exposing the state of a specific dining/restaurant reservation event. + */ +@HiltViewModel +class RestaurantDetailViewModel +@Inject +constructor(savedStateHandle: SavedStateHandle, private val eventDao: EventDao) : ViewModel() { + + private val _uiState = MutableStateFlow(RestaurantDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + savedStateHandle.get("eventId")?.let { loadDetail(it) } + } + + fun loadDetail(eventId: String) { + viewModelScope.launch { + if (eventId.isNotEmpty()) { + combine(eventDao.getDiningDetail(eventId), eventDao.getEventById(eventId)) { d, e -> + if (d != null) { + val instant = Instant.ofEpochMilli(d.reservationTime) + val formatter = + DateTimeFormatter.ofPattern("hh:mm a", Locale.US).withZone(ZoneId.systemDefault()) + val dateFormatter = + DateTimeFormatter.ofPattern("EEE, MMM d, yyyy", Locale.US) + .withZone(ZoneId.systemDefault()) + + RestaurantDetailUiState( + restaurantName = d.restaurantName, + cuisineType = e?.description ?: "Local Cuisine", + rating = d.rating ?: "", + reviewCount = d.reviewCount ?: "", + priceRange = d.priceRange ?: "", + date = dateFormatter.format(instant), + time = formatter.format(instant), + guests = "${d.partySize} People", + address = d.address, + placeId = e?.placeId, + phone = d.phone ?: "+1 555-0199", + ) + } else null + } + .collectLatest { state -> state?.let { s -> _uiState.update { s } } } + } + } + } +} diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailScreen.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailScreen.kt new file mode 100644 index 00000000..91a1996b --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailScreen.kt @@ -0,0 +1,373 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +import androidx.compose.foundation.Image +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.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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.filled.Tour +import androidx.compose.material.icons.rounded.CalendarToday +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.LocationOn +import androidx.compose.material.icons.rounded.Map +import androidx.compose.material.icons.rounded.People +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.core.ui.SekuyaFontFamily + +/** + * Composable screen showing detailed guided tour visit details, including descriptions, + * ratings, tour guide information, prices, schedules, and map location triggers. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TourDetailScreen( + eventId: String? = null, + onBack: () -> Unit = {}, + viewModel: TourDetailViewModel = hiltViewModel(), +) { + LaunchedEffect(eventId) { eventId?.let { viewModel.loadDetail(it) } } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + uiState.title ?: "", + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp, + fontFamily = SekuyaFontFamily, + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), + ) + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { innerPadding -> + Column( + modifier = + Modifier.fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Top Image Card + if ((uiState.imageRes ?: 0) != 0) { + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Image( + painter = painterResource(id = uiState.imageRes ?: 0), + contentDescription = uiState.title ?: "", + modifier = Modifier.fillMaxWidth().height(200.dp), + contentScale = ContentScale.Crop, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + + // Main Info Card + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Box( + modifier = + Modifier.size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLowest), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Tour, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(28.dp), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + uiState.title ?: "", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + (uiState.type ?: "").uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Date and Time Row + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.CalendarToday, + contentDescription = "Date", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + uiState.date ?: "", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + uiState.time ?: "", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // About Card + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + "ABOUT THE TOUR", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = uiState.about ?: "", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + lineHeight = 24.sp, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Location Card + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.LocationOn, + contentDescription = "Location", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "LOCATION", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = + Modifier.fillMaxWidth() + .height(120.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFFE0E0E0)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Rounded.Map, + contentDescription = "Map Placeholder", + tint = Color.Gray, + modifier = Modifier.size(48.dp), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + uiState.locationName ?: "", + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + uiState.locationAddress ?: "", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Meeting Point Card + Card( + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.People, + contentDescription = "Meeting Point", + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "MEETING POINT", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = uiState.meetingPoint ?: "", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Important Notes Card + Card( + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.Info, + contentDescription = "Important Notes", + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "IMPORTANT NOTES", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + letterSpacing = 1.sp, + fontFamily = SekuyaFontFamily, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + (uiState.notes ?: emptyList()).forEach { note -> + Text("• $note", fontSize = 16.sp, color = MaterialTheme.colorScheme.onErrorContainer) + Spacer(modifier = Modifier.height(4.dp)) + } + } + } + } + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun TourDetailScreenPreview() { + JetPackerTheme { TourDetailScreen() } +} diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailUiState.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailUiState.kt new file mode 100644 index 00000000..f5e00e6a --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailUiState.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +data class TourDetailUiState( + val title: String? = null, + val type: String? = null, + val imageRes: Int? = null, + val date: String? = null, + val time: String? = null, + val locationName: String? = null, + val locationAddress: String? = null, + val about: String? = null, + val meetingPoint: String? = null, + val notes: List? = emptyList(), + val urls: List? = emptyList(), +) diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailViewModel.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailViewModel.kt new file mode 100644 index 00000000..e93b4559 --- /dev/null +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailViewModel.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.TourDetailDao +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * ViewModel for loading and exposing details of custom guided tour events. + */ +@HiltViewModel +open class TourDetailViewModel +@Inject +constructor(savedStateHandle: SavedStateHandle, private val tourDetailDao: TourDetailDao) : + ViewModel() { + private val _uiState = MutableStateFlow(TourDetailUiState()) + open val uiState: StateFlow = _uiState.asStateFlow() + + private var eventId: String = savedStateHandle["eventId"] ?: "" + + init { + loadDetail(eventId) + } + + fun loadDetail(id: String) { + if (id.isNotEmpty()) { + eventId = id + } + viewModelScope.launch { + if (eventId.isNotEmpty()) { + tourDetailDao.getTourDetailByEventId(eventId).collectLatest { detail -> + if (detail != null) { + _uiState.update { + TourDetailUiState( + title = detail.title, + type = detail.type, + imageRes = detail.imageRes, + date = detail.date, + time = detail.time, + locationName = detail.locationName, + locationAddress = detail.locationAddress, + about = detail.about, + meetingPoint = detail.meetingPoint, + notes = detail.notes, + ) + } + } + } + } + } + } +} diff --git a/jetpacker/android/feature/detail/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/DetailScreenshotTest.kt b/jetpacker/android/feature/detail/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/DetailScreenshotTest.kt new file mode 100644 index 00000000..a1c22678 --- /dev/null +++ b/jetpacker/android/feature/detail/src/screenshotTest/kotlin/com/example/jetpacker/feature/detail/DetailScreenshotTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.detail + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import androidx.lifecycle.SavedStateHandle +import com.example.jetpacker.core.ui.R +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.data.itinerary.TourDetail +import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.trips.DummyData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow + +class DetailScreenshotTest { + @PreviewTest + @Preview(showBackground = true) + @Composable + fun FlightDetailScreenshotPreview() { + val f = DummyData.flightDetails.first() + JetPackerTheme { + FlightDetailScreen( + onBack = {}, + uiState = + FlightDetailUiState( + flightNumber = "${f.airline} ${f.flightNum}", + route = "${f.origin} to ${f.destination}", + departureCode = f.origin, + departureCity = "Origin", + departureTime = "10:00 AM", + arrivalCode = f.destination, + arrivalCity = "Destination", + arrivalTime = "06:00 PM", + date = "May 15, 2026", + duration = f.duration, + departureTerminal = f.departureTerminal ?: "", + departureGate = f.gate ?: "", + arrivalTerminal = f.arrivalTerminal ?: "", + arrivalGate = f.arrivalGate ?: "", + boardingTime = "09:15 AM", + passenger = "Sarah J. Chen", + seat = f.seat ?: "", + cabin = "Business", + bookingRef = "VERNE123", + ), + ) + } + } + + @PreviewTest + @Preview(showBackground = true) + @Composable + fun HotelDetailScreenshotPreview() { + val h = DummyData.hotelDetails.first() + JetPackerTheme { + HotelDetailScreen( + onBack = {}, + uiState = + HotelDetailUiState( + hotelName = h.name, + location = h.address, + rating = h.rating ?: "4.9", + ratingCount = h.ratingCount ?: "1.0k", + pricePerNight = h.pricePerNight ?: "$300", + checkInDate = "May 15", + checkInTime = "3:00 PM", + checkOutDate = "May 20", + checkOutTime = "11:00 AM", + roomType = "Deluxe Room", + guests = h.guests ?: "2 Guests", + address = h.address, + phone = h.phone ?: "+1 555 0199", + language = "", + ), + ) + } + } + + @PreviewTest + @Preview(showBackground = true) + @Composable + fun RestaurantDetailScreenshotPreview() { + val d = DummyData.diningDetails.first() + JetPackerTheme { + RestaurantDetailScreen( + onBack = {}, + uiState = + RestaurantDetailUiState( + restaurantName = d.restaurantName, + cuisineType = "Fine Dining", + rating = d.rating ?: "4.8", + reviewCount = d.reviewCount ?: "500", + priceRange = d.priceRange ?: "$$$", + date = "May 16, 2026", + time = "7:00 PM", + guests = "${d.partySize}", + reservationName = "Sarah J. Chen", + address = d.address, + phone = d.phone ?: "+1 555 0199", + ), + ) + } + } + + @PreviewTest + @Preview(showBackground = true) + @Composable + fun TourDetailScreenshotPreview() { + val fakeTourDetailDao = + object : TourDetailDao { + override fun getTourDetailByEventId(eventId: String): Flow = emptyFlow() + + override fun getTourDetailById(id: String): Flow = emptyFlow() + + override suspend fun insertTourDetail(tourDetail: TourDetail) {} + + override suspend fun insertTourDetails(tourDetails: List) {} + + override suspend fun deleteAllTourDetails() {} + } + + val savedStateHandle = SavedStateHandle(mapOf("eventId" to "event4")) + + val fakeViewModel = + object : TourDetailViewModel(savedStateHandle, fakeTourDetailDao) { + private val dummyTour = DummyData.tourDetails.first() + private val state = + MutableStateFlow( + TourDetailUiState( + title = dummyTour.title, + type = dummyTour.type, + imageRes = dummyTour.imageRes ?: R.drawable.img_louvre, + date = dummyTour.date, + time = dummyTour.time, + locationName = dummyTour.locationName, + locationAddress = dummyTour.locationAddress, + about = dummyTour.about ?: "", + meetingPoint = dummyTour.meetingPoint ?: "", + notes = dummyTour.notes, + ) + ) + override val uiState: StateFlow = state + } + + JetPackerTheme { + TourDetailScreen(onBack = {}, viewModel = fakeViewModel) + } + } +} diff --git a/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/FlightDetailScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/FlightDetailScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..fb47e85c Binary files /dev/null and b/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/FlightDetailScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/HotelDetailScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/HotelDetailScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..9f51fa7b Binary files /dev/null and b/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/HotelDetailScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/RestaurantDetailScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/RestaurantDetailScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..ce1a6ef1 Binary files /dev/null and b/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/RestaurantDetailScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/TourDetailScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/TourDetailScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..430e3797 Binary files /dev/null and b/jetpacker/android/feature/detail/src/screenshotTestDebug/reference/com/example/jetpacker/feature/detail/DetailScreenshotTest/TourDetailScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/detail/src/test/AndroidManifest.xml b/jetpacker/android/feature/detail/src/test/AndroidManifest.xml new file mode 100644 index 00000000..6de63c50 --- /dev/null +++ b/jetpacker/android/feature/detail/src/test/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/jetpacker/android/feature/home/build.gradle.kts b/jetpacker/android/feature/home/build.gradle.kts new file mode 100644 index 00000000..dbb84ce7 --- /dev/null +++ b/jetpacker/android/feature/home/build.gradle.kts @@ -0,0 +1,65 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.home" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { compose = true } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + + implementation(project(":core:flags")) + implementation(project(":core:speech")) + implementation(project(":core:ui")) + implementation(project(":data:itinerary")) + implementation(project(":data:trips")) + implementation(project(":feature:create_trip")) + + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.mlkit.translate) + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/DebugScreen.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/DebugScreen.kt new file mode 100644 index 00000000..e7cac9f2 --- /dev/null +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/DebugScreen.kt @@ -0,0 +1,403 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.example.jetpacker.core.flags.FeatureFlags +import com.google.mlkit.nl.translate.TranslateLanguage +import java.time.Instant +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugScreen(onBack: () -> Unit = {}, viewModel: DebugViewModel = hiltViewModel()) { + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text("AI Feature Debug Screen") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + } + ) { innerPadding -> + Column( + modifier = + Modifier.fillMaxSize() + .padding(innerPadding) + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + "Offline Features", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FeatureToggle("Itinerary Enrichment", FeatureFlags.ENABLE_ITINERARY_ENRICHMENT) { value -> + FeatureFlags.toggleFlag(context, "enable_itinerary_enrichment", value) + } + FeatureToggle("Expense Management", FeatureFlags.ENABLE_EXPENSE_MANAGEMENT) { value -> + FeatureFlags.toggleFlag(context, "enable_expense_management", value) + } + FeatureToggle("Voice Notes", FeatureFlags.ENABLE_VOICE_NOTES) { value -> + FeatureFlags.toggleFlag(context, "enable_voice_notes", value) + } + + Spacer(modifier = Modifier.padding(vertical = 16.dp)) + + Text( + "Online Features", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FeatureToggle("Review Generation", FeatureFlags.ENABLE_REVIEW_GENERATION) { value -> + FeatureFlags.toggleFlag(context, "enable_review_generation", value) + } + FeatureToggle("Museum Assistant", FeatureFlags.ENABLE_MUSEUM_ASSISTANT) { value -> + FeatureFlags.toggleFlag(context, "enable_museum_assistant", value) + } + FeatureToggle("Museum Assistant – url grounding", FeatureFlags.ENABLE_URL_GROUNDING) { value -> + FeatureFlags.toggleFlag(context, "enable_url_grounding", value) + } + FeatureToggle("Museum Assistant – web grounding", FeatureFlags.ENABLE_SEARCH_GROUNDING) { value -> + FeatureFlags.toggleFlag(context, "enable_search_grounding", value) + } + + Spacer(modifier = Modifier.padding(vertical = 16.dp)) + + Text( + "Voice Settings", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + var showLanguagePicker by remember { mutableStateOf(false) } + val currentLanguageCode = FeatureFlags.DEMO_LANGUAGE + val currentLanguageName = + remember(currentLanguageCode) { + Locale.forLanguageTag(currentLanguageCode).getDisplayName(Locale.US) + } + + Row( + modifier = + Modifier.fillMaxWidth().clickable { showLanguagePicker = true }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Demo Language", style = MaterialTheme.typography.bodyLarge) + Text( + text = currentLanguageName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text("Change", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + } + + if (showLanguagePicker) { + LanguagePickerDialog( + currentLanguageCode = currentLanguageCode, + onLanguageSelected = { newLang -> + FeatureFlags.putStringFlag(context, "demo_language", newLang) + }, + onDismiss = { showLanguagePicker = false }, + ) + } + + Spacer(modifier = Modifier.padding(vertical = 16.dp)) + + Text( + "Time Override Settings", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + var overrideTimeState by remember { mutableStateOf(FeatureFlags.OVERRIDE_CURRENT_TIME_MILLIS) } + var showDatePicker by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Mock System Date", style = MaterialTheme.typography.bodyLarge) + val dateLabel = overrideTimeState?.let { Instant.ofEpochMilli(it).toString().take(10) } ?: "Real System Clock" + Text( + text = dateLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Button(onClick = { showDatePicker = true }) { + Text("Set Date") + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + FeatureFlags.putLongFlag(context, FeatureFlags.KEY_OVERRIDE_CURRENT_TIME_MILLIS, 0L) + overrideTimeState = null + } + ) { + Text("Clear") + } + } + + if (showDatePicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = overrideTimeState ?: System.currentTimeMillis() + ) + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + FeatureFlags.putLongFlag(context, FeatureFlags.KEY_OVERRIDE_CURRENT_TIME_MILLIS, millis) + overrideTimeState = millis + } + showDatePicker = false + } + ) { + Text("Confirm") + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text("Cancel") + } + } + ) { + DatePicker(state = datePickerState) + } + } + + Spacer(modifier = Modifier.padding(vertical = 16.dp)) + + Button(onClick = { viewModel.resetDatabase() }, modifier = Modifier.fillMaxWidth()) { + Text("Reset Database to Mock Data") + } + + Spacer(modifier = Modifier.padding(vertical = 8.dp)) + + var showLicensesDialog by remember { mutableStateOf(false) } + Button(onClick = { showLicensesDialog = true }, modifier = Modifier.fillMaxWidth()) { + Text("Open Source Licenses") + } + + if (showLicensesDialog) { + LicensesDialog(onDismiss = { showLicensesDialog = false }) + } + } + } +} + +@Composable +fun FeatureToggle(name: String, initialValue: Boolean, onValueChange: (Boolean) -> Unit) { + var checked by remember { mutableStateOf(initialValue) } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(name, modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = checked, + onCheckedChange = { + checked = it + onValueChange(it) + }, + ) + } +} + +data class LanguageItem(val code: String, val displayName: String) + +@Composable +fun LanguagePickerDialog( + currentLanguageCode: String, + onLanguageSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + var searchQuery by remember { mutableStateOf("") } + + val allLanguages = remember { + TranslateLanguage.getAllLanguages() + .map { code -> + val displayName = Locale.forLanguageTag(code).getDisplayName(Locale.US) + LanguageItem(code, displayName) + } + .sortedBy { it.displayName } + } + + val filteredLanguages = + remember(searchQuery) { + allLanguages.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.code.contains(searchQuery, ignoreCase = true) + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Select Demo Language") }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text("Search Languages") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) + + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp)) { + items(filteredLanguages) { lang -> + val isSelected = lang.code == currentLanguageCode + Row( + modifier = + Modifier.fillMaxWidth() + .clickable { + onLanguageSelected(lang.code) + onDismiss() + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = lang.displayName, + style = + if (isSelected) { + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } else { + MaterialTheme.typography.bodyLarge + }, + modifier = Modifier.weight(1f), + ) + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + ) +} + +@Composable +fun LicensesDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + val licensesText = remember { + try { + context.assets.open("THIRD_PARTY_NOTICES").bufferedReader().use { it.readText() } + } catch (e: Exception) { + "Failed to load third-party notices:\n${e.localizedMessage}" + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Open Source Licenses") }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider(modifier = Modifier.padding(bottom = 8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = licensesText, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + modifier = Modifier.padding(4.dp) + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + } + ) +} + diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/DebugViewModel.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/DebugViewModel.kt new file mode 100644 index 00000000..6f468ada --- /dev/null +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/DebugViewModel.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.DayThemeDao +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.ExpenseDao +import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.trips.DummyData +import com.example.jetpacker.data.trips.TripDao +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.launch + +@HiltViewModel +open class DebugViewModel +@Inject +constructor( + private val tripDao: TripDao?, + private val eventDao: EventDao?, + private val tourDetailDao: TourDetailDao?, + private val expenseDao: ExpenseDao?, + private val dayThemeDao: DayThemeDao?, +) : ViewModel() { + + open fun resetDatabase() { + viewModelScope.launch { + // Clear all tables through DAOs + tripDao?.deleteAllTrips() + eventDao?.deleteAllEvents() + eventDao?.deleteAllFlightDetails() + eventDao?.deleteAllHotelDetails() + eventDao?.deleteAllDiningDetails() + eventDao?.deleteAllActivityDetails() + tourDetailDao?.deleteAllTourDetails() + expenseDao?.deleteAllExpenses() + dayThemeDao?.deleteAllThemes() + + // Repopulate with mock data + tripDao?.insertTrips(DummyData.trips) + eventDao?.insertEvents(DummyData.events) + tourDetailDao?.insertTourDetails(DummyData.tourDetails) + DummyData.flightDetails.forEach { eventDao?.insertFlightDetail(it) } + DummyData.hotelDetails.forEach { eventDao?.insertHotelDetail(it) } + DummyData.diningDetails.forEach { eventDao?.insertDiningDetail(it) } + DummyData.activityDetails.forEach { eventDao?.insertActivityDetail(it) } + DummyData.museumDetails.forEach { eventDao?.insertMuseumDetail(it) } + DummyData.expenses.forEach { expenseDao?.insertExpense(it) } + DummyData.voiceNotes.forEach { eventDao?.insertVoiceNote(it) } + } + } +} diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeScreen.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeScreen.kt new file mode 100644 index 00000000..64511642 --- /dev/null +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeScreen.kt @@ -0,0 +1,525 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.home + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.content.res.Configuration +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalGridApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.GraphicEq +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ComposeUiFlags +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ExperimentalMediaQueryApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.derivedMediaQuery +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.asComposePath +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.graphics.shapes.Morph +import androidx.graphics.shapes.toPath +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.core.ui.components.JetPackerExtendedFloatingActionButton +import com.example.jetpacker.data.trips.DummyData +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.feature.create_trip.CreateTripViewModel +import java.time.Instant +import kotlinx.coroutines.launch + +@OptIn(ExperimentalComposeUiApi::class) +private val initMediaQuery = run { + ComposeUiFlags.isMediaQueryIntegrationEnabled = true + true +} + +/** + * Composable representing the home dashboard of the application. + * Displays a list of user trips, handles voice-note permissions, and provides links + * to trip creation and application debugging screens. + */ +@SuppressLint("NewApi", "ContextCastToActivity") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMediaQueryApi::class) +@Composable +fun HomeScreen( + modifier: Modifier = Modifier, + onTripClick: (String) -> Unit = {}, + onTripCreated: (String) -> Unit = {}, + onNavigateToDebug: () -> Unit = {}, + onCreateTripClick: () -> Unit = {}, + viewModel: HomeViewModel = hiltViewModel(), + createTripViewModel: CreateTripViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val activity = LocalContext.current as? ComponentActivity + val isHiltActivity = + remember(activity) { + activity != null && + (activity.javaClass.name.contains("Hilt_") || + activity.javaClass.superclass?.name?.contains("Hilt_") == true || + activity.javaClass.interfaces.any { it.name.contains("GeneratedComponent") }) + } + val sharedCreateTripViewModel: CreateTripViewModel = + if (isHiltActivity && activity != null) { + hiltViewModel(activity) + } else { + createTripViewModel + } + val createTripUiState by sharedCreateTripViewModel.uiState.collectAsStateWithLifecycle() + + var tripToDelete by remember { mutableStateOf(null) } + + if (tripToDelete != null) { + DeleteTripSheet( + trip = tripToDelete!!, + onConfirm = { + viewModel.deleteTrip(tripToDelete!!) + tripToDelete = null + }, + onDismiss = { tripToDelete = null }, + ) + } + + val foldableBreakpoint by derivedMediaQuery { windowWidth >= 600.dp } + val tabletBreakpoint by derivedMediaQuery { windowWidth >= 1200.dp } + + Scaffold( + modifier = modifier, + bottomBar = { + if (!tabletBreakpoint) { + Box( + modifier = + Modifier.fillMaxWidth() + .navigationBarsPadding() + .padding(vertical = 16.dp, horizontal = 24.dp), + contentAlignment = if (foldableBreakpoint) Alignment.CenterEnd else Alignment.Center, + ) { + JetPackerExtendedFloatingActionButton( + icon = Icons.Rounded.Add, + label = "Create trip", + onClick = onCreateTripClick, + ) + } + } + }, + containerColor = MaterialTheme.colorScheme.surface, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize()) { + AnimatedContent(targetState = uiState.isLoading, label = "HomeScreenState") { isLoading -> + if (isLoading) { + LoadingState() + } else { + HomeScreenContent( + trips = uiState.trips, + tripToDelete = tripToDelete, + paddingValues = innerPadding, + onTripClick = onTripClick, + onSwipeToDelete = { tripToDelete = it }, + onNavigateToDebug = onNavigateToDebug, + onCreateClick = onCreateTripClick, + ) + } + } + + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeleteTripSheet(trip: Trip, onConfirm: () -> Unit, onDismiss: () -> Unit) { + var isConfirmChecked by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp).padding(bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier.size(80.dp) + .background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(36.dp), + ) + } + + Text( + "Delete Trip?", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Card( + modifier = Modifier.fillMaxWidth().height(120.dp), + shape = MaterialTheme.shapes.large, + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + val model = trip.imageUri ?: trip.imageRes + AsyncImage( + model = model, + placeholder = (model as? Int)?.let { painterResource(it) }, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + + Text( + "This action is permanent and will delete all events in the itinerary for '${trip.title}'.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier.clickable { isConfirmChecked = !isConfirmChecked } + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isConfirmChecked, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.error, + checkmarkColor = MaterialTheme.colorScheme.onError, + ), + onCheckedChange = { isConfirmChecked = it } + ) + Text( + "I am sure I want to delete this trip permanently.", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton( + colors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + onClick = onDismiss, modifier = Modifier.weight(1f)) { Text("Cancel") } + Button( + onClick = onConfirm, + enabled = isConfirmChecked, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + shape = MaterialTheme.shapes.medium, + ) { + Text("Delete") + } + } + } + } +} + +@SuppressLint("NewApi") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun LoadingState() { + val infiniteTransition = rememberInfiniteTransition(label = "shape_loading") + + // A collection of fun Material 3 shapes to loop through + val shapes = remember { + listOf(MaterialShapes.Square, MaterialShapes.Clover4Leaf, MaterialShapes.Cookie6Sided) + } + + // Progress for morphing through the list of shapes (1.5s per transition) + val animProgress by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = shapes.size.toFloat(), + animationSpec = + infiniteRepeatable( + animation = tween(1500 * shapes.size, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "morph_loop", + ) + + // Continuous rotation for added dynamic effect + val rotation by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = tween(3000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "rotation", + ) + + val currentIndex = animProgress.toInt() % shapes.size + val nextIndex = (currentIndex + 1) % shapes.size + val morphProgress = animProgress % 1f + + val shapeA = shapes[currentIndex] + val shapeB = shapes[nextIndex] + val morph = remember(shapeA, shapeB) { Morph(shapeA, shapeB) } + val primaryContainerColor = MaterialTheme.colorScheme.primaryContainer + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = + Modifier.size(160.dp) + .graphicsLayer { rotationZ = rotation } // Rotating around the center of the Box + .drawWithCache { + val path = morph.toPath(morphProgress).asComposePath() + val matrix = Matrix() + matrix.scale(size.width, size.height) + path.transform(matrix) + onDrawBehind { drawPath(path, color = primaryContainerColor) } + } + ) + } +} + +@SuppressLint("NewApi") +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalGridApi::class, + ExperimentalMediaQueryApi::class, +) +@Composable +fun HomeScreenContent( + trips: List, + tripToDelete: Trip?, + onTripClick: (String) -> Unit, + onSwipeToDelete: (Trip) -> Unit, + onNavigateToDebug: () -> Unit, + onCreateClick: () -> Unit, + modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues(0.dp), + currentTimeMillis: Long = FeatureFlags.OVERRIDE_CURRENT_TIME_MILLIS ?: System.currentTimeMillis(), +) { + val currentTrip = remember(trips, currentTimeMillis) { + trips.find { it.startDate <= currentTimeMillis && currentTimeMillis <= it.endDate } + } + val pastTrips = remember(trips, currentTrip, currentTimeMillis) { + trips.filter { it != currentTrip && it.endDate < currentTimeMillis } + } + val upcomingTrips = remember(trips, currentTrip, currentTimeMillis) { + trips.filter { it != currentTrip && it.startDate > currentTimeMillis } + } + Box(modifier = modifier.fillMaxSize()) { + val tabletBreakpoint by derivedMediaQuery { windowWidth >= 1200.dp } + + when { + tabletBreakpoint -> { + TabletLayout( + currentTrip = currentTrip, + tripToDelete = tripToDelete, + onTripClick = onTripClick, + onSwipeToDelete = onSwipeToDelete, + onCreateClick = onCreateClick, + pastTrips = pastTrips, + upcomingTrips = upcomingTrips, + ) + } + else -> { + PhoneLayout( + pastTrips = pastTrips, + paddingValues = paddingValues, + tripToDelete = tripToDelete, + onTripClick = onTripClick, + onSwipeToDelete = onSwipeToDelete, + currentTrip = currentTrip, + upcomingTrips = upcomingTrips, + onCreateClick = onCreateClick, + ) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Preview( + name = "Phone Light - Standard Font", + fontScale = 1.0f, + showBackground = true, + device = "spec:width=411dp,height=891dp,dpi=440", + uiMode = Configuration.UI_MODE_NIGHT_NO, +) +@Preview( + name = "Phone Dark - Standard Font", + fontScale = 1.0f, + showBackground = true, + device = "spec:width=411dp,height=891dp,dpi=440", + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Preview( + name = "Phone Light - Large Font", + fontScale = 1.5f, + showBackground = true, + device = "spec:width=411dp,height=891dp,dpi=440", + uiMode = Configuration.UI_MODE_NIGHT_NO, +) +@Preview( + name = "Tablet Light - Standard Font", + fontScale = 1.0f, + showBackground = true, + device = "spec:width=1280dp,height=800dp,dpi=240", + uiMode = Configuration.UI_MODE_NIGHT_NO, +) +@Preview( + name = "Tablet Dark - Standard Font", + fontScale = 1.0f, + showBackground = true, + device = "spec:width=1280dp,height=800dp,dpi=240", + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Composable +fun HomeScreenMultipreview() { + ComposeUiFlags.isMediaQueryIntegrationEnabled = true + JetPackerTheme { + HomeScreenContent( + trips = DummyData.trips, + tripToDelete = null, + onTripClick = {}, + onSwipeToDelete = {}, + onNavigateToDebug = {}, + onCreateClick = {}, + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Preview(showBackground = true) +@Composable +fun HomeScreenPreview() { + ComposeUiFlags.isMediaQueryIntegrationEnabled = true + JetPackerTheme { + HomeScreenContent( + onNavigateToDebug = {}, + trips = DummyData.trips, + tripToDelete = null, + onTripClick = {}, + onSwipeToDelete = {}, + onCreateClick = {}, + currentTimeMillis = Instant.parse("2026-05-19T12:00:00Z").toEpochMilli(), + ) + } +} diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeUiState.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeUiState.kt new file mode 100644 index 00000000..c57354e4 --- /dev/null +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeUiState.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.home + +import com.example.jetpacker.data.trips.Trip + +data class HomeUiState( + val trips: List = emptyList(), + val isLoading: Boolean = false, + val statusText: String = "Initializing...", + val transcription: String = "", + val partialTranscription: String = "", + val isListening: Boolean = false, + val showDialog: Boolean = false, + val translatedTranscription: String = "", + val transcriptionResult: String? = null, + val audioLevel: Float = 0f, + val isError: Boolean = false, +) diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeViewModel.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeViewModel.kt new file mode 100644 index 00000000..b439890b --- /dev/null +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeViewModel.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.home + +import android.annotation.SuppressLint +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.core.speech.VoiceInputManager +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.ExpenseDao +import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.trips.DummyData +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject + +/** + * ViewModel responsible for managing and exposing the dashboard list of user trips, + * and handling database cleanups/initializations. + */ +@HiltViewModel +@SuppressLint("GlobalCoroutineDispatchers") +open class HomeViewModel +@Inject +constructor( + private val tripDao: TripDao, + private val eventDao: EventDao, + private val tourDetailDao: TourDetailDao, + private val expenseDao: ExpenseDao, + private val voiceInputManager: VoiceInputManager, +) : ViewModel() { + private val _uiState = MutableStateFlow(HomeUiState()) + open val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadTrips() + viewModelScope.launch { + voiceInputManager.uiState.collectLatest { voiceState -> + _uiState.update { current -> + current.copy( + isListening = voiceState.isListening, + showDialog = voiceState.showDialog, + statusText = voiceState.statusText, + transcription = voiceState.transcription, + partialTranscription = voiceState.partialTranscription, + translatedTranscription = voiceState.translatedTranscription, + audioLevel = voiceState.audioLevel, + transcriptionResult = voiceState.transcriptionResult, + isError = voiceState.isError, + ) + } + } + } + } + + private fun loadTrips() { + viewModelScope.launch { + tripDao + .getAllTrips() + .onStart { + _uiState.update { current -> current.copy(isLoading = true) } + // Simulating network or database latency to showcase the loading animation + delay(1500L) + } + .collectLatest { trips -> + if (trips.isEmpty()) { + prepopulateDatabase() + } else { + // Sort trips naturally by timestamp + val sortedTrips = trips.sortedBy { it.endDate } + _uiState.update { current -> current.copy(trips = sortedTrips, isLoading = false) } + } + } + } + } + + private fun prepopulateDatabase() { + viewModelScope.launch { + tripDao.insertTrips(DummyData.trips) + eventDao.insertEvents(DummyData.events) + tourDetailDao.insertTourDetails(DummyData.tourDetails) + DummyData.flightDetails.forEach { eventDao.insertFlightDetail(it) } + DummyData.hotelDetails.forEach { eventDao.insertHotelDetail(it) } + DummyData.diningDetails.forEach { eventDao.insertDiningDetail(it) } + DummyData.museumDetails.forEach { eventDao.insertMuseumDetail(it) } + DummyData.expenses.forEach { expenseDao.insertExpense(it) } + DummyData.voiceNotes.forEach { eventDao.insertVoiceNote(it) } + } + } + + open fun deleteTrip(trip: Trip) { + viewModelScope.launch { + eventDao.deleteEventsForTrip(trip.id) + tripDao.deleteTrip(trip) + } + } + + open fun setPermissionGranted(granted: Boolean) { + voiceInputManager.setPermissionGranted(granted) + } + + open fun handleListenToggle(onPermissionRequired: () -> Unit) { + voiceInputManager.handleListenToggle(onPermissionRequired) + } + + open fun clearTranscriptionResult() { + voiceInputManager.clearTranscriptionResult() + } + + open fun cancelListening() { + voiceInputManager.cancelListening() + } +} diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/PhoneLayout.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/PhoneLayout.kt new file mode 100644 index 00000000..0e35b1b4 --- /dev/null +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/PhoneLayout.kt @@ -0,0 +1,220 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.home + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.plus +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalMediaQueryApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.derivedMediaQuery +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.feature.home.components.TripCard + +@OptIn(ExperimentalMediaQueryApi::class) +@Composable +fun PhoneLayout( + pastTrips: List, + paddingValues: PaddingValues, + tripToDelete: Trip?, + onTripClick: (String) -> Unit, + onSwipeToDelete: (Trip) -> Unit, + currentTrip: Trip?, + upcomingTrips: List, + onCreateClick: () -> Unit = {}, +) { + var isUpcomingExpanded by remember { mutableStateOf(true) } + var isCurrentExpanded by remember { mutableStateOf(true) } + var isPastExpanded by remember { mutableStateOf(true) } + val foldableBreakpoint by derivedMediaQuery { windowWidth >= 600.dp } + + rememberCoroutineScope() + + Box(modifier = Modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = + Modifier.fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + drawRect( + brush = + Brush.verticalGradient( + 0f to Color.Black, + 0.8f to Color.Black, + 1f to Color.Transparent, + ), + blendMode = BlendMode.DstIn, + ) + }, + contentPadding = + PaddingValues(bottom = paddingValues.calculateBottomPadding() + 208.dp) + + PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (currentTrip != null) { + stickyHeader { + SectionHeader( + title = currentTrip.title, + isExpandable = true, + isExpanded = isCurrentExpanded, + onToggle = { isCurrentExpanded = !isCurrentExpanded }, + ) + } + + item(key = "current_trip_content", span = { GridItemSpan(2) }) { + AnimatedVisibility( + visible = isCurrentExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column(modifier = Modifier.padding(bottom = 16.dp)) { + TripCard( + trip = currentTrip, + isSwipedOff = tripToDelete == currentTrip, + useHorizontalLayout = foldableBreakpoint, + showTitle = false, + onClick = { onTripClick(currentTrip.id) }, + onSwipeToDelete = { onSwipeToDelete(currentTrip) }, + ) + } + } + } + } + + if (upcomingTrips.isNotEmpty()) { + stickyHeader { + SectionHeader( + title = "Upcoming", + isExpandable = true, + isExpanded = isUpcomingExpanded, + onToggle = { isUpcomingExpanded = !isUpcomingExpanded }, + ) + } + item(key = "upcoming_trips_content", span = { GridItemSpan(2) }) { + AnimatedVisibility( + visible = isUpcomingExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column( + modifier = Modifier.padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val columns = if (foldableBreakpoint) 2 else 1 + upcomingTrips.chunked(columns).forEach { rowTrips -> + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + rowTrips.forEach { trip -> + Box(modifier = Modifier.weight(1f)) { + TripCard( + trip = trip, + isSwipedOff = tripToDelete == trip, + useHorizontalLayout = false, + onClick = { onTripClick(trip.id) }, + onSwipeToDelete = { onSwipeToDelete(trip) }, + ) + } + } + if (rowTrips.size < columns) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + } + } + + if (pastTrips.isNotEmpty()) { + stickyHeader { + SectionHeader( + title = "Past", + isExpandable = true, + isExpanded = isPastExpanded, + onToggle = { isPastExpanded = !isPastExpanded }, + ) + } + item(key = "past_trips_content", span = { GridItemSpan(2) }) { + AnimatedVisibility( + visible = isPastExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column( + modifier = Modifier.padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val columns = if (foldableBreakpoint) 2 else 1 + pastTrips.chunked(columns).forEach { rowTrips -> + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + rowTrips.forEach { trip -> + Box(modifier = Modifier.weight(1f)) { + TripCard( + trip = trip, + isSwipedOff = tripToDelete == trip, + useHorizontalLayout = false, + onClick = { onTripClick(trip.id) }, + onSwipeToDelete = { onSwipeToDelete(trip) }, + ) + } + } + if (rowTrips.size < columns) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + } + } + } + } +} diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/SectionHeader.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/SectionHeader.kt new file mode 100644 index 00000000..9b4ce4bd --- /dev/null +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/SectionHeader.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.home + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.UnfoldLess +import androidx.compose.material.icons.rounded.UnfoldMore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.ui.SekuyaFontFamily + +@Composable +fun SectionHeader( + title: String, + modifier: Modifier = Modifier, + isExpandable: Boolean = false, + isExpanded: Boolean = false, + drawBackground: Boolean = true, + contentPadding: PaddingValues = PaddingValues(horizontal = 8.dp), + onToggle: () -> Unit = {}, +) { + var stickiness by remember { mutableStateOf(0f) } + val containerColor = MaterialTheme.colorScheme.surfaceContainer + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + + val topPaddingAnimated by + animateDpAsState(if (stickiness >= 1f) statusBarHeight else 12.dp, label = "topPadding") + val bottomPaddingAnimated by + animateDpAsState(if (stickiness >= 1f) 8.dp else 12.dp, label = "bottomPadding") + + val drawBackgroundModifier = + if (drawBackground) { + Modifier.onGloballyPositioned { coordinates -> + val y = coordinates.positionInParent().y + // As y approaches 0, stickiness goes from 0 to 1. Using a 100px threshold. + stickiness = (1f - (y / 100f)).coerceIn(0f, 1f) + } + .drawBehind { + if (stickiness > 0f && drawBackground) { + // Calculate height including status bar (approximate 48dp) to bleed to top + val extraBleed = 100.dp.toPx() + drawRect( + color = containerColor, + topLeft = Offset(-extraBleed, size.height * (1f - stickiness) - extraBleed), + size = Size(size.width + (extraBleed * 2), size.height * stickiness + extraBleed), + ) + } + } + } else { + Modifier + } + + Row( + modifier = + modifier + .fillMaxWidth() + .then(drawBackgroundModifier) + .then(if (isExpandable) Modifier.clickable(onClick = onToggle) else Modifier) + .padding(contentPadding) + .padding(top = topPaddingAnimated, bottom = bottomPaddingAnimated), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = SekuyaFontFamily, + ) + + if (isExpandable) { + Box( + modifier = + Modifier.size(40.dp).border(2.dp, MaterialTheme.colorScheme.outline, CircleShape), + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = isExpanded, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "SectionHeaderIcon", + ) { expanded -> + Icon( + imageVector = if (expanded) Icons.Rounded.UnfoldLess else Icons.Rounded.UnfoldMore, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + } + } + } + } +} diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/TabletLayout.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/TabletLayout.kt new file mode 100644 index 00000000..68bb650a --- /dev/null +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/TabletLayout.kt @@ -0,0 +1,247 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.home + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.ui.components.JetPackerExtendedFloatingActionButton +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.feature.home.components.TripCard + +@Composable +fun TabletLayout( + currentTrip: Trip?, + tripToDelete: Trip?, + onTripClick: (String) -> Unit, + onSwipeToDelete: (Trip) -> Unit, + onCreateClick: () -> Unit, + pastTrips: List, + upcomingTrips: List, +) { + var isUpcomingExpanded by remember { mutableStateOf(true) } + var isCurrentExpanded by remember { mutableStateOf(true) } + var isPastExpanded by remember { mutableStateOf(true) } + + LazyColumn( + modifier = + Modifier.fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + drawRect( + brush = + Brush.verticalGradient( + 0f to Color.Black, + 0.8f to Color.Black, + 1f to Color.Transparent, + ), + blendMode = BlendMode.DstIn, + ) + }, + contentPadding = PaddingValues(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (currentTrip != null) { + stickyHeader { + var stickiness by remember { mutableStateOf(0f) } + val containerColor = MaterialTheme.colorScheme.surfaceContainer + + Row( + modifier = + Modifier.fillMaxWidth() + .onGloballyPositioned { coordinates -> + val y = coordinates.positionInParent().y + stickiness = (1f - (y / 100f)).coerceIn(0f, 1f) + } + .padding(horizontal = 128.dp) + .padding(top = 32.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SectionHeader( + modifier = + Modifier.weight(1f) + .background(containerColor.copy(alpha = stickiness), RoundedCornerShape(24.dp)), + title = currentTrip.title, + isExpandable = true, + isExpanded = isCurrentExpanded, + onToggle = { isCurrentExpanded = !isCurrentExpanded }, + drawBackground = false, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + ) + + JetPackerExtendedFloatingActionButton( + icon = Icons.Rounded.Add, + label = "Create trip", + onClick = onCreateClick, + ) + } + } + + item(key = "current_trip_content") { + AnimatedVisibility( + visible = isCurrentExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Box(modifier = Modifier.padding(horizontal = 128.dp).padding(bottom = 16.dp)) { + TripCard( + trip = currentTrip, + useHorizontalLayout = true, + isSwipedOff = tripToDelete == currentTrip, + showTitle = false, + onClick = { onTripClick(currentTrip.id) }, + onSwipeToDelete = { onSwipeToDelete(currentTrip) }, + ) + } + } + } + } + + if (upcomingTrips.isNotEmpty()) { + stickyHeader { + var stickiness by remember { mutableStateOf(0f) } + val containerColor = MaterialTheme.colorScheme.surfaceContainer + + SectionHeader( + title = "Upcoming", + isExpandable = true, + drawBackground = false, + isExpanded = isUpcomingExpanded, + onToggle = { isUpcomingExpanded = !isUpcomingExpanded }, + modifier = + Modifier.onGloballyPositioned { coordinates -> + val y = coordinates.positionInParent().y + stickiness = (1f - (y / 100f)).coerceIn(0f, 1f) + } + .padding(horizontal = 128.dp, vertical = 16.dp) + .background(containerColor.copy(alpha = stickiness), RoundedCornerShape(24.dp)), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + ) + } + item(key = "upcoming_trips_content") { + AnimatedVisibility( + visible = isUpcomingExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + LazyRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 128.dp), + ) { + itemsIndexed(upcomingTrips, key = { _, trip -> trip.id }) { _, trip -> + TripCard( + modifier = Modifier.width(504.dp), + trip = trip, + useHorizontalLayout = false, + canSwipeToDelete = false, + isSwipedOff = tripToDelete == trip, + onClick = { onTripClick(trip.id) }, + onSwipeToDelete = { onSwipeToDelete(trip) }, + ) + } + } + } + } + + if (pastTrips.isNotEmpty()) { + stickyHeader { + var stickiness by remember { mutableStateOf(0f) } + val containerColor = MaterialTheme.colorScheme.surfaceContainer + + SectionHeader( + title = "Past", + isExpandable = true, + drawBackground = false, + isExpanded = isPastExpanded, + onToggle = { isPastExpanded = !isPastExpanded }, + modifier = + Modifier.onGloballyPositioned { coordinates -> + val y = coordinates.positionInParent().y + stickiness = (1f - (y / 100f)).coerceIn(0f, 1f) + } + .padding(horizontal = 128.dp, vertical = 16.dp) + .background(containerColor.copy(alpha = stickiness), RoundedCornerShape(24.dp)), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp), + ) + } + item(key = "past_trips_content") { + AnimatedVisibility( + visible = isPastExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + LazyRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 128.dp), + ) { + itemsIndexed(pastTrips, key = { _, trip -> trip.id }) { _, trip -> + TripCard( + modifier = Modifier.width(504.dp), + trip = trip, + useHorizontalLayout = false, + canSwipeToDelete = false, + isSwipedOff = tripToDelete == trip, + onClick = { onTripClick(trip.id) }, + onSwipeToDelete = { onSwipeToDelete(trip) }, + ) + } + } + } + } + } + } + } +} diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/components/TripCard.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/components/TripCard.kt new file mode 100644 index 00000000..ad37eacb --- /dev/null +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/components/TripCard.kt @@ -0,0 +1,437 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.home.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalFlexBoxApi +import androidx.compose.foundation.layout.FlexAlignItems +import androidx.compose.foundation.layout.FlexBox +import androidx.compose.foundation.layout.FlexDirection +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ImageNotSupported +import androidx.compose.material.icons.rounded.DateRange +import androidx.compose.material.icons.rounded.LocationOn +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalMediaQueryApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.derivedMediaQuery +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.core.ui.R as CoreUiR +import com.example.jetpacker.core.ui.SekuyaFontFamily +import com.example.jetpacker.data.trips.Trip +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.abs + +@OptIn(ExperimentalMediaQueryApi::class, ExperimentalFlexBoxApi::class) +@Composable +fun TripCard( + trip: Trip, + isSwipedOff: Boolean, + modifier: Modifier = Modifier, + useHorizontalLayout: Boolean = true, + canSwipeToDelete: Boolean = true, + showTitle: Boolean = true, + onClick: () -> Unit = {}, + onSwipeToDelete: () -> Unit = {}, +) { + val dismissState = rememberSwipeToDismissBoxState() + + LaunchedEffect(dismissState.currentValue) { + if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart) { + onSwipeToDelete() + } + } + + LaunchedEffect(isSwipedOff) { + if (!isSwipedOff && dismissState.currentValue != SwipeToDismissBoxValue.Settled) { + dismissState.reset() + } + } + + SwipeToDismissBox( + modifier = modifier, + state = dismissState, + enableDismissFromStartToEnd = false, + enableDismissFromEndToStart = canSwipeToDelete, + backgroundContent = { + val color = MaterialTheme.colorScheme.errorContainer + // Fill the entire background if the item is considered swiped off/deleted to avoid gaps. + Box( + Modifier.fillMaxSize() + .clip(RoundedCornerShape(40.dp)) + .background(if (isSwipedOff) color else Color.Transparent) + ) { + val offset = + try { + dismissState.requireOffset() + } catch (_: Exception) { + 0f + } + val p = (abs(offset) / 600f).coerceIn(0f, 1f) + + if (!isSwipedOff) { + Spacer( + Modifier.align(Alignment.CenterEnd) + .padding(end = 24.dp) + .size(24.dp) + .graphicsLayer { + val s = 1f + 50f * (p * p) + scaleX = s + scaleY = s + alpha = if (p > 0.01f) 1f else 0f + } + .background(color, CircleShape) + ) + } + + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 32.dp), + ) + } + }, + ) { + val participants = + remember(trip.participants) { + trip.participants.map { name -> Participant(name = name, avatar = getAvatarForName(name)) } + } + + FlexBox( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(40.dp)) + .then(modifier) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(40.dp)) + .clickable(onClick = onClick) + .padding(16.dp), + config = { + if (useHorizontalLayout) { + direction(FlexDirection.Row) + } else { + direction(FlexDirection.Column) + } + + if (useHorizontalLayout) { + gap(32.dp) + } else { + gap(24.dp) + } + + alignItems(FlexAlignItems.Stretch) + }, + ) { + val hasImage = trip.imageUri != null || (trip.imageRes != null && trip.imageRes != 0) + if (hasImage) { + val model = trip.imageUri ?: trip.imageRes + val imageShape = RoundedCornerShape(32.dp) + val imageModifier = + if (useHorizontalLayout) { + Modifier.flex { basis(0.5f) }.aspectRatio(1.31f) + } else { + Modifier.fillMaxWidth().requiredHeight(200.dp) + } + + if (model is Int) { + Image( + painter = painterResource(id = model), + contentDescription = trip.title, + modifier = imageModifier.clip(imageShape), + contentScale = ContentScale.Crop, + ) + } else { + AsyncImage( + model = model, + contentDescription = trip.title, + modifier = imageModifier.clip(imageShape), + contentScale = ContentScale.Crop, + ) + } + } + + val tabletBreakpoint by derivedMediaQuery { windowWidth >= 1200.dp } + val spacers = if (tabletBreakpoint) 16.dp else 8.dp + + Column( + modifier = Modifier.flex { grow(1f) }, + verticalArrangement = Arrangement.spacedBy(spacers), + ) { + val titleTextStyle = + if (tabletBreakpoint) { + MaterialTheme.typography.displaySmall + } else { + MaterialTheme.typography.titleLarge + } + + if (showTitle) { + Text( + text = trip.title, + color = MaterialTheme.colorScheme.onSurface, + style = titleTextStyle, + fontFamily = SekuyaFontFamily, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + val sdf = remember { SimpleDateFormat("MMM dd", Locale.US) } + val sdfWithYear = remember { SimpleDateFormat("MMM dd, yyyy", Locale.US) } + val formattedStart = + try { + sdf.format(Date(trip.startDate)) + } catch (_: Exception) { + "" + } + val formattedEnd = + try { + sdfWithYear.format(Date(trip.endDate)) + } catch (_: Exception) { + "" + } + + TripInfoItem(icon = Icons.Rounded.DateRange, text = "$formattedStart – $formattedEnd") + + if (trip.location.isNotEmpty()) { + TripInfoItem(icon = Icons.Rounded.LocationOn, text = trip.location) + } + + if (participants.isNotEmpty()) { + Spacer(Modifier.weight(1f)) + + OverlappingParticipants(participants = participants) + } else { + Box(Modifier.requiredSize(40.dp)) + } + } + } + } +} + +@OptIn(ExperimentalMediaQueryApi::class) +@Composable +private fun TripInfoItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface( + modifier = Modifier.size(32.dp), + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + val tabletBreakpoint by derivedMediaQuery { windowWidth >= 1200.dp } + val textStyle = + if (tabletBreakpoint) { + MaterialTheme.typography.bodyLarge + } else { + MaterialTheme.typography.bodyMedium + } + + Text(text = text, style = textStyle, color = MaterialTheme.colorScheme.onSurface) + } +} + +sealed interface AvatarSource { + data class Url(val url: String) : AvatarSource + + data class Resource(val resId: Int) : AvatarSource +} + +private val participantAvatars = + mapOf( + "Alice" to AvatarSource.Resource(CoreUiR.drawable.avatar_01), + "Bob" to AvatarSource.Resource(CoreUiR.drawable.avatar_02), + "Charlie" to AvatarSource.Resource(CoreUiR.drawable.avatar_03), + "David" to AvatarSource.Resource(CoreUiR.drawable.avatar_04), + "Eva" to AvatarSource.Resource(CoreUiR.drawable.avatar_05), + "Frank" to AvatarSource.Resource(CoreUiR.drawable.avatar_06), + "Grace" to AvatarSource.Resource(CoreUiR.drawable.avatar_07), + "Hank" to AvatarSource.Resource(CoreUiR.drawable.avatar_08), + "Ivy" to AvatarSource.Resource(CoreUiR.drawable.avatar_09), + "Jack" to AvatarSource.Resource(CoreUiR.drawable.avatar_10), + ) + +private fun getAvatarForName(name: String): AvatarSource? = participantAvatars[name] + +data class Participant(val name: String, val avatar: AvatarSource? = null) + +@Composable +fun ParticipantAvatar( + participant: Participant, + modifier: Modifier = Modifier, + cutoutNext: Boolean = false, +) { + val model = + when (val avatar = participant.avatar) { + is AvatarSource.Url -> avatar.url + is AvatarSource.Resource -> avatar.resId + null -> null + } + + Box( + modifier = + modifier.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen).drawWithContent { + drawContent() + if (cutoutNext) { + val strokeWidth = 2.dp.toPx() + val avatarRadius = size.width / 2 + val overlap = 10.dp.toPx() + val nextCenter = size.width - overlap + avatarRadius + + drawCircle( + color = Color.Transparent, + radius = avatarRadius + strokeWidth, + center = Offset(nextCenter, size.height / 2), + blendMode = BlendMode.Clear, + ) + } + } + ) { + if (model != null) { + AsyncImage( + model = model, + contentDescription = participant.name, + modifier = Modifier.fillMaxSize().clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = + Modifier.fillMaxSize().background(MaterialTheme.colorScheme.secondary, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = participant.name.firstOrNull()?.uppercase() ?: "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondary, + fontWeight = FontWeight.Bold, + ) + } + } + } +} + +@Composable +fun OverlappingParticipants(participants: List, modifier: Modifier = Modifier) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy((-6).dp)) { + participants.take(5).forEachIndexed { index, participant -> + ParticipantAvatar( + participant = participant, + modifier = Modifier.requiredSize(40.dp), + cutoutNext = index < participants.size - 1 && index < 4, + ) + } + if (participants.size > 5) { + Box( + modifier = + Modifier.requiredSize(40.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, CircleShape) + .border(2.dp, MaterialTheme.colorScheme.surface, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+${participants.size - 5}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun TripCardPreview() { + JetPackerTheme { + Box(modifier = Modifier.padding(16.dp)) { + TripCard( + trip = + Trip( + id = "1", + title = "California Summer Trip", + location = "California", + startDate = System.currentTimeMillis(), + endDate = System.currentTimeMillis() + 10 * 24 * 3600 * 1000L, + imageRes = CoreUiR.drawable.img_california, + participants = listOf("John", "Jane", "Bob", "Alice"), + ), + isSwipedOff = false, + useHorizontalLayout = false, + onClick = {}, + onSwipeToDelete = {}, + ) + } + } +} diff --git a/jetpacker/android/feature/home/src/screenshotTest/kotlin/com/example/jetpacker/feature/home/HomeScreenshotTest.kt b/jetpacker/android/feature/home/src/screenshotTest/kotlin/com/example/jetpacker/feature/home/HomeScreenshotTest.kt new file mode 100644 index 00000000..8d415011 --- /dev/null +++ b/jetpacker/android/feature/home/src/screenshotTest/kotlin/com/example/jetpacker/feature/home/HomeScreenshotTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn( + androidx.compose.ui.ExperimentalMediaQueryApi::class, + androidx.compose.ui.ExperimentalComposeUiApi::class +) + +package com.example.jetpacker.feature.home + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.ComposeUiFlags +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.LocalUiMediaScope +import androidx.compose.ui.UiMediaScope +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.tools.screenshot.PreviewTest + +@OptIn(ExperimentalComposeUiApi::class) +private val initMediaQuery = run { + ComposeUiFlags.isMediaQueryIntegrationEnabled = true + true +} + +@OptIn(ExperimentalComposeUiApi::class) +class MockUiMediaScope( + override val windowWidth: Dp = 400.dp, + override val windowHeight: Dp = 800.dp, + override val windowPosture: UiMediaScope.Posture = UiMediaScope.Posture.Flat, + override val pointerPrecision: UiMediaScope.PointerPrecision = UiMediaScope.PointerPrecision.Coarse, + override val keyboardKind: UiMediaScope.KeyboardKind = UiMediaScope.KeyboardKind.Virtual, + override val hasCamera: Boolean = true, + override val hasMicrophone: Boolean = true, + override val viewingDistance: UiMediaScope.ViewingDistance = UiMediaScope.ViewingDistance.Near +) : UiMediaScope + +class HomeScreenshotTest { + @PreviewTest + @Preview(showBackground = true) + @Composable + fun LoadingStateScreenshotPreview() { + MaterialTheme { LoadingState() } + } + + @OptIn(ExperimentalComposeUiApi::class) + @PreviewTest + @Preview(showBackground = true) + @Composable + fun HomeScreenScreenshotPreview() { + CompositionLocalProvider(LocalUiMediaScope provides MockUiMediaScope()) { + HomeScreenPreview() + } + } + + @OptIn(ExperimentalComposeUiApi::class) + @PreviewTest + @Preview(showBackground = true) + @Composable + fun DebugScreenScreenshotPreview() { + val fakeViewModel = object : DebugViewModel(null, null, null, null, null) { + override fun resetDatabase() {} + } + CompositionLocalProvider(LocalUiMediaScope provides MockUiMediaScope()) { + DebugScreen(onBack = {}, viewModel = fakeViewModel) + } + } +} diff --git a/jetpacker/android/feature/home/src/screenshotTestDebug/reference/com/example/jetpacker/feature/home/HomeScreenshotTest/DebugScreenScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/home/src/screenshotTestDebug/reference/com/example/jetpacker/feature/home/HomeScreenshotTest/DebugScreenScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..47c8dde1 Binary files /dev/null and b/jetpacker/android/feature/home/src/screenshotTestDebug/reference/com/example/jetpacker/feature/home/HomeScreenshotTest/DebugScreenScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/home/src/screenshotTestDebug/reference/com/example/jetpacker/feature/home/HomeScreenshotTest/HomeScreenScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/home/src/screenshotTestDebug/reference/com/example/jetpacker/feature/home/HomeScreenshotTest/HomeScreenScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..a7d87695 Binary files /dev/null and b/jetpacker/android/feature/home/src/screenshotTestDebug/reference/com/example/jetpacker/feature/home/HomeScreenshotTest/HomeScreenScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/home/src/screenshotTestDebug/reference/com/example/jetpacker/feature/home/HomeScreenshotTest/LoadingStateScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/home/src/screenshotTestDebug/reference/com/example/jetpacker/feature/home/HomeScreenshotTest/LoadingStateScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..e8fcc3b9 Binary files /dev/null and b/jetpacker/android/feature/home/src/screenshotTestDebug/reference/com/example/jetpacker/feature/home/HomeScreenshotTest/LoadingStateScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/home/src/test/AndroidManifest.xml b/jetpacker/android/feature/home/src/test/AndroidManifest.xml new file mode 100644 index 00000000..6de63c50 --- /dev/null +++ b/jetpacker/android/feature/home/src/test/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/jetpacker/android/feature/trip/build.gradle.kts b/jetpacker/android/feature/trip/build.gradle.kts new file mode 100644 index 00000000..6c8b2dfb --- /dev/null +++ b/jetpacker/android/feature/trip/build.gradle.kts @@ -0,0 +1,80 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.trip" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + + implementation(project(":core:flags")) + implementation(project(":core:ui")) + implementation(project(":data:itinerary")) + implementation(project(":data:trips")) + implementation(project(":feature:trip:itinerary")) + implementation(project(":feature:trip:expenses")) + implementation(project(":feature:trip:voice_notes")) + + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + + debugImplementation(libs.androidx.compose.ui.tooling) + + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.robolectric) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/trip/expenses/build.gradle.kts b/jetpacker/android/feature/trip/expenses/build.gradle.kts new file mode 100644 index 00000000..bb53fd23 --- /dev/null +++ b/jetpacker/android/feature/trip/expenses/build.gradle.kts @@ -0,0 +1,74 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.expenses" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(project(":core:ui")) + implementation(project(":core:flags")) + implementation(project(":data:itinerary")) + implementation(project(":data:trips")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.mlkit.genai.prompt) + + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) + screenshotTestImplementation(project(":data:trips")) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.robolectric) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/trip/expenses/src/main/AndroidManifest.xml b/jetpacker/android/feature/trip/expenses/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/jetpacker/android/feature/trip/expenses/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesScreen.kt b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesScreen.kt new file mode 100644 index 00000000..6f681ed6 --- /dev/null +++ b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesScreen.kt @@ -0,0 +1,852 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.expenses + +import com.example.jetpacker.core.ui.components.JetPackerFabConfig +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DocumentScanner +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.ModeOfTravel +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.ShoppingCart +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxDefaults +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.asComposePath +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.graphics.shapes.toPath +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.EventColors +import com.example.jetpacker.core.ui.SekuyaFontFamily +import com.example.jetpacker.data.itinerary.Expense +import com.example.jetpacker.data.trips.DummyData +import kotlinx.coroutines.launch +import org.json.JSONObject + +object CurrencyConverter { + // Hardcoded rates for offline usage (Realistically these would be updated periodically) + private val rates = + mapOf( + "USD" to 1.0, + "INR" to 0.012, // 1 INR = 0.012 USD + "EUR" to 1.08, // 1 EUR = 1.08 USD + "GBP" to 1.27, // 1 GBP = 1.27 USD + "JPY" to 0.0067, // 1 JPY = 0.0067 USD + "KRW" to 0.00075, // 1 KRW = 0.00075 USD + "SGD" to 0.75, // 1 SGD = 0.75 USD + "CNY" to 0.14, // 1 CNY = 0.14 USD + "THB" to 0.029, // 1 THB = 0.029 USD + "VND" to 0.00004, // 1 VND = 0.00004 USD + ) + + fun convertToUsd(amount: Double, currency: String): Double { + val rate = rates[currency.uppercase()] ?: 1.0 + return amount * rate + } + + fun getSymbol(currency: String): String { + return when (currency.uppercase()) { + "USD" -> "$" + "INR" -> "₹" + "EUR" -> "€" + "GBP" -> "£" + "JPY" -> "¥" + "KRW" -> "₩" + "SGD" -> "S$" + "CNY" -> "¥" + "THB" -> "฿" + "VND" -> "₫" + else -> "$" + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ManageExpensesScreen( + modifier: Modifier = Modifier, + tripId: String = "", + contentPadding: PaddingValues, + onBack: () -> Unit = {}, + onFabConfigChange: (JetPackerFabConfig?) -> Unit = {}, + viewModel: ManageExpensesViewModel = hiltViewModel(), +) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + + val expenses by viewModel.expenses.collectAsStateWithLifecycle(initialValue = emptyList()) + + LaunchedEffect(tripId) { + if (tripId.isNotEmpty()) { + viewModel.loadForTrip(tripId) + } + } + + var budget by remember { mutableStateOf(2000.00) } + var isBudgetSheetVisible by remember { mutableStateOf(false) } + + var selectedExpense by remember { mutableStateOf(null) } + var isParsingError by remember { mutableStateOf(false) } + var isDetailSheetVisible by remember { mutableStateOf(false) } + + var expenseToDelete by remember { mutableStateOf(null) } + + val totalSpent = expenses.sumOf { CurrencyConverter.convertToUsd(it.amount, it.currency) } + + val isProcessing = remember { mutableStateOf(false) } + + val cameraLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.TakePicturePreview()) { + bitmap -> + if (bitmap != null) { + isProcessing.value = true + coroutineScope.launch { + try { + val fullResponse = viewModel.parseReceipt(bitmap) + Log.d("ManageExpensesScreen", "Receipt parsed successfully") + if (fullResponse.isNotEmpty() && !fullResponse.contains("false", ignoreCase = true)) { + val jsonString = fullResponse.substringAfter("```json").substringBefore("```").trim() + val json = JSONObject(if (jsonString.startsWith("{")) jsonString else fullResponse) + val title = json.getString("title") + val amount = json.getDouble("amount") + val currency = json.getString("currency").uppercase() + val category = json.getString("category").lowercase() + viewModel.addExpense( + Expense(title = title, amount = amount, currency = currency, category = category) + ) + } else { + isParsingError = true + selectedExpense = + Expense(title = "", amount = 0.0, currency = "USD", category = "activity") + isDetailSheetVisible = true + } + } catch (e: Exception) { + Log.e("ManageExpensesScreen", "Error using GenAI Prompt API", e) + isParsingError = true + selectedExpense = + Expense(title = "", amount = 0.0, currency = "USD", category = "activity") + isDetailSheetVisible = true + } finally { + isProcessing.value = false + } + } + } + } + + LaunchedEffect(Unit) { + onFabConfigChange( + JetPackerFabConfig( + icon = Icons.Rounded.DocumentScanner, + onClick = { + coroutineScope.launch { + if (viewModel.isSupported()) { + cameraLauncher.launch(null) + } else { + Toast.makeText(context, "Feature not available", Toast.LENGTH_SHORT).show() + } + } + }, + contentDescription = "Add Expense", + ) + ) + } + + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { + Text( + "Expenses", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = SekuyaFontFamily, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + actions = { + IconButton(onClick = {}) { + Icon( + Icons.Rounded.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + colors = + TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + scrollBehavior = scrollBehavior, + ) + }, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize().padding(innerPadding).padding(horizontal = 16.dp)) { + + // Total spent and Budget display + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val totalText = "$${"%.2f".format(totalSpent)}" + + Surface(shape = RoundedCornerShape(30.dp), color = MaterialTheme.colorScheme.surface) { + Text( + text = totalText, + style = + MaterialTheme.typography.displayLarge.copy( + fontSize = 84.sp, + fontWeight = FontWeight.Black, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + autoSize = TextAutoSize.StepBased(minFontSize = 24.sp, maxFontSize = 84.sp), + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp, end = 16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "/", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.secondary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(30.dp), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.clickable { isBudgetSheetVisible = true }, + ) { + Text( + text = "$${budget.toInt()}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + } + } + + Text( + text = "Recent expenses", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(vertical = 16.dp), + ) + + if (isProcessing.value) { + ShimmerExpenseItem() + Spacer(modifier = Modifier.height(12.dp)) + } + + LazyColumn( + modifier = + Modifier.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + drawRect( + brush = + Brush.verticalGradient( + 0f to Color.Black, + 0.8f to Color.Black, + 1f to Color.Transparent, + ), + blendMode = BlendMode.DstIn, + ) + }, + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(bottom = 100.dp), + ) { + items(expenses, key = { it.id }) { expense -> + ExpenseItem( + expense = expense, + isSwipedOff = expenseToDelete == expense, + onClick = { + isParsingError = false + selectedExpense = expense + isDetailSheetVisible = true + }, + onDelete = { expenseToDelete = expense }, + ) + } + } + } + } + + if (expenseToDelete != null) { + DeleteExpenseSheet( + expense = expenseToDelete!!, + onConfirm = { + viewModel.deleteExpense(expenseToDelete!!) + expenseToDelete = null + }, + onDismiss = { expenseToDelete = null }, + ) + } + + if (isBudgetSheetVisible) { + BudgetEditSheet( + initialBudget = budget, + onDismiss = { isBudgetSheetVisible = false }, + onSave = { budget = it }, + ) + } + + if (isDetailSheetVisible && selectedExpense != null) { + ExpenseDetailSheet( + expense = selectedExpense!!, + isParsingError = isParsingError, + onDismiss = { isDetailSheetVisible = false }, + onSave = { updatedExpense -> + viewModel.addExpense(updatedExpense) + isDetailSheetVisible = false + }, + ) + } + } +} + +@Composable +fun ShimmerExpenseItem() { + val shimmerColors = + listOf( + Color.LightGray.copy(alpha = 0.6f), + Color.LightGray.copy(alpha = 0.2f), + Color.LightGray.copy(alpha = 0.6f), + ) + + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim = + transition.animateFloat( + initialValue = 0f, + targetValue = 2000f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmer_translate", + ) + + val brush = + Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim.value - 500f, translateAnim.value - 500f), + end = Offset(translateAnim.value, translateAnim.value), + ) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(50.dp).clip(CircleShape).background(brush)) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Spacer( + modifier = + Modifier.height(16.dp) + .fillMaxWidth(0.7f) + .clip(RoundedCornerShape(4.dp)) + .background(brush) + ) + Spacer(modifier = Modifier.height(8.dp)) + Spacer( + modifier = + Modifier.height(12.dp) + .fillMaxWidth(0.4f) + .clip(RoundedCornerShape(4.dp)) + .background(brush) + ) + } + + Column(horizontalAlignment = Alignment.End) { + Spacer( + modifier = + Modifier.height(16.dp).width(64.dp).clip(RoundedCornerShape(4.dp)).background(brush) + ) + Spacer(modifier = Modifier.height(8.dp)) + Spacer( + modifier = + Modifier.height(12.dp).width(48.dp).clip(RoundedCornerShape(4.dp)).background(brush) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ExpenseItem( + expense: Expense, + isSwipedOff: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + val (icon, color) = + when (expense.category.lowercase()) { + "travel" -> Icons.Rounded.ModeOfTravel to EventColors.Flight + "food" -> Icons.Rounded.Restaurant to EventColors.Food + "shopping" -> Icons.Rounded.ShoppingCart to EventColors.Shopping + "entertainment" -> Icons.Rounded.Star to EventColors.Entertainment + "hotel" -> Icons.Rounded.Hotel to EventColors.Hotel + else -> Icons.Rounded.ConfirmationNumber to EventColors.Activity + } + + val dismissState = + rememberSwipeToDismissBoxState( + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxDefaults.positionalThreshold, + ) + + LaunchedEffect(dismissState.currentValue) { + if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart) { + onDelete() + } + } + + LaunchedEffect(isSwipedOff) { + if (!isSwipedOff && dismissState.currentValue != SwipeToDismissBoxValue.Settled) { + dismissState.snapTo(SwipeToDismissBoxValue.Settled) + } + } + + SwipeToDismissBox( + state = dismissState, + modifier = modifier, + enableDismissFromStartToEnd = false, + backgroundContent = { + val backgroundColor = + when (dismissState.dismissDirection) { + SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.errorContainer + else -> Color.Transparent + } + + Box( + modifier = + Modifier.fillMaxSize().clip(RoundedCornerShape(12.dp)).background(backgroundColor), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(end = 16.dp), + ) + } + }, + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + onClick = onClick, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(50.dp), contentAlignment = Alignment.Center) { + val shape = MaterialShapes.SoftBoom + Box( + modifier = + Modifier.fillMaxSize().drawWithCache { + val path = shape.toPath().asComposePath() + val matrix = Matrix() + matrix.scale(size.width, size.height) + path.transform(matrix) + onDrawBehind { drawPath(path, color = color.content) } + } + ) + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.surface, + modifier = Modifier.size(16.dp), + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = expense.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = expense.category.lowercase(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(horizontalAlignment = Alignment.End) { + Text( + text = + "${CurrencyConverter.getSymbol(expense.currency)}${"%.2f".format(expense.amount)}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeleteExpenseSheet(expense: Expense, onConfirm: () -> Unit, onDismiss: () -> Unit) { + var isConfirmChecked by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp).padding(bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier.size(80.dp) + .background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(36.dp), + ) + } + + Text( + "Delete Expense?", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + "Are you sure you want to delete '${expense.title}' for ${CurrencyConverter.getSymbol(expense.currency)}${"%.2f".format(expense.amount)}? This action cannot be undone.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier.clickable { isConfirmChecked = !isConfirmChecked } + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isConfirmChecked, + + onCheckedChange = { isConfirmChecked = it } + ) + Text( + "I am sure I want to delete this expense.", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton( + colors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ), + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) { + Text("Cancel") + } + Button( + onClick = onConfirm, + enabled = isConfirmChecked, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + shape = MaterialTheme.shapes.medium, + ) { + Text("Delete") + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExpenseDetailSheet( + expense: Expense, + isParsingError: Boolean = false, + onDismiss: () -> Unit, + onSave: (Expense) -> Unit, +) { + var title by remember { mutableStateOf(expense.title) } + var amountText by remember { + mutableStateOf(if (expense.amount == 0.0) "" else expense.amount.toString()) + } + var category by remember { mutableStateOf(expense.category) } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp, horizontal = 24.dp)) { + Text( + "Expense Details", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = if (isParsingError) 8.dp else 16.dp), + ) + if (isParsingError) { + Text( + text = "Expense processing failed - enter fields manually", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = amountText, + onValueChange = { amountText = it }, + label = { Text("Amount") }, + prefix = { Text(CurrencyConverter.getSymbol(expense.currency)) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = category, + onValueChange = { category = it }, + label = { Text("Category") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { + val newAmount = amountText.toDoubleOrNull() ?: expense.amount + onSave(expense.copy(title = title, amount = newAmount, category = category.lowercase())) + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text("Save Changes") + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BudgetEditSheet(initialBudget: Double, onDismiss: () -> Unit, onSave: (Double) -> Unit) { + var text by remember { mutableStateOf(initialBudget.toInt().toString()) } + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp).padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Set Trip Budget", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text("Budget Amount") }, + prefix = { Text("$") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + onSave(text.toDoubleOrNull() ?: initialBudget) + onDismiss() + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text("Save Budget") + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ShimmerExpenseItemPreview() { + MaterialTheme { ShimmerExpenseItem() } +} + +@Preview(showBackground = true) +@Composable +fun ExpenseItemPreview() { + MaterialTheme { + ExpenseItem( + expense = DummyData.expenses.first(), + isSwipedOff = false, + onClick = {}, + onDelete = {}, + ) + } +} diff --git a/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModel.kt b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModel.kt new file mode 100644 index 00000000..6a5c312f --- /dev/null +++ b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModel.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.expenses + +import android.graphics.Bitmap +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.Expense +import com.example.jetpacker.data.itinerary.ExpenseDao +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch + +@HiltViewModel +class ManageExpensesViewModel +@Inject +constructor( + private val savedStateHandle: SavedStateHandle, + private val receiptParser: ReceiptParser, + private val expenseDao: ExpenseDao, +) : ViewModel() { + + private val _tripIdFlow = MutableStateFlow(savedStateHandle.get("tripId") ?: "") + internal val tripId: String get() = _tripIdFlow.value + + fun loadForTrip(id: String) { + if (id.isNotEmpty() && id != _tripIdFlow.value) { + _tripIdFlow.value = id + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val expenses: Flow> = _tripIdFlow.flatMapLatest { id -> expenseDao.getExpensesForTrip(id) } + + suspend fun isSupported(): Boolean { + return receiptParser.isSupported() + } + + suspend fun parseReceipt(bitmap: Bitmap): String { + return receiptParser.parseReceipt(bitmap) + } + + fun addExpense(expense: Expense) { + viewModelScope.launch { + val expenseWithTripId = + if (expense.tripId.isEmpty()) expense.copy(tripId = tripId) else expense + expenseDao.insertExpense(expenseWithTripId) + } + } + + fun deleteExpense(expense: Expense) { + viewModelScope.launch { expenseDao.deleteExpense(expense) } + } +} diff --git a/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ReceiptParser.kt b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ReceiptParser.kt new file mode 100644 index 00000000..f5d57355 --- /dev/null +++ b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ReceiptParser.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.expenses + +import android.graphics.Bitmap + +interface ReceiptParser { + suspend fun parseReceipt(bitmap: Bitmap): String + + suspend fun isSupported(): Boolean +} diff --git a/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ReceiptParserImpl.kt b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ReceiptParserImpl.kt new file mode 100644 index 00000000..c8d8487c --- /dev/null +++ b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ReceiptParserImpl.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.expenses + +import android.graphics.Bitmap +import android.util.Log +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.ImagePart +import com.google.mlkit.genai.prompt.ModelPreference +import com.google.mlkit.genai.prompt.ModelReleaseStage +import com.google.mlkit.genai.prompt.TextPart +import com.google.mlkit.genai.prompt.generateContentRequest +import com.google.mlkit.genai.prompt.generationConfig +import com.google.mlkit.genai.prompt.modelConfig +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ReceiptParserImpl @Inject constructor() : ReceiptParser { + private val generativeModel by lazy { + val previewFastConfig = generationConfig { + modelConfig = modelConfig { + releaseStage = ModelReleaseStage.PREVIEW + preference = ModelPreference.FULL + } + } + Generation.getClient(previewFastConfig) + } + + override suspend fun isSupported(): Boolean { + return try { + var status = generativeModel.checkStatus() + if (status == 1) { // 1 is DOWNLOADABLE + Log.d("ReceiptParser", "Model is downloadable. Triggering download...") + generativeModel.download().collect { Log.d("ReceiptParser", "Downloading progress...") } + status = generativeModel.checkStatus() + } + status == 3 // 3 is AVAILABLE + } catch (e: GenAiException) { + if (e.errorCode == 606) { + Log.w("ReceiptParser", "Feature not found, disabling.", e) + false + } else { + throw e + } + } + } + + override suspend fun parseReceipt(bitmap: Bitmap): String { + val prompt = + "Determine if the image looks like a type of expense or receipt. " + + "If it is not an expense or receipt, return false. " + + "Otherwise parse the image for the following information and return it in JSON format: " + + "1. title: Generate a title for the expense less than 6 words. The title should be based on the name of the restaurant or activity. The title should not include the word receipt." + + "2. amount: Total amount of the entire expense. Look for values at the bottom of the receipt and words like total or balance due. Do not include any currency signs." + + "3. currency: The ISO 4217 currency code of the amount (e.g., USD, INR, EUR, GBP, JPY, SGD). If not found, default to USD." + + "4. category: Type of expense, whether it's travel, food, activity, shopping, entertainment, or other " + + "The JSON should follow this structure: {\"title\": \"...\", \"amount\": 0.0, \"currency\": \"...\", \"category\": \"...\"}" + + val request = generateContentRequest(ImagePart(bitmap), TextPart(prompt)) {} + + var fullResponse = "" + try { + generativeModel.generateContentStream(request).collect { response -> + val text = response.candidates.firstOrNull()?.text ?: "" + fullResponse += text + } + } catch (e: Exception) { + Log.e("ReceiptParser", "Error using GenAI Prompt API", e) + } + return fullResponse + } +} diff --git a/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ReceiptParserModule.kt b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ReceiptParserModule.kt new file mode 100644 index 00000000..28eb72a5 --- /dev/null +++ b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ReceiptParserModule.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.expenses + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class ReceiptParserModule { + @Binds abstract fun bindReceiptParser(impl: ReceiptParserImpl): ReceiptParser +} diff --git a/jetpacker/android/feature/trip/expenses/src/screenshotTest/kotlin/com/example/jetpacker/feature/expenses/ExpensesScreenshotTest.kt b/jetpacker/android/feature/trip/expenses/src/screenshotTest/kotlin/com/example/jetpacker/feature/expenses/ExpensesScreenshotTest.kt new file mode 100644 index 00000000..0d99e43f --- /dev/null +++ b/jetpacker/android/feature/trip/expenses/src/screenshotTest/kotlin/com/example/jetpacker/feature/expenses/ExpensesScreenshotTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.expenses + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest + +class ExpensesScreenshotTest { + @PreviewTest + @Preview(showBackground = true) + @Composable + fun ShimmerExpenseScreenshotPreview() { + ShimmerExpenseItemPreview() + } + + @PreviewTest + @Preview(showBackground = true) + @Composable + fun ExpenseScreenshotPreview() { + ExpenseItemPreview() + } +} diff --git a/jetpacker/android/feature/trip/expenses/src/screenshotTestDebug/reference/com/example/jetpacker/feature/expenses/ExpensesScreenshotTest/ExpenseScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/trip/expenses/src/screenshotTestDebug/reference/com/example/jetpacker/feature/expenses/ExpensesScreenshotTest/ExpenseScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..0dc9e85f Binary files /dev/null and b/jetpacker/android/feature/trip/expenses/src/screenshotTestDebug/reference/com/example/jetpacker/feature/expenses/ExpensesScreenshotTest/ExpenseScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/trip/expenses/src/screenshotTestDebug/reference/com/example/jetpacker/feature/expenses/ExpensesScreenshotTest/ShimmerExpenseScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/trip/expenses/src/screenshotTestDebug/reference/com/example/jetpacker/feature/expenses/ExpensesScreenshotTest/ShimmerExpenseScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..039ce6f3 Binary files /dev/null and b/jetpacker/android/feature/trip/expenses/src/screenshotTestDebug/reference/com/example/jetpacker/feature/expenses/ExpensesScreenshotTest/ShimmerExpenseScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/trip/expenses/src/test/AndroidManifest.xml b/jetpacker/android/feature/trip/expenses/src/test/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/jetpacker/android/feature/trip/expenses/src/test/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/jetpacker/android/feature/trip/itinerary/build.gradle.kts b/jetpacker/android/feature/trip/itinerary/build.gradle.kts new file mode 100644 index 00000000..d22acd2f --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/build.gradle.kts @@ -0,0 +1,90 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.itinerary" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + + implementation(project(":core:flags")) + implementation(project(":core:speech")) + implementation(project(":core:ui")) + implementation(project(":data:itinerary")) + implementation(project(":data:trips")) + implementation(project(":feature:trip:itinerary:enrichment")) + + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.mlkit.genai.prompt) + implementation(libs.mlkit.genai.speech) + implementation(libs.mlkit.translate) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.mlkit.language.id) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.ai) + implementation(libs.firebase.ai.ondevice) + implementation(libs.firebase.auth.ktx) + implementation(libs.firebase.firestore) + + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) + + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.google.truth) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/build.gradle.kts b/jetpacker/android/feature/trip/itinerary/enrichment/build.gradle.kts new file mode 100644 index 00000000..9b5ce7e3 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/build.gradle.kts @@ -0,0 +1,74 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.itinerary_enrichment" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(project(":core:ui")) + implementation(project(":core:flags")) + implementation(project(":data:itinerary")) + implementation(project(":data:trips")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.mlkit.genai.prompt) + + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.robolectric) + testImplementation(libs.google.truth) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/AndroidManifest.xml b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DailyThemeModule.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DailyThemeModule.kt new file mode 100644 index 00000000..fbd9603a --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DailyThemeModule.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary_enrichment + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class DailyThemeModule { + @Binds abstract fun bindDailyThemeProvider(impl: DailyThemeProviderImpl): DailyThemeProvider +} diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DailyThemeProvider.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DailyThemeProvider.kt new file mode 100644 index 00000000..3741daa3 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DailyThemeProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary_enrichment + +import com.example.jetpacker.data.itinerary.TimelineEvent + +interface DailyThemeProvider { + suspend fun generateDailyThemes(events: List): List +} diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DailyThemeProviderImpl.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DailyThemeProviderImpl.kt new file mode 100644 index 00000000..d75ccb57 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DailyThemeProviderImpl.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary_enrichment + +import android.util.Log +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.ModelPreference +import com.google.mlkit.genai.prompt.ModelReleaseStage +import com.google.mlkit.genai.prompt.TextPart +import com.google.mlkit.genai.prompt.generateContentRequest +import com.google.mlkit.genai.prompt.generationConfig +import com.google.mlkit.genai.prompt.modelConfig +import java.time.Instant +import java.time.ZoneId +import javax.inject.Inject +import javax.inject.Singleton +import org.json.JSONArray +import org.json.JSONObject + +@Singleton +class DailyThemeProviderImpl @Inject constructor() : DailyThemeProvider { + private val generativeModel by lazy { + val previewFastConfig = generationConfig { + modelConfig = modelConfig { + releaseStage = ModelReleaseStage.PREVIEW + preference = ModelPreference.FAST + } + } + Generation.getClient(previewFastConfig) + } + + override suspend fun generateDailyThemes(events: List): List { + var status = + try { + generativeModel.checkStatus() + } catch (e: GenAiException) { + if (e.errorCode == 606) { + Log.w("DailyTheme", "Feature 646 not found in generateDailyThemes, disabling.", e) + return emptyList() + } else { + throw e + } + } + if (status == 1) { // 1 is DOWNLOADABLE + Log.d("DailyTheme", "Model is downloadable. Triggering download...") + generativeModel.download().collect { Log.d("DailyTheme", "Downloading progress...") } + status = generativeModel.checkStatus() + } + + if (status != 3) { // 3 is AVAILABLE + Log.e("DailyTheme", "Model is not available (status: $status). Skipping theme generation.") + return emptyList() + } + + val prompt = + """ + Generate a short theme (max 3 words) for each day of the trip. + Respond ONLY with pairs separated by a pipe character. Do NOT output JSON. + Format example: + 2026-05-17|Arrival & Welcoming + 2026-05-18|Sightseeing Tour + + Current Trip Itinerary: + ${events.joinToString("\n") { "- ${Instant.ofEpochMilli(it.timestamp).atZone(ZoneId.systemDefault()).toLocalDate()} / ${it.title} in ${it.location}" }} + """ + .trimIndent() + + return try { + var responseText = "" + var retryCount = 0 + val maxRetries = 3 + + while (retryCount < maxRetries) { + try { + val request = generateContentRequest(TextPart(prompt)) {} + val response = generativeModel.generateContent(request) + responseText = response.candidates.firstOrNull()?.text ?: "" + if (responseText.isNotEmpty()) break + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + if (e.message?.contains("ErrorCode 9") == true || e.message?.contains("BUSY") == true) { + retryCount++ + Log.w("DailyTheme", "AI Core is busy, retrying ($retryCount/$maxRetries)...") + kotlinx.coroutines.delay(1000L * retryCount) + } else { + throw e + } + } + } + + if (responseText.isEmpty()) return emptyList() + + Log.d("DailyTheme", "Raw LLM output: $responseText") + + val themes = mutableListOf() + val uniqueDates = + events + .map { + Instant.ofEpochMilli(it.timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() + } + .distinct() + .sorted() + + val extractedLines = + responseText.lines().flatMap { it.split("|") }.map { it.trim() }.filter { it.isNotEmpty() } + + var dateIndex = 0 + extractedLines.forEach { part -> + if (part.matches(Regex("""\d{4}-\d{2}-\d{2}"""))) { + // It's a standalone date from broken split, skip it + } else if (part.contains("|")) { + val split = part.split("|") + if (split.size == 2) { + themes.add(DayThemeItem(split[0].trim(), split[1].trim())) + } + } else if (dateIndex < uniqueDates.size) { + val cleanTheme = part.replace(Regex("""^\d{4}-\d{2}-\d{2}[^\w]*"""), "").trim() + if (cleanTheme.isNotEmpty()) { + themes.add(DayThemeItem(uniqueDates[dateIndex], cleanTheme)) + dateIndex++ + } + } + } + Log.d("DailyTheme", "Successfully extracted ${themes.size} themes") + themes + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e("DailyTheme", "Theme generation process failed", e) + emptyList() + } + } +} diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DayThemeItem.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DayThemeItem.kt new file mode 100644 index 00000000..2854568e --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/DayThemeItem.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary_enrichment + +data class DayThemeItem(val day: String, val theme: String) diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsCard.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsCard.kt new file mode 100644 index 00000000..39a69c03 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsCard.kt @@ -0,0 +1,355 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn( + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3Api::class, +) + +package com.example.jetpacker.feature.itinerary_enrichment + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.example.jetpacker.data.trips.DummyData +import com.example.jetpacker.data.trips.Trip +import java.time.ZoneId +import java.time.ZonedDateTime + +@Composable +fun TripSummaryAndTipsCard(modifier: Modifier = Modifier, isLoading: Boolean, summary: String?, trip: Trip? = null) { + val isLoaded = !isLoading && summary != null + var expanded by remember { mutableStateOf(false) } + var containerSize by remember { mutableStateOf(IntSize.Zero) } + var contentHeight by remember { mutableStateOf(0) } + + val density = LocalDensity.current + val maxHeight = 120.dp + val maxHeightPx = with(density) { maxHeight.toPx() } + val isScrollable = isLoaded && contentHeight > maxHeightPx + + val infiniteTransition = rememberInfiniteTransition(label = "glow_transition") + val rotation by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 4000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "glow_rotation", + ) + + val glowBrush = + remember(rotation, containerSize) { + val width = containerSize.width.toFloat() + if (width <= 0f) + return@remember Brush.linearGradient(listOf(Color.Transparent, Color.Transparent)) + + val xOffset = width * (rotation / 360f) + Brush.linearGradient( + colors = + listOf( + Color(0xFF9CEFFF), // Cyan + Color(0xFFD2E4FF), // Blue + Color(0xFFFFB59F), // Pink + ), + start = Offset(xOffset - width, 0f), + end = Offset(xOffset, 0f), + tileMode = TileMode.Mirror, + ) + } + + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + .onSizeChanged { containerSize = it } + .dropShadow(RoundedCornerShape(24.dp)) { + if (isLoaded) { + brush = glowBrush + radius = 24.dp.toPx() + offset = Offset(x = 0f, y = 16.dp.toPx()) + } + } + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(24.dp)) + .border( + 2.dp, + if (isLoaded) Color.Transparent else Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(24.dp), + ) + .clip(RoundedCornerShape(24.dp)) + .animateContentSize() + .clickable(enabled = isLoaded) { expanded = !expanded } + ) { + Box( + modifier = + Modifier.fillMaxWidth() + .then(if (!expanded && isScrollable) Modifier.height(maxHeight) else Modifier) + .graphicsLayer(clip = true) + ) { + Row( + modifier = + Modifier.fillMaxWidth() + .wrapContentHeight(unbounded = true, align = Alignment.Top) + .onSizeChanged { contentHeight = it.height } + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + val shape = MaterialShapes.Gem + val primary = MaterialTheme.colorScheme.primary + val secondary = MaterialTheme.colorScheme.secondary + val tertiary = MaterialTheme.colorScheme.tertiary + + Box( + modifier = + Modifier.size(50.dp) + .background( + brush = + Brush.linearGradient( + colors = listOf(primary, secondary, tertiary) + ), + shape = shape.toShape(), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Rounded.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondary, + modifier = Modifier.size(24.dp), + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = getTripMessage(trip), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(4.dp)) + + if (isLoading) { + ShimmerEffect() + } else if (summary != null) { + Text( + text = parseMarkdownToAnnotatedString(summary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (expanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = "Add more events to your itinerary to generate trip summaries and tips!", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + } + } + + if (isLoaded) { + Box( + modifier = + Modifier.size(24.dp) + .background(MaterialTheme.colorScheme.surfaceContainerLowest, CircleShape), + contentAlignment = Alignment.Center, + ) { + val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "rotation") + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.graphicsLayer { rotationZ = rotation }, + ) + } + } + } + + if (!expanded && isScrollable) { + Box( + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .height(40.dp) + .background( + Brush.verticalGradient( + listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceBright) + ) + ) + ) + } + } + } +} + +@Composable +fun ShimmerEffect() { + val shimmerColors = + listOf( + Color.LightGray.copy(alpha = 0.6f), + Color.LightGray.copy(alpha = 0.2f), + Color.LightGray.copy(alpha = 0.6f), + ) + + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim by + transition.animateFloat( + initialValue = 0f, + targetValue = 2000f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmer_translate", + ) + + val brush = + Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim - 500f, translateAnim - 500f), + end = Offset(translateAnim, translateAnim), + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Spacer( + modifier = + Modifier.height(14.dp).fillMaxWidth().clip(RoundedCornerShape(4.dp)).background(brush) + ) + Spacer( + modifier = + Modifier.height(14.dp).fillMaxWidth(0.9f).clip(RoundedCornerShape(4.dp)).background(brush) + ) + Spacer( + modifier = + Modifier.height(14.dp).fillMaxWidth(0.7f).clip(RoundedCornerShape(4.dp)).background(brush) + ) + } +} + +fun parseMarkdownToAnnotatedString(text: String): AnnotatedString { + return buildAnnotatedString { + val regex = Regex("""\*\*(.*?)\*\*|\*(?!\s)(.*?)(? + append(text.substring(lastIndex, matchResult.range.first)) + + val boldGroup = matchResult.groups[1] + val italicGroup = matchResult.groups[2] + + if (boldGroup != null) { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { append(boldGroup.value) } + } else if (italicGroup != null) { + withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) { append(italicGroup.value) } + } + lastIndex = matchResult.range.last + 1 + } + if (lastIndex < text.length) { + append(text.substring(lastIndex)) + } + } +} + +fun getTripMessage(trip: Trip?): String { + // val now = System.currentTimeMillis() + // to test for May 20, 2026, 12:15pm Pacific time, time of I/O live talk + val now = ZonedDateTime.of( + 2026, 5, 20, 12, 15, 0, 0, ZoneId + .of("America/Los_Angeles")).toInstant().toEpochMilli() + if (trip == null) return "Get ready for your trip" + return when { + now > trip.endDate -> "Trip summary" + now in trip.startDate..trip.endDate -> "Tips for the next few days" + else -> "Get ready for your trip" + } +} + +@Preview(showBackground = true) +@Composable +fun TripSummaryAndTipsCardPreview() { + MaterialTheme { + TripSummaryAndTipsCard( + isLoading = false, + summary = "Your trip is looking **amazing**! Get ready for a perfect balance of exploration and relaxation.", + trip = DummyData.trips.first() + ) + } +} + +@Preview(showBackground = true) +@Composable +fun TripSummaryAndTipsCardLoadingPreview() { + MaterialTheme { TripSummaryAndTipsCard(isLoading = true, summary = null, trip = DummyData.trips.first()) } +} diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsModule.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsModule.kt new file mode 100644 index 00000000..7f29451d --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsModule.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary_enrichment + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class TripSummaryAndTipsModule { + @Binds abstract fun bindTripSummaryAndTipsProvider(impl: TripSummaryAndTipsProviderImpl): TripSummaryAndTipsProvider +} diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsProvider.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsProvider.kt new file mode 100644 index 00000000..10d6c2e6 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsProvider.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary_enrichment + +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.data.trips.Trip +import kotlinx.coroutines.flow.Flow + +interface TripSummaryAndTipsProvider { + suspend fun isSupported(): Boolean + + fun generateTripSummaryAndTips(events: List, trip: Trip): Flow + + fun generateTripSummary(events: List, trip: Trip, voiceNotes: List): Flow + + fun generateUpcomingTip(events: List, trip: Trip): Flow +} diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsProviderImpl.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsProviderImpl.kt new file mode 100644 index 00000000..50424adb --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsProviderImpl.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary_enrichment + +import android.util.Log +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.data.trips.Trip +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.ModelPreference +import com.google.mlkit.genai.prompt.ModelReleaseStage +import com.google.mlkit.genai.prompt.generationConfig +import com.google.mlkit.genai.prompt.modelConfig +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +private enum class TripStatus { + PAST, + CURRENT, + UPCOMING, +} + +@Singleton +class TripSummaryAndTipsProviderImpl @Inject constructor() : TripSummaryAndTipsProvider { + private val generativeModel by lazy { + val previewFastConfig = generationConfig { + modelConfig = modelConfig { + releaseStage = ModelReleaseStage.PREVIEW + // Fast model is not good at if/else instructions for whether or not to generate 'phrases + // to learn' in prompt. + preference = ModelPreference.FAST + } + } + Generation.getClient(previewFastConfig) + } + + override suspend fun isSupported(): Boolean { + return try { + val status = generativeModel.checkStatus() + status != 0 // 0 is UNAVAILABLE + } catch (e: GenAiException) { + if (e.errorCode == 606) { + Log.w("TripSummaryAndTips", "Feature 646 not found, disabling trip summary and tips.", e) + false + } else { + throw e + } + } + } + + override fun generateTripSummaryAndTips(events: List, trip: Trip): Flow = flow { + var status = + try { + generativeModel.checkStatus() + } catch (e: GenAiException) { + if (e.errorCode == 606) { + Log.w("TripSummaryAndTips", "Feature 646 not found in generateTripSummaryAndTips, disabling.", e) + emit("Trip summaries and tips are not available on this device.") + return@flow + } else { + throw e + } + } + if (status == 1) { // 1 is DOWNLOADABLE + Log.d("TripSummaryAndTips", "Model is downloadable. Triggering download...") + generativeModel.download().collect { Log.d("TripSummaryAndTips", "Downloading progress...") } + Log.d("TripSummaryAndTips", "Download flow completed. Re-checking status...") + status = generativeModel.checkStatus() + } + + if (status != 3) { // 3 is AVAILABLE + Log.e("TripSummaryAndTips", "Model is not available (status: $status). Skipping trip summary and tips.") + emit("Model is downloading or unavailable. Please try again later.") + return@flow + } + + val prompt = + "Given this itinerary for a trip: $events, " + + "generate the following: " + + "overall trip summary in 1 sentence less than 15 words without a bolded title, " + + "1 tip on how to prepare for this trip with a bolded title \"Tip\"" + + val promptWithShortPhrases = + "Given this itinerary for a trip: $events, " + + "generate the following: " + + "overall trip summary in 1 sentence less than 15 words without a bolded title, " + + "1 tip on how to prepare for this trip with a bolded title \"Tip\", " + + "2 common short phrases to learn for the trip, with a bolded title \"Useful Phrases\". Format each strictly as '* Foreign Phrase - English Translation' (e.g., '* Bonjour - Hello')." + + val countriesWithEnglishLocale = listOf("USA", "Ireland", "UK", "Australia") + val location = trip.location + + val selectedPrompt = + if (countriesWithEnglishLocale.any { location.contains(it) }) { + prompt + } else { + promptWithShortPhrases + } + + try { + generativeModel.generateContentStream(selectedPrompt).collect { chunk -> + val textChunk = chunk.candidates.firstOrNull()?.text ?: "" + emit(textChunk) + } + } catch (e: Exception) { + Log.e("TripSummaryAndTips", "Generation stream failed", e) + throw e + } + } + + override fun generateTripSummary( + events: List, + trip: Trip, + voiceNotes: List, + ): Flow = flow { + var status = + try { + generativeModel.checkStatus() + } catch (e: GenAiException) { + if (e.errorCode == 606) { + emit("Trip summaries and tips are not available on this device.") + return@flow + } else { + throw e + } + } + if (status == 1) { + generativeModel.download().collect {} + status = generativeModel.checkStatus() + } + if (status != 3) { + emit("Model is downloading or unavailable. Please try again later.") + return@flow + } + + val prompt = + "Given these voice notes for a trip: $voiceNotes, use a reminiscent tone to" + + " generate a short 2 sentence summary of the highlights. Only return the summary." + try { + generativeModel.generateContentStream(prompt).collect { chunk -> + val textChunk = chunk.candidates.firstOrNull()?.text ?: "" + emit(textChunk) + } + } catch (e: Exception) { + throw e + } + } + + override fun generateUpcomingTip(events: List, trip: Trip): Flow = flow { + var status = + try { + generativeModel.checkStatus() + } catch (e: GenAiException) { + if (e.errorCode == 606) { + emit("Trip summaries and tips are not available on this device.") + return@flow + } else { + throw e + } + } + if (status == 1) { + generativeModel.download().collect {} + status = generativeModel.checkStatus() + } + if (status != 3) { + emit("Model is downloading or unavailable. Please try again later.") + return@flow + } + + val prompt = + "Given this itinerary for the upcoming events of a trip: $events, " + + "generate 2 short tips. " + + "return only the 2 short tips." + try { + generativeModel.generateContentStream(prompt).collect { chunk -> + val textChunk = chunk.candidates.firstOrNull()?.text ?: "" + emit(textChunk) + } + } catch (e: Exception) { + throw e + } + } +} diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/screenshotTest/kotlin/com/example/jetpacker/feature/itinerary_enrichment/EnrichmentScreenshotTest.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/screenshotTest/kotlin/com/example/jetpacker/feature/itinerary_enrichment/EnrichmentScreenshotTest.kt new file mode 100644 index 00000000..6c318e13 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/screenshotTest/kotlin/com/example/jetpacker/feature/itinerary_enrichment/EnrichmentScreenshotTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary_enrichment + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest + +class EnrichmentScreenshotTest { + @PreviewTest + @Preview(showBackground = true) + @Composable + fun TripSummaryAndTipsScreenshotPreview() { + TripSummaryAndTipsCardPreview() + } + + @PreviewTest + @Preview(showBackground = true) + @Composable + fun TripSummaryAndTipsLoadingScreenshotPreview() { + TripSummaryAndTipsCardLoadingPreview() + } +} diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary_enrichment/EnrichmentScreenshotTest/TripSummaryAndTipsLoadingScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/trip/itinerary/enrichment/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary_enrichment/EnrichmentScreenshotTest/TripSummaryAndTipsLoadingScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..1cc6a7a2 Binary files /dev/null and b/jetpacker/android/feature/trip/itinerary/enrichment/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary_enrichment/EnrichmentScreenshotTest/TripSummaryAndTipsLoadingScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary_enrichment/EnrichmentScreenshotTest/TripSummaryAndTipsScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/trip/itinerary/enrichment/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary_enrichment/EnrichmentScreenshotTest/TripSummaryAndTipsScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..a2e4770a Binary files /dev/null and b/jetpacker/android/feature/trip/itinerary/enrichment/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary_enrichment/EnrichmentScreenshotTest/TripSummaryAndTipsScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/test/AndroidManifest.xml b/jetpacker/android/feature/trip/itinerary/enrichment/src/test/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/test/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/test/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsCardTest.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/test/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsCardTest.kt new file mode 100644 index 00000000..2a327637 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/test/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsCardTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary_enrichment + +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import com.example.jetpacker.data.trips.Trip +import com.google.common.truth.Truth.assertThat +import java.time.ZoneId +import java.time.ZonedDateTime +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class TripSummaryAndTipsCardTest { + + @Test + fun parseMarkdownToAnnotatedString_boldText_isBold() { + val text = "This is **bold** text" + val result = parseMarkdownToAnnotatedString(text) + + assertThat(result.text).isEqualTo("This is bold text") + val span = result.spanStyles.find { it.item.fontWeight == FontWeight.Bold } + assertThat(span).isNotNull() + assertThat(span?.start).isEqualTo(8) + assertThat(span?.end).isEqualTo(12) + } + + @Test + fun parseMarkdownToAnnotatedString_italicText_isItalic() { + val text = "This is *italic* text" + val result = parseMarkdownToAnnotatedString(text) + + assertThat(result.text).isEqualTo("This is italic text") + val span = result.spanStyles.find { it.item.fontStyle == FontStyle.Italic } + assertThat(span).isNotNull() + assertThat(span?.start).isEqualTo(8) + assertThat(span?.end).isEqualTo(14) + } + + @Test + fun parseMarkdownToAnnotatedString_bothBoldAndItalic_areParsed() { + val text = "This is **bold** and *italic* text" + val result = parseMarkdownToAnnotatedString(text) + + assertThat(result.text).isEqualTo("This is bold and italic text") + + val boldSpan = result.spanStyles.find { it.item.fontWeight == FontWeight.Bold } + assertThat(boldSpan).isNotNull() + assertThat(boldSpan?.start).isEqualTo(8) + assertThat(boldSpan?.end).isEqualTo(12) + + val italicSpan = result.spanStyles.find { it.item.fontStyle == FontStyle.Italic } + assertThat(italicSpan).isNotNull() + assertThat(italicSpan?.start).isEqualTo(17) + assertThat(italicSpan?.end).isEqualTo(23) + } + + @Test + fun parseMarkdownToAnnotatedString_bulletPointAndBold_isHandledCorrectly() { + val text = "* **Name**" + val result = parseMarkdownToAnnotatedString(text) + + assertThat(result.text).isEqualTo("* Name") + val boldSpan = result.spanStyles.find { it.item.fontWeight == FontWeight.Bold } + assertThat(boldSpan).isNotNull() + assertThat(boldSpan?.start).isEqualTo(2) + assertThat(boldSpan?.end).isEqualTo(6) + + val italicSpan = result.spanStyles.find { it.item.fontStyle == FontStyle.Italic } + assertThat(italicSpan).isNull() + } + + @Test + fun parseMarkdownToAnnotatedString_noFormatting_returnsPlainText() { + val text = "This is plain text" + val result = parseMarkdownToAnnotatedString(text) + + assertThat(result.text).isEqualTo("This is plain text") + assertThat(result.spanStyles).isEmpty() + } + + @Test + fun getTripMessage_nullTrip_returnsDefault() { + val result = getTripMessage(null) + assertThat(result).isEqualTo("Get ready for your trip") + } + + @Test + fun getTripMessage_upcomingTrip_returnsDefault() { + val now = + ZonedDateTime.of( + 2026, + 5, + 20, + 12, + 15, + 0, + 0, + ZoneId.of("America/Los_Angeles"), + ) + .toInstant() + .toEpochMilli() + val trip = + Trip( + id = "1", + title = "Paris", + location = "France", + startDate = now + 100000, + endDate = now + 200000, + ) + val result = getTripMessage(trip) + assertThat(result).isEqualTo("Get ready for your trip") + } + + @Test + fun getTripMessage_currentTrip_returnsEnjoy() { + val now = + ZonedDateTime.of( + 2026, + 5, + 20, + 12, + 15, + 0, + 0, + ZoneId.of("America/Los_Angeles"), + ) + .toInstant() + .toEpochMilli() + val trip = + Trip( + id = "1", + title = "Paris", + location = "France", + startDate = now - 100000, + endDate = now + 100000, + ) + val result = getTripMessage(trip) + assertThat(result).isEqualTo("Tips for the next few days") + } + + @Test + fun getTripMessage_pastTrip_returnsHope() { + val now = + ZonedDateTime.of( + 2026, + 5, + 20, + 12, + 15, + 0, + 0, + ZoneId.of("America/Los_Angeles"), + ) + .toInstant() + .toEpochMilli() + val trip = + Trip( + id = "1", + title = "Paris", + location = "France", + startDate = now - 200000, + endDate = now - 100000, + ) + val result = getTripMessage(trip) + assertThat(result).isEqualTo("Trip summary") + } +} diff --git a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryScreen.kt b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryScreen.kt new file mode 100644 index 00000000..21792c1c --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryScreen.kt @@ -0,0 +1,885 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn( + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3Api::class, +) + +package com.example.jetpacker.feature.itinerary + +import com.example.jetpacker.core.ui.components.JetPackerFabConfig +import com.example.jetpacker.feature.itinerary_enrichment.DayThemeItem +import com.example.jetpacker.feature.itinerary_enrichment.TripSummaryAndTipsCard +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.Wallet +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxDefaults +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalMediaQueryApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.core.ui.EventColors +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.core.ui.R +import com.example.jetpacker.core.ui.SekuyaFontFamily +import com.example.jetpacker.core.ui.TimelineAudioBorderBlue +import com.example.jetpacker.core.ui.TimelineNodeBorderGreen +import com.example.jetpacker.core.ui.components.JetPackerFab +import com.example.jetpacker.core.ui.components.JetPackerToolbar +import com.example.jetpacker.core.ui.components.JetPackerToolbarAction +import com.example.jetpacker.feature.itinerary.components.AddEventDialog +import com.example.jetpacker.feature.itinerary.components.DatePickerModal +import com.example.jetpacker.feature.itinerary.components.DeleteEventSheet +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.trips.Trip +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle +import java.util.Locale + +/** + * Composable screen displaying a list of events (flights, hotels, activities) in a chronological + * timeline format. Includes swipe-to-dismiss deletion, inline audio notes, and floating action + * button triggers for editing and creation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItineraryScreen( + modifier: Modifier = Modifier, + tripId: String = "", + contentPadding: PaddingValues = PaddingValues(0.dp), + onBack: () -> Unit = {}, + onEditTripClick: () -> Unit = {}, + onEventClick: (String, EventType) -> Unit = { _, _ -> }, + onFabConfigChange: (JetPackerFabConfig?) -> Unit = {}, + onNavigateToDebug: () -> Unit = {}, + viewModel: ItineraryViewModel = hiltViewModel(), +) { + LocalContext.current + + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val fabContainerColor = MaterialTheme.colorScheme.secondary + val fabContentColor = MaterialTheme.colorScheme.onSecondary + + var eventToDelete by remember { mutableStateOf(null) } + + LaunchedEffect(tripId) { + if (tripId.isNotEmpty()) { + viewModel.loadForTrip(tripId) + } + } + + LaunchedEffect(Unit) { + onFabConfigChange( + JetPackerFabConfig( + icon = Icons.Rounded.Add, + onClick = { viewModel.showAddEvent() }, + contentDescription = "Add Event", + containerColor = fabContainerColor, + contentColor = fabContentColor, + ) + ) + } + + if (uiState.showAddEventDialog) { + AddEventDialog( + onDismiss = { viewModel.hideAddEvent() }, + onSave = { type, title, location, time, extraFields -> + viewModel.addEvent(type, title, location, time, extraFields) + }, + ) + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar( + title = { + Column { + Text( + uiState.trip?.title?.uppercase() ?: "ITINERARY", + style = + MaterialTheme.typography.titleLarge.copy( + fontSize = 22.sp, + fontFamily = SekuyaFontFamily, + ), + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + autoSize = TextAutoSize.StepBased(minFontSize = 12.sp, maxFontSize = 22.sp), + ) + + uiState.trip?.location?.let { location -> + Text( + location, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = onEditTripClick) { + Icon(Icons.Rounded.Edit, contentDescription = "Edit Trip") + } + }, + scrollBehavior = scrollBehavior, + colors = + TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primary), + ) + }, + containerColor = Color.Transparent, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize()) { + AnimatedContent( + targetState = uiState.items.isEmpty() && !uiState.isGenerating, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "itinerary_content", + ) { isEmpty -> + if (isEmpty) { + EmptyItineraryView( + modifier = Modifier.fillMaxSize().padding(innerPadding), + ) + } else { + val expandedHeaders = + remember(uiState.items) { + val states = mutableStateMapOf() + val referenceDate = + FeatureFlags.OVERRIDE_CURRENT_TIME_MILLIS?.let { + Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()).toLocalDate() + } ?: LocalDate.now() + uiState.items.filterIsInstance().forEach { header -> + val localDate = + try { + LocalDate.parse(header.date) + } catch (_: Exception) { + null + } + val isPast = localDate?.isBefore(referenceDate) ?: false + states[header.date] = !isPast + } + if (states.values.none { it }) { + states.keys.forEach { states[it] = true } + } + states + } + + val groupedItems = + remember(uiState.items) { + val result = mutableListOf>>() + var currentHeader: ItineraryUiListItem.Header? = null + var currentEvents = mutableListOf() + + uiState.items.forEach { item -> + when (item) { + is ItineraryUiListItem.Header -> { + currentHeader?.let { result.add(it to currentEvents) } + currentHeader = item + currentEvents = mutableListOf() + } + is ItineraryUiListItem.Event -> { + currentEvents.add(item.event) + } + } + } + currentHeader?.let { result.add(it to currentEvents) } + result + } + + LazyColumn( + modifier = + Modifier.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()) + .drawWithContent { + drawContent() + drawRect( + brush = + Brush.verticalGradient( + 0f to Color.Black, + 0.8f to Color.Black, + 1f to Color.Transparent, + ), + blendMode = BlendMode.DstIn, + ) + }, + contentPadding = PaddingValues(bottom = innerPadding.calculateBottomPadding() + 120.dp), + ) { + val hasImage = + uiState.trip?.imageUri != null || + (uiState.trip?.imageRes != null && uiState.trip?.imageRes != 0) + if (hasImage) { + item(key = "header_image") { + HeaderImage(trip = uiState.trip, modifier = Modifier.animateItem()) + } + } + + if (uiState.isTripSummaryAndTipsSupported && FeatureFlags.ENABLE_ITINERARY_ENRICHMENT) { + item(key = "trip_summary_and_tips") { + TripSummaryAndTipsCard( + modifier = Modifier.animateItem().padding(bottom = 8.dp), + isLoading = + uiState.isTripSummaryAndTipsLoading || (uiState.isGenerating && uiState.items.isEmpty()), + summary = uiState.tripSummaryAndTips, + trip = uiState.trip, + ) + } + } + + if (uiState.isGenerating && uiState.items.isEmpty()) { + items(3, key = { "shimmer_$it" }) { + TimelineShimmerItem(modifier = Modifier.animateItem()) + } + } + + groupedItems.forEach { (header, events) -> + val isExpanded = expandedHeaders[header.date] ?: true + item(key = header.date) { + DayHeader( + modifier = Modifier.animateItem(), + date = header.date, + dayNumber = header.dayNumber, + totalDays = header.totalDays, + isExpanded = isExpanded, + onToggle = { expandedHeaders[header.date] = !isExpanded }, + ) + } + + if (isExpanded) { + items(events, key = { it.id }) { event -> + TimelineItem( + modifier = Modifier.animateItem(), + event = event, + isLastInDay = event == events.last(), + isSwipedOff = eventToDelete == event, + onClick = { onEventClick(event.id, event.type) }, + onDelete = { eventToDelete = event }, + ) + } + } + } + } + } + } + } + } + + if (eventToDelete != null) { + DeleteEventSheet( + event = eventToDelete!!, + onConfirm = { + viewModel.deleteEvent(eventToDelete!!) + eventToDelete = null + }, + onDismiss = { eventToDelete = null }, + ) + } +} + + +@Composable +fun EmptyItineraryView( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Rounded.Flight, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Your itinerary is empty", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Start adding events using the + button below.", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun DayHeader( + modifier: Modifier = Modifier, + date: String, + dayNumber: Int, + totalDays: Int, + isExpanded: Boolean, + onToggle: () -> Unit, +) { + val localDate = + try { + LocalDate.parse(date) + } catch (_: Exception) { + null + } + val dayOfWeek = localDate?.dayOfWeek?.getDisplayName(TextStyle.FULL, Locale.US) ?: date + val monthDay = localDate?.format(DateTimeFormatter.ofPattern("MMM d", Locale.US)) ?: "" + + Column( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Day $dayNumber/$totalDays", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary, + ) + + Spacer(Modifier.width(4.dp)) + + Text( + text = "•", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.width(4.dp)) + + Text( + text = dayOfWeek, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.width(4.dp)) + + Text( + text = "•", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.width(4.dp)) + + Text( + text = monthDay, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + + Box( + modifier = + Modifier.size(24.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + val rotation by animateFloatAsState(if (isExpanded) 180f else 0f, label = "rotation") + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp).graphicsLayer { rotationZ = rotation }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimelineItem( + modifier: Modifier = Modifier, + event: TimelineEvent, + isLastInDay: Boolean, + isSwipedOff: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + Row( + modifier = + modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp).height(IntrinsicSize.Max), + verticalAlignment = Alignment.Top, + ) { + // Left timeline column + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(24.dp).fillMaxHeight(), + ) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Box( + modifier = + Modifier.size(16.dp) + .border(4.dp, TimelineNodeBorderGreen, CircleShape) + .padding(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + ) + } + + if (!isLastInDay) { + DashedLine(modifier = Modifier.weight(1f)) + } else { + Spacer(modifier = Modifier.height(32.dp)) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + val dismissState = + rememberSwipeToDismissBoxState( + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxDefaults.positionalThreshold, + ) + + LaunchedEffect(dismissState.currentValue) { + if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart) { + onDelete() + } + } + + LaunchedEffect(isSwipedOff) { + if (!isSwipedOff && dismissState.currentValue != SwipeToDismissBoxValue.Settled) { + dismissState.snapTo(SwipeToDismissBoxValue.Settled) + } + } + + SwipeToDismissBox( + state = dismissState, + modifier = Modifier.weight(1f), + enableDismissFromStartToEnd = false, + backgroundContent = { + val color = + when (dismissState.dismissDirection) { + SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.errorContainer + else -> Color.Transparent + } + + Box( + modifier = + Modifier.fillMaxSize() + .padding(bottom = 24.dp) + .clip(MaterialTheme.shapes.medium) + .background(color), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(end = 16.dp), + ) + } + }, + ) { + TimelineItemCard( + event = event, + onClick = onClick, + ) + } + } +} + +/** + * Card layout representing a timeline event inside the itinerary list. + */ +@Composable +fun TimelineItemCard( + event: TimelineEvent, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth().padding(bottom = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + val (tagText, icon, eventColors) = + when (event.type) { + EventType.TRANSPORTATION -> + Triple("TRANSIT", Icons.Rounded.Flight, EventColors.Flight) + EventType.ACCOMMODATION -> Triple("STAY", Icons.Rounded.Hotel, EventColors.Hotel) + EventType.FOOD_AND_DRINK -> Triple("EATS", Icons.Rounded.Restaurant, EventColors.Food) + EventType.CAR_RENTAL -> Triple("RENTAL", Icons.Rounded.Event, EventColors.Activity) + EventType.ACTIVITY -> Triple("ACTIVITY", Icons.Rounded.Star, EventColors.Activity) + EventType.CULTURE -> Triple("CULTURE", Icons.Rounded.Star, EventColors.Museum) + EventType.WORK -> Triple("WORK", Icons.Rounded.Event, EventColors.Activity) + } + + Box( + modifier = + Modifier.size(50.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondary, MaterialShapes.Burst.toShape()), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondary, + modifier = Modifier.size(18.dp), + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + val tagColor = MaterialTheme.colorScheme.surface + val tagTextColor = eventColors.content + + Text( + text = tagText, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = tagTextColor, + modifier = + Modifier.background(tagColor, RoundedCornerShape(16.dp)) + .border(1.dp, tagTextColor, RoundedCornerShape(16.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = event.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + + event.description?.let { text -> + if (text.isNotBlank()) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + event.audioNotes.forEach { extract -> + if (extract.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + border = BorderStroke(1.dp, TimelineAudioBorderBlue), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "“$extract”", + style = + MaterialTheme.typography.bodySmall.copy( + fontStyle = FontStyle.Italic, + letterSpacing = 0.4.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(8.dp), + ) + } + } + } + } + } + } +} + +@Composable +fun TimelineShimmerItem(modifier: Modifier = Modifier) { + Row( + modifier = + modifier.fillMaxWidth().padding(start = 16.dp, end = 24.dp).height(IntrinsicSize.Max), + verticalAlignment = Alignment.Top, + ) { + // Left timeline column + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(24.dp).fillMaxHeight(), + ) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Box( + modifier = + Modifier.size(16.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.outlineVariant), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier.size(6.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surface) + ) + } + } + + DashedLine(modifier = Modifier.weight(1f)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Content column + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.elevatedCardColors(), + ) { + Column(modifier = Modifier.padding(24.dp)) { + val brush = rememberShimmerBrush() + + Box( + modifier = + Modifier.size(width = 80.dp, height = 24.dp) + .clip(RoundedCornerShape(8.dp)) + .background(brush) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = + Modifier.fillMaxWidth(0.6f) + .height(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(brush) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = + Modifier.fillMaxWidth().height(16.dp).clip(RoundedCornerShape(4.dp)).background(brush) + ) + } + } + } +} + +@Composable +fun rememberShimmerBrush(): Brush { + val shimmerColors = + listOf( + Color.LightGray.copy(alpha = 0.6f), + Color.LightGray.copy(alpha = 0.2f), + Color.LightGray.copy(alpha = 0.6f), + ) + + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim = + transition.animateFloat( + initialValue = 0f, + targetValue = 2000f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmer_translate", + ) + + return Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim.value - 500f, translateAnim.value - 500f), + end = Offset(translateAnim.value, translateAnim.value), + ) +} + +@Composable +fun DashedLine(modifier: Modifier = Modifier) { + val dashedLineColor = MaterialTheme.colorScheme.onSurface + + Canvas(modifier = modifier.width(2.dp)) { + val dashLength = 12.dp.toPx() + val gapLength = 2.dp.toPx() + drawLine( + color = dashedLineColor, + start = Offset(size.width / 2, 0f), + end = Offset(size.width / 2, size.height), + strokeWidth = 2.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashLength, gapLength), 0f), + ) + } +} + +/** + * Header image card for the trip itinerary list. + */ +@Composable +fun HeaderImage( + trip: Trip?, + modifier: Modifier = Modifier, +) { + val model = trip?.imageUri ?: trip?.imageRes + Box(modifier = modifier.fillMaxWidth().height(240.dp)) { + AsyncImage( + model = model, + contentDescription = null, + modifier = + Modifier.padding(horizontal = 24.dp, vertical = 8.dp) + .fillMaxSize() + .clip(RoundedCornerShape(40.dp)), + contentScale = ContentScale.Crop, + ) + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +fun ItineraryScreenPreview() { + JetPackerTheme { ItineraryScreen() } +} diff --git a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryUiState.kt b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryUiState.kt new file mode 100644 index 00000000..e345a3f8 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryUiState.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary + +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.trips.Trip + +sealed class ItineraryUiListItem { + data class Header( + val date: String, + val theme: String?, + val dayNumber: Int = 0, + val totalDays: Int = 0, + ) : ItineraryUiListItem() + + data class Event(val event: TimelineEvent) : ItineraryUiListItem() +} + +data class ItineraryUiState( + val trip: Trip? = null, + val items: List = emptyList(), + val isGenerating: Boolean = false, + val showAddEventDialog: Boolean = false, + val isTripSummaryAndTipsSupported: Boolean = true, + val isTripSummaryAndTipsLoading: Boolean = false, + val tripSummaryAndTips: String? = null, + val showEditTripDialog: Boolean = false, +) diff --git a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryViewModel.kt b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryViewModel.kt new file mode 100644 index 00000000..c29361cd --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryViewModel.kt @@ -0,0 +1,429 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary + +import com.example.jetpacker.feature.itinerary_enrichment.DailyThemeProvider +import com.example.jetpacker.feature.itinerary_enrichment.DayThemeItem +import com.example.jetpacker.feature.itinerary_enrichment.TripSummaryAndTipsProvider +import android.annotation.SuppressLint +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.data.itinerary.ActivityDetail +import com.example.jetpacker.data.itinerary.DayTheme +import com.example.jetpacker.data.itinerary.DayThemeDao +import com.example.jetpacker.data.itinerary.DiningDetail +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.FlightDetail +import com.example.jetpacker.data.itinerary.HotelDetail +import com.example.jetpacker.data.itinerary.MuseumDetail +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TourDetail +import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.BufferedReader +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject + +/** + * ViewModel managing the UI state and interactions for a trip's itinerary. + * Supports loading itinerary events, adding new events (flights, hotels, dining), + * deleting events, and modifying trip details. + */ +@HiltViewModel +@SuppressLint("GlobalCoroutineDispatchers") +open class ItineraryViewModel +@Inject +constructor( + savedStateHandle: SavedStateHandle, + private val eventDao: EventDao, + private val tourDetailDao: TourDetailDao, + private val dayThemeDao: DayThemeDao, + private val tripDao: TripDao, + private val tripSummaryAndTipsProvider: TripSummaryAndTipsProvider, + private val dailyThemeProvider: DailyThemeProvider, +) : ViewModel() { + private val _uiState = MutableStateFlow(ItineraryUiState()) + open val uiState: StateFlow = _uiState.asStateFlow() + + private var tripId: String = savedStateHandle["tripId"] ?: "" + + private var isGeneratingThemes = false + private var attemptedThemeDates = emptySet() + + init { + loadEvents() + } + + fun loadForTrip(id: String) { + if (id.isEmpty() || (id == tripId && _uiState.value.items.isNotEmpty())) return + tripId = id + loadEvents() + } + + private fun loadEvents() { + viewModelScope.launch { + if (tripId.isNotEmpty()) { + combine( + eventDao.getEventsForTrip(tripId), + dayThemeDao.getThemesForTrip(tripId), + tripDao.getTripById(tripId), + ) { events, themes, trip -> + val themesMap = themes.associateBy { it.date } + val groupedEvents = + events + .groupBy { + Instant.ofEpochMilli(it.timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() + } + .toSortedMap() + + val uiItems = mutableListOf() + val totalDays = groupedEvents.size + groupedEvents.onEachIndexed { index, (date, eventList) -> + val theme = themesMap[date]?.theme + uiItems.add( + ItineraryUiListItem.Header( + date = date, + theme = theme, + dayNumber = index + 1, + totalDays = totalDays, + ) + ) + uiItems.addAll(eventList.map { ItineraryUiListItem.Event(it) }) + } + + Triple(uiItems, events, trip) + } + .collectLatest { (items, events, trip) -> + _uiState.update { it.copy(items = items, trip = trip) } + if (events.isNotEmpty() && trip != null) { + if (FeatureFlags.ENABLE_ITINERARY_ENRICHMENT) { + if (!trip.tripSummaryAndTips.isNullOrBlank()) { + _uiState.update { + it.copy(tripSummaryAndTips = trip.tripSummaryAndTips, isTripSummaryAndTipsSupported = true) + } + } else if ( + _uiState.value.tripSummaryAndTips == null && !_uiState.value.isTripSummaryAndTipsLoading + ) { + // val now = System.currentTimeMillis() + // to test for May 20, 2026, 12:15pm Pacific time, time of I/O live talk + val now = + ZonedDateTime.of( + 2026, + 5, + 20, + 12, + 15, + 0, + 0, + ZoneId.of("America/Los_Angeles"), + ) + .toInstant() + .toEpochMilli() + when { + trip.startDate > now -> generateTripSummaryAndTips(events, trip) + now > trip.endDate -> generateTripSummary(events, trip) + else -> { + val upcomingEvents = events.filter { it.timestamp > now } + generateUpcomingTip(upcomingEvents, trip) + } + } + } + + val headers = items.filterIsInstance() + val missingThemeDates = + headers.filter { it.theme.isNullOrBlank() }.map { it.date }.toSet() + + if ( + missingThemeDates.isNotEmpty() && + !attemptedThemeDates.containsAll(missingThemeDates) && + !isGeneratingThemes + ) { + // Stagger to avoid concurrent AI Core requests + kotlinx.coroutines.delay(500) + attemptedThemeDates = attemptedThemeDates + missingThemeDates + generateDailyThemes(events) + } + } + } else { + _uiState.update { it.copy(tripSummaryAndTips = null) } + } + } + } else { + _uiState.update { it.copy(items = emptyList(), tripSummaryAndTips = null) } + } + } + } + + private fun generateTripSummaryAndTips(events: List, trip: Trip) { + viewModelScope.launch { + if (!tripSummaryAndTipsProvider.isSupported()) { + _uiState.update { it.copy(isTripSummaryAndTipsSupported = false) } + return@launch + } + + _uiState.update { it.copy(isTripSummaryAndTipsLoading = true) } + try { + var fullSummary = "" + tripSummaryAndTipsProvider.generateTripSummaryAndTips(events, trip).collect { chunk -> + fullSummary += chunk + _uiState.update { state -> + state.copy( + tripSummaryAndTips = fullSummary, + isTripSummaryAndTipsSupported = true, + isTripSummaryAndTipsLoading = false, + ) + } + } + if (fullSummary.isNotEmpty()) { + tripDao.insertTrip(trip.copy(tripSummaryAndTips = fullSummary)) + } + } catch (e: Exception) { + // Fallback or unsupported state + _uiState.update { it.copy(isTripSummaryAndTipsSupported = false) } + } finally { + _uiState.update { it.copy(isTripSummaryAndTipsLoading = false) } + } + } + } + + private fun generateTripSummary(events: List, trip: Trip) { + viewModelScope.launch { + if (!tripSummaryAndTipsProvider.isSupported()) { + _uiState.update { it.copy(isTripSummaryAndTipsSupported = false) } + return@launch + } + + _uiState.update { it.copy(isTripSummaryAndTipsLoading = true) } + try { + val voiceNotes = eventDao.getVoiceNotesForTrip(trip.id).first() + var fullSummary = "" + tripSummaryAndTipsProvider.generateTripSummary(events, trip, voiceNotes).collect { chunk -> + fullSummary += chunk + _uiState.update { state -> + state.copy( + tripSummaryAndTips = fullSummary, + isTripSummaryAndTipsSupported = true, + isTripSummaryAndTipsLoading = false, + ) + } + } + if (fullSummary.isNotEmpty()) { + tripDao.insertTrip(trip.copy(tripSummaryAndTips = fullSummary)) + } + } catch (e: Exception) { + _uiState.update { it.copy(isTripSummaryAndTipsSupported = false) } + } finally { + _uiState.update { it.copy(isTripSummaryAndTipsLoading = false) } + } + } + } + + private fun generateUpcomingTip(events: List, trip: Trip) { + viewModelScope.launch { + if (!tripSummaryAndTipsProvider.isSupported()) { + _uiState.update { it.copy(isTripSummaryAndTipsSupported = false) } + return@launch + } + + _uiState.update { it.copy(isTripSummaryAndTipsLoading = true) } + try { + var fullSummary = "" + tripSummaryAndTipsProvider.generateUpcomingTip(events, trip).collect { chunk -> + fullSummary += chunk + _uiState.update { state -> + state.copy( + tripSummaryAndTips = fullSummary, + isTripSummaryAndTipsSupported = true, + isTripSummaryAndTipsLoading = false, + ) + } + } + if (fullSummary.isNotEmpty()) { + tripDao.insertTrip(trip.copy(tripSummaryAndTips = fullSummary)) + } + } catch (e: Exception) { + _uiState.update { it.copy(isTripSummaryAndTipsSupported = false) } + } finally { + _uiState.update { it.copy(isTripSummaryAndTipsLoading = false) } + } + } + } + + private fun generateDailyThemes(events: List) { + isGeneratingThemes = true + viewModelScope.launch { + try { + val themes = dailyThemeProvider.generateDailyThemes(events) + if (themes.isNotEmpty()) { + val dayThemes = themes.map { themeItem -> + DayTheme( + id = UUID.randomUUID().toString(), + tripId = tripId, + date = themeItem.day.trim(), + theme = themeItem.theme.trim(), + ) + } + dayThemeDao.insertThemes(dayThemes) + } + } catch (e: Exception) { + Log.e("ItineraryViewModel", "Failed to generate daily themes", e) + } finally { + isGeneratingThemes = false + } + } + } + + open fun generateRecommendations() { + Log.d("ItineraryViewModel", "Cloud recommendations generation is disabled in basic release.") + } + + open fun showAddEvent() { + _uiState.update { it.copy(showAddEventDialog = true) } + } + + open fun hideAddEvent() { + _uiState.update { it.copy(showAddEventDialog = false) } + } + + fun showEditTrip() { + _uiState.update { it.copy(showEditTripDialog = true) } + } + + fun hideEditTrip() { + _uiState.update { it.copy(showEditTripDialog = false) } + } + + open fun addEvent( + type: EventType, + title: String, + location: String, + time: String, + extraFields: Map, + ) { + viewModelScope.launch { + val eventId = UUID.randomUUID().toString() + val newEvent = + TimelineEvent( + id = eventId, + tripId = tripId, + type = type, + timestamp = System.currentTimeMillis(), // Mock timestamp for now + title = title.ifEmpty { "New Event" }, + location = location.ifEmpty { "Unknown Location" }, + description = null, + extraInfo = null, + imageResList = emptyList(), + ) + if (tripId.isNotEmpty()) { + eventDao.insertEvent(newEvent) + + when (type) { + EventType.TRANSPORTATION -> { + val detail = + FlightDetail( + eventId = eventId, + airline = extraFields["airline"] ?: "", + flightNum = extraFields["flightNum"] ?: "", + origin = "Origin", + destination = "Destination", + ) + eventDao.insertFlightDetail(detail) + } + EventType.ACCOMMODATION -> { + val detail = + HotelDetail( + eventId = eventId, + name = extraFields["hotelName"] ?: "", + address = location, + checkInTime = System.currentTimeMillis(), + checkOutTime = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1), + ) + eventDao.insertHotelDetail(detail) + } + EventType.FOOD_AND_DRINK -> { + val detail = + DiningDetail( + eventId = eventId, + restaurantName = extraFields["restaurantName"] ?: "", + address = location, + reservationTime = System.currentTimeMillis(), + partySize = DEFAULT_PARTY_SIZE, + ) + eventDao.insertDiningDetail(detail) + } + else -> {} + } + } + _uiState.update { it.copy(showAddEventDialog = false) } + } + } + + open fun deleteEvent(event: TimelineEvent) { + viewModelScope.launch { eventDao.deleteEvent(event) } + } + + fun updateTripDetails( + title: String, + location: String, + startDate: Long, + endDate: Long, + participants: List, + ) { + viewModelScope.launch { + val currentTrip = _uiState.value.trip ?: return@launch + val updatedTrip = + currentTrip.copy( + title = title, + location = location, + startDate = startDate, + endDate = endDate, + participants = participants, + ) + tripDao.insertTrip(updatedTrip) + } + } + + companion object { + private const val DEFAULT_PARTY_SIZE = 2 + } +} diff --git a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/AddEventDialog.kt b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/AddEventDialog.kt new file mode 100644 index 00000000..62ac748a --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/AddEventDialog.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.jetpacker.data.itinerary.EventType + +/** + * Dialog for adding a new event to the itinerary. + * Supports dynamic fields based on selected [EventType]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddEventDialog( + onDismiss: () -> Unit, + onSave: + ( + type: EventType, + title: String, + location: String, + time: String, + extraFields: Map, + ) -> Unit, +) { + var type by remember { mutableStateOf(EventType.ACTIVITY) } + var title by remember { mutableStateOf("") } + var location by remember { mutableStateOf("") } + var time by remember { mutableStateOf("") } + + // Extra fields based on type + var airline by remember { mutableStateOf("") } + var flightNum by remember { mutableStateOf("") } + var hotelName by remember { mutableStateOf("") } + var restaurantName by remember { mutableStateOf("") } + + var expanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Event") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) { + OutlinedTextField( + value = type.name, + onValueChange = {}, + readOnly = true, + label = { Text("Event Type") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled = true), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + EventType.values().forEach { eventType -> + DropdownMenuItem( + text = { Text(eventType.name) }, + onClick = { + type = eventType + expanded = false + }, + ) + } + } + } + + OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") }) + OutlinedTextField( + value = location, + onValueChange = { location = it }, + label = { Text("Location") }, + ) + OutlinedTextField( + value = time, + onValueChange = { time = it }, + label = { Text("Time (e.g. 10:00 AM)") }, + ) + + // Dynamic fields + when (type) { + EventType.TRANSPORTATION -> { + OutlinedTextField( + value = airline, + onValueChange = { airline = it }, + label = { Text("Airline") }, + ) + OutlinedTextField( + value = flightNum, + onValueChange = { flightNum = it }, + label = { Text("Flight Number") }, + ) + } + EventType.ACCOMMODATION -> { + OutlinedTextField( + value = hotelName, + onValueChange = { hotelName = it }, + label = { Text("Hotel Name") }, + ) + } + EventType.FOOD_AND_DRINK -> { + OutlinedTextField( + value = restaurantName, + onValueChange = { restaurantName = it }, + label = { Text("Restaurant Name") }, + ) + } + else -> {} + } + } + }, + confirmButton = { + TextButton( + onClick = { + val extraFields = mutableMapOf() + when (type) { + EventType.TRANSPORTATION -> { + extraFields["airline"] = airline + extraFields["flightNum"] = flightNum + } + EventType.ACCOMMODATION -> { + extraFields["hotelName"] = hotelName + } + EventType.FOOD_AND_DRINK -> { + extraFields["restaurantName"] = restaurantName + } + else -> {} + } + onSave(type, title, location, time, extraFields) + onDismiss() + } + ) { + Text("Save") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/DatePickerModal.kt b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/DatePickerModal.kt new file mode 100644 index 00000000..f840a66d --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/DatePickerModal.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary.components + +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import com.example.jetpacker.core.flags.FeatureFlags +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +/** + * Modal dialog wrapping Material3 DatePicker. + * Relies on [FeatureFlags.OVERRIDE_CURRENT_TIME_MILLIS] to enforce bounds. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DatePickerModal(onDateSelected: (Long?) -> Unit, onDismiss: () -> Unit) { + val datePickerState = + rememberDatePickerState( + selectableDates = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + val todayUtc = + FeatureFlags.OVERRIDE_CURRENT_TIME_MILLIS?.let { + Instant.ofEpochMilli(it) + .atZone(ZoneOffset.UTC) + .toLocalDate() + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + } + ?: LocalDate.now(ZoneOffset.UTC) + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli() + return utcTimeMillis >= todayUtc + } + } + ) + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onDateSelected(datePickerState.selectedDateMillis) + onDismiss() + } + ) { + Text("OK") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) { + DatePicker(state = datePickerState) + } +} diff --git a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/DeleteEventSheet.kt b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/DeleteEventSheet.kt new file mode 100644 index 00000000..2a1f0e8a --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/DeleteEventSheet.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.jetpacker.data.itinerary.TimelineEvent + +/** + * Bottom sheet to confirm deletion of a [TimelineEvent]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeleteEventSheet( + event: TimelineEvent, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + var isConfirmChecked by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp).padding(bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier.size(80.dp) + .background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(36.dp), + ) + } + + Text( + "Delete Event?", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + "Are you sure you want to delete '${event.title}'? This action cannot be undone.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier.clickable { isConfirmChecked = !isConfirmChecked } + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isConfirmChecked, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.error, + checkmarkColor = MaterialTheme.colorScheme.onError, + ), + onCheckedChange = { isConfirmChecked = it } + ) + Text( + "I am sure I want to delete this event.", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton( + colors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + onClick = onDismiss, + modifier = Modifier.weight(1f) + ) { + Text("Cancel") + } + Button( + onClick = onConfirm, + enabled = isConfirmChecked, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + shape = MaterialTheme.shapes.medium, + ) { + Text("Delete") + } + } + } + } +} diff --git a/jetpacker/android/feature/trip/itinerary/src/screenshotTest/kotlin/com/example/jetpacker/feature/itinerary/ItineraryScreenshotTest.kt b/jetpacker/android/feature/trip/itinerary/src/screenshotTest/kotlin/com/example/jetpacker/feature/itinerary/ItineraryScreenshotTest.kt new file mode 100644 index 00000000..d6ca31ef --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/src/screenshotTest/kotlin/com/example/jetpacker/feature/itinerary/ItineraryScreenshotTest.kt @@ -0,0 +1,232 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary + +import com.example.jetpacker.feature.itinerary_enrichment.DayThemeItem +import com.example.jetpacker.feature.itinerary_enrichment.TripSummaryAndTipsProvider +import com.example.jetpacker.feature.itinerary_enrichment.DailyThemeProvider +import android.content.Context +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest +import androidx.compose.ui.unit.dp +import androidx.lifecycle.SavedStateHandle +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.data.itinerary.DayTheme +import com.example.jetpacker.data.itinerary.DayThemeDao +import com.example.jetpacker.data.itinerary.TourDetail +import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TripSessionIdentifier +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.data.trips.DummyData +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf + +class ItineraryScreenshotTest { + @PreviewTest + @Preview(showBackground = true) + @Composable + fun ItineraryScreenScreenshotPreview() { + val fakeEventDao = + object : EventDao { + override fun getEventsForTrip(tripId: String): Flow> = emptyFlow() + + override fun getAllSessionIds() = flowOf(emptyList()) + + override suspend fun deleteEventsForTrip(tripId: String) {} + + override suspend fun insertEvents(events: List) {} + + override suspend fun insertEvent(event: TimelineEvent) {} + + override suspend fun deleteEvent(event: TimelineEvent) {} + + override suspend fun insertFlightDetail( + detail: com.example.jetpacker.data.itinerary.FlightDetail + ) {} + + override suspend fun insertHotelDetail( + detail: com.example.jetpacker.data.itinerary.HotelDetail + ) {} + + override suspend fun insertDiningDetail( + detail: com.example.jetpacker.data.itinerary.DiningDetail + ) {} + + override suspend fun insertActivityDetail( + detail: com.example.jetpacker.data.itinerary.ActivityDetail + ) {} + + override suspend fun insertMuseumDetail(detail: com.example.jetpacker.data.itinerary.MuseumDetail) {} + + override fun getFlightDetail(eventId: String) = flowOf(null) + + override fun getHotelDetail(eventId: String) = flowOf(null) + + override fun getDiningDetail(eventId: String) = flowOf(null) + + override fun getMuseumDetail(eventId: String) = flowOf(null) + + override fun getVoiceNotesForTrip( + tripId: String + ): Flow> = flowOf(emptyList()) + + override suspend fun insertVoiceNote( + note: VoiceNoteEntity + ) {} + + override suspend fun deleteVoiceNoteById(id: String) {} + + override fun getEventById(eventId: String) = flowOf(null) + + override suspend fun deleteAllEvents() {} + + override suspend fun deleteAllFlightDetails() {} + + override suspend fun deleteAllHotelDetails() {} + + override suspend fun deleteAllDiningDetails() {} + + override suspend fun deleteAllActivityDetails() {} + } + + val fakeTripSummaryAndTipsProvider = + object : TripSummaryAndTipsProvider { + override suspend fun isSupported(): Boolean = true + + override fun generateTripSummaryAndTips(events: List, trip: Trip): Flow = + flowOf("Amazing trip summaries and tips!") + + override fun generateTripSummary( + events: List, + trip: Trip, + voiceNotes: List, + ): Flow = flowOf("Amazing summary!") + + override fun generateUpcomingTip(events: List, trip: Trip): Flow = + flowOf("Upcoming tip!") + } + + val savedStateHandle = SavedStateHandle(mapOf("tripId" to "trip1")) + + val fakeDailyThemeProvider = + object : DailyThemeProvider { + override suspend fun generateDailyThemes(events: List): List = + emptyList() + } + + val fakeDayThemeDao = + object : DayThemeDao { + override fun getThemesForTrip( + tripId: String + ): Flow> = flowOf(emptyList()) + + override suspend fun deleteThemesForTrip(tripId: String) {} + + override suspend fun insertThemes( + themes: List + ) {} + + override suspend fun insertTheme(theme: DayTheme) {} + + override suspend fun deleteAllThemes() {} + } + + val fakeTripDao = + object : TripDao { + override fun getAllTrips() = flowOf(emptyList()) + + override fun getTripById(tripId: String) = flowOf(null) + + override suspend fun insertTrip(trip: Trip) {} + + override suspend fun insertTrips(trips: List) {} + + override suspend fun deleteTrip(trip: Trip) {} + + override suspend fun deleteAllTrips() {} + } + + val fakeTourDetailDao = + object : TourDetailDao { + override fun getTourDetailByEventId(eventId: String): Flow = emptyFlow() + + override fun getTourDetailById(id: String): Flow = emptyFlow() + + override suspend fun insertTourDetail(tourDetail: TourDetail) {} + + override suspend fun insertTourDetails(tourDetails: List) {} + + override suspend fun deleteAllTourDetails() {} + } + + val fakeViewModel = + object : + ItineraryViewModel( + savedStateHandle = savedStateHandle, + eventDao = fakeEventDao, + tourDetailDao = fakeTourDetailDao, + dayThemeDao = fakeDayThemeDao, + tripDao = fakeTripDao, + tripSummaryAndTipsProvider = fakeTripSummaryAndTipsProvider, + dailyThemeProvider = fakeDailyThemeProvider, + ) { + private val state = + MutableStateFlow( + ItineraryUiState( + items = + run { + val parisEvents = DummyData.events.filter { it.tripId == "2026-3" } + val uiItems = mutableListOf() + uiItems.add(ItineraryUiListItem.Header("Aug 12, 2026", "Arrival & Check-in", 1, 3)) + uiItems.addAll(parisEvents.take(2).map { ItineraryUiListItem.Event(it) }) + uiItems.add(ItineraryUiListItem.Header("Aug 13, 2026", "Art & Haute Cuisine", 2, 3)) + uiItems.addAll(parisEvents.drop(2).take(2).map { ItineraryUiListItem.Event(it) }) + uiItems.add( + ItineraryUiListItem.Header("Aug 14, 2026", "Royal Palaces & River Views", 3, 3) + ) + uiItems.addAll(parisEvents.drop(4).take(2).map { ItineraryUiListItem.Event(it) }) + uiItems + }, + tripSummaryAndTips = "Relaxing, romantic, and cultural.", + isTripSummaryAndTipsSupported = true, + isGenerating = false, + showAddEventDialog = false, + ) + ) + override val uiState: StateFlow = state + } + + JetPackerTheme { + ItineraryScreen( + contentPadding = PaddingValues(0.dp), + onBack = {}, + viewModel = fakeViewModel, + onEventClick = { _, _ -> }, + ) + } + } +} diff --git a/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/CloudHybridScreenshotTest/ChatbotScreenScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/CloudHybridScreenshotTest/ChatbotScreenScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..0dddbbe5 Binary files /dev/null and b/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/CloudHybridScreenshotTest/ChatbotScreenScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/CloudHybridScreenshotTest/HotelSupportChatScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/CloudHybridScreenshotTest/HotelSupportChatScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..aea5c16f Binary files /dev/null and b/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/CloudHybridScreenshotTest/HotelSupportChatScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/CloudHybridScreenshotTest/ReviewScreenScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/CloudHybridScreenshotTest/ReviewScreenScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..040e80fc Binary files /dev/null and b/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/CloudHybridScreenshotTest/ReviewScreenScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/ItineraryScreenshotTest/ItineraryScreenScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/ItineraryScreenshotTest/ItineraryScreenScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..786ac06a Binary files /dev/null and b/jetpacker/android/feature/trip/itinerary/src/screenshotTestDebug/reference/com/example/jetpacker/feature/itinerary/ItineraryScreenshotTest/ItineraryScreenScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/trip/itinerary/src/test/AndroidManifest.xml b/jetpacker/android/feature/trip/itinerary/src/test/AndroidManifest.xml new file mode 100644 index 00000000..aaa7556b --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/src/test/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/jetpacker/android/feature/trip/itinerary/src/test/kotlin/com/example/jetpacker/feature/itinerary/ItineraryViewModelTest.kt b/jetpacker/android/feature/trip/itinerary/src/test/kotlin/com/example/jetpacker/feature/itinerary/ItineraryViewModelTest.kt new file mode 100644 index 00000000..2f41c996 --- /dev/null +++ b/jetpacker/android/feature/trip/itinerary/src/test/kotlin/com/example/jetpacker/feature/itinerary/ItineraryViewModelTest.kt @@ -0,0 +1,679 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.itinerary + +import com.example.jetpacker.feature.itinerary_enrichment.DailyThemeProvider +import com.example.jetpacker.feature.itinerary_enrichment.DayThemeItem +import com.example.jetpacker.feature.itinerary_enrichment.TripSummaryAndTipsProvider +import androidx.lifecycle.SavedStateHandle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.jetpacker.data.itinerary.ActivityDetail +import com.example.jetpacker.data.itinerary.DayTheme +import com.example.jetpacker.data.itinerary.DayThemeDao +import com.example.jetpacker.data.itinerary.DiningDetail +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.FlightDetail +import com.example.jetpacker.data.itinerary.HotelDetail +import com.example.jetpacker.data.itinerary.MuseumDetail +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TourDetail +import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.TripSessionIdentifier +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +@OptIn(ExperimentalCoroutinesApi::class) +class ItineraryViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @Test + fun generateTripSummaryAndTips_updatesUiState_whenSupported() = runTest { + val fakeSavedStateHandle = SavedStateHandle(mapOf("tripId" to "trip123")) + + val mockEvents = + listOf( + TimelineEvent( + id = "1", + tripId = "trip123", + type = EventType.TRANSPORTATION, + timestamp = System.currentTimeMillis(), + title = "Flight to Paris", + location = "CDG Airport", + description = null, + extraInfo = null, + imageResList = emptyList(), + ) + ) + + val mockTrip = + Trip(id = "trip123", title = "Test Trip", location = "Paris", startDate = 0L, endDate = 0L) + + val fakeEventDao = + object : EventDao { + override fun getAllSessionIds() = flowOf(emptyList()) + + override fun getEventsForTrip(tripId: String) = flowOf(mockEvents) + + override suspend fun insertEvent(event: TimelineEvent) {} + + override suspend fun insertEvents(events: List) {} + + override suspend fun deleteEventsForTrip(tripId: String) {} + + override suspend fun deleteEvent(event: TimelineEvent) {} + + override suspend fun insertFlightDetail( + detail: FlightDetail + ) {} + + override suspend fun insertHotelDetail( + detail: HotelDetail + ) {} + + override suspend fun insertDiningDetail( + detail: DiningDetail + ) {} + + override suspend fun insertActivityDetail( + detail: ActivityDetail + ) {} + + override suspend fun insertMuseumDetail(detail: MuseumDetail) {} + + override fun getFlightDetail(eventId: String) = flowOf(null) + + override fun getHotelDetail(eventId: String) = flowOf(null) + + override fun getDiningDetail(eventId: String) = flowOf(null) + + override fun getMuseumDetail(eventId: String) = flowOf(null) + + override fun getVoiceNotesForTrip(tripId: String) = + flowOf(emptyList()) + + override suspend fun insertVoiceNote( + note: VoiceNoteEntity + ) {} + + override suspend fun deleteVoiceNoteById(id: String) {} + + override fun getEventById(eventId: String) = flowOf(null) + + override suspend fun deleteAllEvents() {} + + override suspend fun deleteAllFlightDetails() {} + + override suspend fun deleteAllHotelDetails() {} + + override suspend fun deleteAllDiningDetails() {} + + override suspend fun deleteAllActivityDetails() {} + } + + val fakeTripSummaryAndTipsProvider = + object : TripSummaryAndTipsProvider { + override suspend fun isSupported(): Boolean = true + + override fun generateTripSummaryAndTips(events: List, trip: Trip): Flow { + return flowOf("Mocked energetic trip summary and tips!") + } + + override fun generateTripSummary( + events: List, + trip: Trip, + voiceNotes: List, + ): Flow = flowOf("Mocked energetic trip summary and tips!") + + override fun generateUpcomingTip(events: List, trip: Trip): Flow = + flowOf("") + } + + val fakeDailyThemeProvider = + object : DailyThemeProvider { + override suspend fun generateDailyThemes(events: List): List { + return emptyList() + } + } + + val fakeDayThemeDao = + object : DayThemeDao { + override fun getThemesForTrip(tripId: String) = flowOf(emptyList()) + + override suspend fun insertTheme(theme: DayTheme) {} + + override suspend fun insertThemes(themes: List) {} + + override suspend fun deleteThemesForTrip(tripId: String) {} + + override suspend fun deleteAllThemes() {} + } + + val fakeTripDao = + object : TripDao { + override fun getAllTrips() = flowOf(emptyList()) + + override fun getTripById(tripId: String) = flowOf(mockTrip) + + override suspend fun insertTrip(trip: Trip) {} + + override suspend fun insertTrips(trips: List) {} + + override suspend fun deleteTrip(trip: Trip) {} + + override suspend fun deleteAllTrips() {} + } + + val fakeTourDetailDao = + object : TourDetailDao { + override fun getTourDetailByEventId(eventId: String) = flowOf(null) + override fun getTourDetailById(id: String) = flowOf(null) + override suspend fun insertTourDetail(tourDetail: TourDetail) {} + override suspend fun insertTourDetails(tourDetails: List) {} + override suspend fun deleteAllTourDetails() {} + } + + val viewModel = + ItineraryViewModel( + fakeSavedStateHandle, + fakeEventDao, + fakeTourDetailDao, + fakeDayThemeDao, + fakeTripDao, + fakeTripSummaryAndTipsProvider, + fakeDailyThemeProvider, + ) + + advanceUntilIdle() + + val uiState = viewModel.uiState.first() + assertTrue(uiState.isTripSummaryAndTipsSupported) + assertEquals("Mocked energetic trip summary and tips!", uiState.tripSummaryAndTips) + assertFalse(uiState.isTripSummaryAndTipsLoading) + } + + @Test + fun generateTripSummaryAndTips_updatesUiState_whenUnsupported() = runTest { + val fakeSavedStateHandle = SavedStateHandle(mapOf("tripId" to "trip123")) + + val mockEvents = + listOf( + TimelineEvent( + id = "1", + tripId = "trip123", + type = EventType.TRANSPORTATION, + timestamp = System.currentTimeMillis(), + title = "Flight to Paris", + location = "CDG Airport", + description = null, + extraInfo = null, + imageResList = emptyList(), + ) + ) + + val mockTrip = + Trip(id = "trip123", title = "Test Trip", location = "Paris", startDate = 0L, endDate = 0L) + + val fakeEventDao = + object : EventDao { + override fun getAllSessionIds() = flowOf(emptyList()) + + override fun getEventsForTrip(tripId: String) = flowOf(mockEvents) + + override suspend fun insertEvent(event: TimelineEvent) {} + + override suspend fun insertEvents(events: List) {} + + override suspend fun deleteEventsForTrip(tripId: String) {} + + override suspend fun deleteEvent(event: TimelineEvent) {} + + override suspend fun insertFlightDetail( + detail: FlightDetail + ) {} + + override suspend fun insertHotelDetail( + detail: HotelDetail + ) {} + + override suspend fun insertDiningDetail( + detail: DiningDetail + ) {} + + override suspend fun insertActivityDetail( + detail: ActivityDetail + ) {} + + override suspend fun insertMuseumDetail(detail: MuseumDetail) {} + + override fun getFlightDetail(eventId: String) = flowOf(null) + + override fun getHotelDetail(eventId: String) = flowOf(null) + + override fun getDiningDetail(eventId: String) = flowOf(null) + + override fun getMuseumDetail(eventId: String) = flowOf(null) + + override fun getVoiceNotesForTrip(tripId: String) = + flowOf(emptyList()) + + override suspend fun insertVoiceNote( + note: VoiceNoteEntity + ) {} + + override suspend fun deleteVoiceNoteById(id: String) {} + + override fun getEventById(eventId: String) = flowOf(null) + + override suspend fun deleteAllEvents() {} + + override suspend fun deleteAllFlightDetails() {} + + override suspend fun deleteAllHotelDetails() {} + + override suspend fun deleteAllDiningDetails() {} + + override suspend fun deleteAllActivityDetails() {} + } + + val fakeTripSummaryAndTipsProvider = + object : TripSummaryAndTipsProvider { + override suspend fun isSupported(): Boolean = false + + override fun generateTripSummaryAndTips(events: List, trip: Trip) = flowOf("") + + override fun generateTripSummary( + events: List, + trip: Trip, + voiceNotes: List, + ): Flow = flowOf("") + + override fun generateUpcomingTip(events: List, trip: Trip): Flow = + flowOf("") + } + + val fakeDailyThemeProvider = + object : DailyThemeProvider { + override suspend fun generateDailyThemes(events: List): List = + emptyList() + } + + val fakeDayThemeDao = + object : DayThemeDao { + override fun getThemesForTrip(tripId: String) = flowOf(emptyList()) + + override suspend fun insertTheme(theme: DayTheme) {} + + override suspend fun insertThemes(themes: List) {} + + override suspend fun deleteThemesForTrip(tripId: String) {} + + override suspend fun deleteAllThemes() {} + } + + val fakeTripDao = + object : TripDao { + override fun getAllTrips() = flowOf(emptyList()) + + override fun getTripById(tripId: String) = flowOf(mockTrip) + + override suspend fun insertTrip(trip: Trip) {} + + override suspend fun insertTrips(trips: List) {} + + override suspend fun deleteTrip(trip: Trip) {} + + override suspend fun deleteAllTrips() {} + } + + val fakeTourDetailDao = + object : TourDetailDao { + override fun getTourDetailByEventId(eventId: String) = flowOf(null) + override fun getTourDetailById(id: String) = flowOf(null) + override suspend fun insertTourDetail(tourDetail: TourDetail) {} + override suspend fun insertTourDetails(tourDetails: List) {} + override suspend fun deleteAllTourDetails() {} + } + + val viewModel = + ItineraryViewModel( + fakeSavedStateHandle, + fakeEventDao, + fakeTourDetailDao, + fakeDayThemeDao, + fakeTripDao, + fakeTripSummaryAndTipsProvider, + fakeDailyThemeProvider, + ) + + advanceUntilIdle() + + val uiState = viewModel.uiState.first() + assertFalse(uiState.isTripSummaryAndTipsSupported) + } + + @Test + fun generateTripSummaryAndTips_usesPersistedSummary_whenPresent() = runTest { + val fakeSavedStateHandle = SavedStateHandle(mapOf("tripId" to "trip123")) + + val mockEvents = + listOf( + TimelineEvent( + id = "1", + tripId = "trip123", + type = EventType.TRANSPORTATION, + timestamp = System.currentTimeMillis(), + title = "Flight to Paris", + location = "CDG Airport", + description = null, + extraInfo = null, + imageResList = emptyList(), + ) + ) + + val mockTrip = + Trip( + id = "trip123", + title = "Test Trip", + location = "Paris", + startDate = 0L, + endDate = 0L, + tripSummaryAndTips = "Persisted trip summary and tips", + ) + + val fakeEventDao = + object : EventDao { + override fun getAllSessionIds() = + kotlinx.coroutines.flow.flowOf(emptyList()) + + override fun getEventsForTrip(tripId: String) = kotlinx.coroutines.flow.flowOf(mockEvents) + + override suspend fun insertEvent(event: TimelineEvent) {} + + override suspend fun insertEvents(events: List) {} + + override suspend fun deleteEventsForTrip(tripId: String) {} + + override suspend fun deleteEvent(event: TimelineEvent) {} + + override suspend fun insertFlightDetail( + detail: FlightDetail + ) {} + + override suspend fun insertHotelDetail( + detail: HotelDetail + ) {} + + override suspend fun insertDiningDetail( + detail: DiningDetail + ) {} + + override suspend fun insertActivityDetail( + detail: ActivityDetail + ) {} + + override suspend fun insertMuseumDetail(detail: MuseumDetail) {} + + override fun getFlightDetail(eventId: String) = flowOf(null) + + override fun getHotelDetail(eventId: String) = flowOf(null) + + override fun getDiningDetail(eventId: String) = flowOf(null) + + override fun getMuseumDetail(eventId: String) = flowOf(null) + + override fun getVoiceNotesForTrip(tripId: String) = + flowOf(emptyList()) + + override suspend fun insertVoiceNote( + note: VoiceNoteEntity + ) {} + + override suspend fun deleteVoiceNoteById(id: String) {} + + override fun getEventById(eventId: String) = flowOf(null) + + override suspend fun deleteAllEvents() {} + + override suspend fun deleteAllFlightDetails() {} + + override suspend fun deleteAllHotelDetails() {} + + override suspend fun deleteAllDiningDetails() {} + + override suspend fun deleteAllActivityDetails() {} + } + + val fakeTripSummaryAndTipsProvider = + object : TripSummaryAndTipsProvider { + override suspend fun isSupported(): Boolean = true + + override fun generateTripSummaryAndTips( + events: List, + trip: Trip, + ): kotlinx.coroutines.flow.Flow { + return kotlinx.coroutines.flow.flowOf("Should not be called!") + } + + override fun generateTripSummary( + events: List, + trip: Trip, + voiceNotes: List, + ): Flow = flowOf("") + + override fun generateUpcomingTip(events: List, trip: Trip): Flow = + flowOf("") + } + + val fakeDailyThemeProvider = + object : DailyThemeProvider { + override suspend fun generateDailyThemes(events: List): List = + emptyList() + } + + val fakeDayThemeDao = + object : DayThemeDao { + override fun getThemesForTrip(tripId: String) = + kotlinx.coroutines.flow.flowOf(emptyList()) + + override suspend fun insertTheme(theme: DayTheme) {} + + override suspend fun insertThemes(themes: List) {} + + override suspend fun deleteThemesForTrip(tripId: String) {} + + override suspend fun deleteAllThemes() {} + } + + val fakeTripDao = + object : TripDao { + override fun getAllTrips() = kotlinx.coroutines.flow.flowOf(emptyList()) + + override fun getTripById(tripId: String) = kotlinx.coroutines.flow.flowOf(mockTrip) + + override suspend fun insertTrip(trip: Trip) {} + + override suspend fun insertTrips(trips: List) {} + + override suspend fun deleteTrip(trip: Trip) {} + + override suspend fun deleteAllTrips() {} + } + + val fakeTourDetailDao = + object : TourDetailDao { + override fun getTourDetailByEventId(eventId: String) = flowOf(null) + override fun getTourDetailById(id: String) = flowOf(null) + override suspend fun insertTourDetail(tourDetail: TourDetail) {} + override suspend fun insertTourDetails(tourDetails: List) {} + override suspend fun deleteAllTourDetails() {} + } + + val viewModel = + ItineraryViewModel( + fakeSavedStateHandle, + fakeEventDao, + fakeTourDetailDao, + fakeDayThemeDao, + fakeTripDao, + fakeTripSummaryAndTipsProvider, + fakeDailyThemeProvider, + ) + + advanceUntilIdle() + + val uiState = viewModel.uiState.first() + assertTrue(uiState.isTripSummaryAndTipsSupported) + assertEquals("Persisted trip summary and tips", uiState.tripSummaryAndTips) + assertFalse(uiState.isTripSummaryAndTipsLoading) + } + + @Test + fun testMultiDayEventGrouping() = runTest { + val mockTrip = Trip(id = "trip123", title = "Test Trip", location = "Paris", startDate = 0L, endDate = 0L) + val multiDayEvents = listOf( + TimelineEvent( + id = "1", + tripId = "trip123", + type = EventType.TRANSPORTATION, + timestamp = 1779192000000, // Day 1 + title = "Flight", + location = "CDG", + description = null, + extraInfo = null, + imageResList = emptyList(), + ), + TimelineEvent( + id = "2", + tripId = "trip123", + type = EventType.ACCOMMODATION, + timestamp = 1779278400000, // Day 2 (+24h) + title = "Hotel", + location = "Paris", + description = null, + extraInfo = null, + imageResList = emptyList(), + ) + ) + + val fakeSavedStateHandle = SavedStateHandle(mapOf("tripId" to "trip123")) + + val fakeEventDao = object : EventDao { + override fun getAllSessionIds() = flowOf(emptyList()) + override fun getEventsForTrip(tripId: String) = flowOf(multiDayEvents) + override suspend fun insertEvent(event: TimelineEvent) {} + override suspend fun insertEvents(events: List) {} + override suspend fun deleteEventsForTrip(tripId: String) {} + override suspend fun deleteEvent(event: TimelineEvent) {} + override suspend fun insertFlightDetail(detail: FlightDetail) {} + override suspend fun insertHotelDetail(detail: HotelDetail) {} + override suspend fun insertDiningDetail(detail: DiningDetail) {} + override suspend fun insertActivityDetail(detail: ActivityDetail) {} + override suspend fun insertMuseumDetail(detail: MuseumDetail) {} + override fun getFlightDetail(eventId: String) = flowOf(null) + override fun getHotelDetail(eventId: String) = flowOf(null) + override fun getDiningDetail(eventId: String) = flowOf(null) + override fun getMuseumDetail(eventId: String) = flowOf(null) + override fun getVoiceNotesForTrip(tripId: String) = flowOf(emptyList()) + override suspend fun insertVoiceNote(note: VoiceNoteEntity) {} + override suspend fun deleteVoiceNoteById(id: String) {} + override fun getEventById(eventId: String) = flowOf(null) + override suspend fun deleteAllEvents() {} + override suspend fun deleteAllFlightDetails() {} + override suspend fun deleteAllHotelDetails() {} + override suspend fun deleteAllDiningDetails() {} + override suspend fun deleteAllActivityDetails() {} + } + + val fakeTourDetailDao = object : TourDetailDao { + override fun getTourDetailByEventId(eventId: String) = flowOf(null) + override fun getTourDetailById(id: String) = flowOf(null) + override suspend fun insertTourDetail(tourDetail: TourDetail) {} + override suspend fun insertTourDetails(tourDetails: List) {} + override suspend fun deleteAllTourDetails() {} + } + + val fakeDayThemeDao = object : DayThemeDao { + override fun getThemesForTrip(tripId: String) = flowOf(emptyList()) + override suspend fun insertTheme(theme: DayTheme) {} + override suspend fun insertThemes(themes: List) {} + override suspend fun deleteThemesForTrip(tripId: String) {} + override suspend fun deleteAllThemes() {} + } + + val fakeTripDao = object : TripDao { + override fun getAllTrips() = flowOf(listOf(mockTrip)) + override fun getTripById(tripId: String) = flowOf(mockTrip) + override suspend fun insertTrip(trip: Trip) {} + override suspend fun insertTrips(trips: List) {} + override suspend fun deleteTrip(trip: Trip) {} + override suspend fun deleteAllTrips() {} + } + + val fakeTripSummaryAndTipsProvider = object : TripSummaryAndTipsProvider { + override suspend fun isSupported(): Boolean = true + override fun generateTripSummaryAndTips(events: List, trip: Trip) = flowOf("Summary") + override fun generateTripSummary(events: List, trip: Trip, voiceNotes: List) = flowOf("Summary") + override fun generateUpcomingTip(events: List, trip: Trip) = flowOf("") + } + + val fakeDailyThemeProvider = object : DailyThemeProvider { + override suspend fun generateDailyThemes(events: List) = emptyList() + } + + val viewModel = ItineraryViewModel( + fakeSavedStateHandle, + fakeEventDao, + fakeTourDetailDao, + fakeDayThemeDao, + fakeTripDao, + fakeTripSummaryAndTipsProvider, + fakeDailyThemeProvider, + ) + advanceUntilIdle() + val items = viewModel.uiState.first().items + val headers = items.filterIsInstance() + assertEquals(2, headers.size) + assertEquals(1, headers[0].dayNumber) + assertEquals(2, headers[1].dayNumber) + } +} diff --git a/jetpacker/android/feature/trip/src/main/AndroidManifest.xml b/jetpacker/android/feature/trip/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/jetpacker/android/feature/trip/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt b/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt new file mode 100644 index 00000000..d6aadd18 --- /dev/null +++ b/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.trip + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateBounds +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material.icons.rounded.Wallet +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.core.ui.R +import com.example.jetpacker.core.ui.components.JetPackerFab +import com.example.jetpacker.core.ui.components.JetPackerFabConfig +import com.example.jetpacker.core.ui.components.JetPackerToolbar +import com.example.jetpacker.core.ui.components.JetPackerToolbarAction +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.feature.expenses.ManageExpensesScreen +import com.example.jetpacker.feature.itinerary.ItineraryScreen +import com.example.jetpacker.feature.voice_notes.VoiceNotesScreen + +enum class TripTab { + ITINERARY, + EXPENSES, + VOICE_NOTES, +} + +/** + * Composable screen serving as the main container for viewing details of a specific trip. + * Displays the itinerary and links to event details, maps, and editing options. + */ +@Composable +fun TripScreen( + tripId: String, + onBack: () -> Unit = {}, + onEditTripClick: () -> Unit = {}, + onEventClick: (String, EventType) -> Unit = { _, _ -> }, + onVoiceNotesClick: () -> Unit = {}, + onNavigateToDebug: () -> Unit = {}, +) { + var selectedTab by remember { mutableStateOf(TripTab.ITINERARY) } + var fabConfig by remember { mutableStateOf(null) } + + Scaffold( + containerColor = MaterialTheme.colorScheme.primary, + bottomBar = { + val showExpenses = FeatureFlags.ENABLE_EXPENSE_MANAGEMENT + val showVoiceNotes = FeatureFlags.ENABLE_VOICE_NOTES + + var visibleCount = 1 + if (showExpenses) visibleCount++ + if (showVoiceNotes) visibleCount++ + + if (visibleCount > 1) { + AnimatedVisibility( + visible = true, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + JetPackerBottomBar( + selectedTab = selectedTab, + onTabSelected = { selectedTab = it }, + fabConfig = fabConfig, + ) + } + } + }, + ) { innerPadding -> + Box(modifier = Modifier.fillMaxSize()) { + when (selectedTab) { + TripTab.ITINERARY -> { + ItineraryScreen( + tripId = tripId, + contentPadding = innerPadding, + onBack = onBack, + onEditTripClick = onEditTripClick, + onEventClick = onEventClick, + onFabConfigChange = { fabConfig = it }, + onNavigateToDebug = onNavigateToDebug, + ) + } + + TripTab.EXPENSES -> { + ManageExpensesScreen( + tripId = tripId, + contentPadding = innerPadding, + onBack = { selectedTab = TripTab.ITINERARY }, + onFabConfigChange = { fabConfig = it }, + ) + } + + TripTab.VOICE_NOTES -> { + VoiceNotesScreen( + tripId = tripId, + contentPadding = innerPadding, + onBack = { selectedTab = TripTab.ITINERARY }, + onFabConfigChange = { fabConfig = it }, + ) + } + } + } + } +} + +@Composable +fun JetPackerBottomBar( + selectedTab: TripTab, + onTabSelected: (TripTab) -> Unit, + fabConfig: JetPackerFabConfig?, + modifier: Modifier = Modifier, +) { + val showExpenses = FeatureFlags.ENABLE_EXPENSE_MANAGEMENT + val showVoiceNotes = FeatureFlags.ENABLE_VOICE_NOTES + + LookaheadScope { + Row( + modifier = + modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 16.dp) + .padding(horizontal = 32.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + JetPackerToolbar( + modifier = Modifier.widthIn(max = 272.dp).animateBounds(this@LookaheadScope) + ) { + JetPackerToolbarAction( + icon = Icons.Rounded.Event, + onClick = { onTabSelected(TripTab.ITINERARY) }, + selected = selectedTab == TripTab.ITINERARY, + contentDescription = "Itinerary", + ) + if (showExpenses) { + JetPackerToolbarAction( + icon = Icons.Rounded.Wallet, + onClick = { onTabSelected(TripTab.EXPENSES) }, + selected = selectedTab == TripTab.EXPENSES, + contentDescription = "Expenses", + ) + } + + if (showVoiceNotes) { + JetPackerToolbarAction( + selected = selectedTab == TripTab.VOICE_NOTES, + onClick = { onTabSelected(TripTab.VOICE_NOTES) }, + icon = ImageVector.vectorResource(R.drawable.speech_to_text), + contentDescription = "Voice Notes", + ) + } + } + + Spacer(Modifier.width(4.dp)) + + AnimatedVisibility( + visible = fabConfig != null, + enter = slideInHorizontally { it / 2 } + fadeIn(), + exit = slideOutHorizontally { it / 2 } + fadeOut(), + ) { + JetPackerFab( + modifier = Modifier.padding(4.dp), + onClick = fabConfig?.onClick ?: {}, + icon = fabConfig?.icon ?: Icons.Rounded.Add, + contentDescription = fabConfig?.contentDescription, + containerColor = fabConfig?.containerColor ?: MaterialTheme.colorScheme.tertiary, + contentColor = fabConfig?.contentColor ?: MaterialTheme.colorScheme.onTertiary, + ) + } + } + } +} diff --git a/jetpacker/android/feature/trip/src/test/AndroidManifest.xml b/jetpacker/android/feature/trip/src/test/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/jetpacker/android/feature/trip/src/test/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/jetpacker/android/feature/trip/voice_notes/build.gradle.kts b/jetpacker/android/feature/trip/voice_notes/build.gradle.kts new file mode 100644 index 00000000..7ea780ad --- /dev/null +++ b/jetpacker/android/feature/trip/voice_notes/build.gradle.kts @@ -0,0 +1,76 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.android.compose.screenshot) +} + +android { + namespace = "com.example.jetpacker.feature.voice_notes" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + experimentalProperties["android.experimental.enableScreenshotTest"] = true + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(project(":core:ui")) + implementation(project(":core:flags")) + implementation(project(":core:speech")) + implementation(project(":data:itinerary")) + implementation(project(":data:trips")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + "ksp"(libs.hilt.compiler) + implementation(libs.mlkit.genai.prompt) + implementation(libs.mlkit.genai.speech) + + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) + screenshotTestImplementation(project(":data:trips")) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.robolectric) +} + +kotlin { jvmToolchain(17) } + +screenshotTests { + imageDifferenceThreshold = 0.05f +} diff --git a/jetpacker/android/feature/trip/voice_notes/src/main/AndroidManifest.xml b/jetpacker/android/feature/trip/voice_notes/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/jetpacker/android/feature/trip/voice_notes/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/jetpacker/android/feature/trip/voice_notes/src/main/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesScreen.kt b/jetpacker/android/feature/trip/voice_notes/src/main/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesScreen.kt new file mode 100644 index 00000000..8133d4b3 --- /dev/null +++ b/jetpacker/android/feature/trip/voice_notes/src/main/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesScreen.kt @@ -0,0 +1,837 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.voice_notes + +import com.example.jetpacker.core.ui.components.JetPackerFabConfig +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxDefaults +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.JetPackerTheme +import com.example.jetpacker.core.ui.SekuyaFontFamily +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.data.trips.DummyData + +data class VoiceNotePlaceholder( + val id: String, + val title: String, + val date: String, + val isError: Boolean = false, + val errorMessage: String? = null, +) + +@SuppressLint("NewApi", "GlobalCoroutineDispatchers") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VoiceNotesScreen( + modifier: Modifier = Modifier, + tripId: String, + contentPadding: PaddingValues, + onBack: () -> Unit = {}, + onFabConfigChange: (JetPackerFabConfig?) -> Unit = {}, + viewModel: VoiceNotesViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val events by viewModel.events.collectAsStateWithLifecycle() + + LaunchedEffect(tripId) { + if (tripId.isNotEmpty()) { + viewModel.loadForTrip(tripId) + } + } + + var hasRecordAudioPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) + } + + val permissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted -> hasRecordAudioPermission = isGranted }, + ) + + val voiceInputManager = viewModel.voiceInputManager + val voiceInputState by voiceInputManager.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(hasRecordAudioPermission) { + voiceInputManager.setPermissionGranted(hasRecordAudioPermission) + } + + val handleListenToggle: () -> Unit = { + voiceInputManager.handleListenToggle( + onPermissionRequired = { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }, + onResult = { originalText, translatedText -> + viewModel.processVoiceNote(originalText, translatedText) + }, + ) + } + + val secondary = MaterialTheme.colorScheme.secondary + val tertiary = MaterialTheme.colorScheme.tertiary + val onSecondary = MaterialTheme.colorScheme.onSecondary + val onTertiary = MaterialTheme.colorScheme.onTertiary + + LaunchedEffect(voiceInputState.isReady, voiceInputState.isListening, voiceInputState.statusText) { + onFabConfigChange( + JetPackerFabConfig( + onClick = handleListenToggle, + containerColor = if (voiceInputState.isListening) tertiary else secondary, + contentColor = if (voiceInputState.isListening) onTertiary else onSecondary, + icon = if (voiceInputState.isListening) Icons.Rounded.Stop else Icons.Rounded.Mic, + contentDescription = if (voiceInputState.isListening) "Stop" else "Record", + ) + ) + } + + DisposableEffect(Unit) { onDispose { voiceInputManager.cancelListening() } } + + var noteToDelete by remember { + mutableStateOf(null) + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + Box(modifier = modifier.fillMaxSize()) { + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar( + title = { + Text( + "Voice notes", + style = + MaterialTheme.typography.titleLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 22.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + scrollBehavior = scrollBehavior, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + ) + }, + containerColor = Color.Transparent, + ) { innerPadding -> + val voiceNotes by viewModel.voiceNotes.collectAsStateWithLifecycle(initialValue = emptyList()) + + Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + if (voiceNotes.isEmpty() && viewModel.processingNotes.isEmpty()) { + EmptyVoiceNotesView(modifier = Modifier.fillMaxSize()) + } else { + LazyColumn( + modifier = + Modifier.fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithContent { + drawContent() + drawRect( + brush = + Brush.verticalGradient( + 0f to Color.Black, + 0.8f to Color.Black, + 1f to Color.Transparent, + ), + blendMode = BlendMode.DstIn, + ) + }, + contentPadding = + PaddingValues(bottom = 120.dp, start = 16.dp, end = 16.dp, top = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(viewModel.processingNotes, key = { it.id }) { placeholder -> + ProcessingVoiceNoteItem(placeholder = placeholder) + } + items(voiceNotes, key = { it.id }) { note -> + VoiceNoteItem( + note = note, + events = events, + isSwipedOff = noteToDelete == note, + onDelete = { noteToDelete = note }, + ) + } + } + } + } + } + + if (noteToDelete != null) { + DeleteVoiceNoteSheet( + note = noteToDelete!!, + onConfirm = { + viewModel.deleteVoiceNote(noteToDelete!!) + noteToDelete = null + }, + onDismiss = { noteToDelete = null }, + ) + } + + // Transcription Overlay + if (voiceInputState.showDialog || voiceInputState.isListening) { + VoiceModeOverlay( + contentPadding = contentPadding, + statusText = voiceInputState.statusText, + transcription = voiceInputState.transcription + voiceInputState.partialTranscription, + translatedTranscription = voiceInputState.translatedTranscription, + isListening = voiceInputState.isListening, + isError = voiceInputState.isError, + onDismiss = { voiceInputManager.cancelListening() }, + ) + } + } +} + +@Composable +fun VoiceModeOverlay( + contentPadding: PaddingValues, + statusText: String, + transcription: String, + translatedTranscription: String, + isListening: Boolean, + isError: Boolean, + onDismiss: () -> Unit, +) { + val infiniteTransition = rememberInfiniteTransition(label = "voice_note_gradient") + val animationProgress by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(3000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "animation_progress", + ) + + JetPackerTheme(darkTheme = false) { + Box(modifier = Modifier.fillMaxSize().clickable { onDismiss() }) { + // 60% Surface Overlay (Scrim) + Box( + modifier = + Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) + ) + + // Animated Gradient Background (Aligned to bottom as per Figma) + val errorContainer = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.45f) + val primary90 = MaterialTheme.colorScheme.primaryFixed + val secondary90 = MaterialTheme.colorScheme.secondaryFixed + val tertiary80 = MaterialTheme.colorScheme.tertiaryFixedDim + + Box( + modifier = + Modifier.fillMaxWidth() + .height(447.dp) + .align(Alignment.BottomCenter) + .blur(80.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded) + .background( + Brush.linearGradient( + 0.0f to if (isError) errorContainer else primary90, + 0.36f to if (isError) errorContainer else secondary90, + 0.66f to if (isError) errorContainer else tertiary80, + start = Offset(0f, 500f * animationProgress), + end = Offset(1000f, 1000f - (500f * animationProgress)), + ) + ) + ) + + Column( + modifier = + Modifier.align(Alignment.BottomStart) + .padding(horizontal = 32.dp) + .padding(bottom = contentPadding.calculateBottomPadding() + 56.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center, + ) { + AnimatedContent( + targetState = + if (isError) "Something went wrong" else transcription.ifEmpty { statusText }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + ) { targetText -> + Text( + text = targetText, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Start, + color = + if (isError) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + + if (translatedTranscription.isNotEmpty() && !isError) { + Spacer(modifier = Modifier.height(16.dp)) + AnimatedContent( + targetState = translatedTranscription, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + ) { targetTranslation -> + Text( + text = targetTranslation, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + if (isError) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.errorContainer) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = statusText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } + } + } + } + } +} + +@Composable +fun EmptyVoiceNotesView(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Rounded.Mic, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No voice notes yet", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Tap the mic button to start recording a note for your trip.", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun ProcessingVoiceNoteItem(placeholder: VoiceNotePlaceholder) { + val shape = RoundedCornerShape(12.dp) + Card( + modifier = + Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outline, shape).dropShadow( + shape = shape + ) { + radius = 0f + spread = 0f + offset = Offset(x = 4.dp.toPx(), y = 6.dp.toPx()) + color = Color(0xFF20290A) + }, + shape = shape, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background( + if (placeholder.isError) MaterialTheme.colorScheme.errorContainer + else MaterialTheme.colorScheme.surfaceVariant + ), + contentAlignment = Alignment.Center, + ) { + if (placeholder.isError) { + Icon( + Icons.Rounded.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (placeholder.isError) "Failed to process" else placeholder.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.ExtraBold, + color = + if (placeholder.isError) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = placeholder.errorMessage ?: placeholder.date, + style = MaterialTheme.typography.bodySmall, + color = + if (placeholder.isError) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VoiceNoteItem( + note: VoiceNoteEntity, + events: List, + isSwipedOff: Boolean, + onDelete: () -> Unit, +) { + val dismissState = + rememberSwipeToDismissBoxState( + SwipeToDismissBoxValue.Settled, + SwipeToDismissBoxDefaults.positionalThreshold, + ) + + LaunchedEffect(dismissState.currentValue) { + if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart) { + onDelete() + } + } + + LaunchedEffect(isSwipedOff) { + if (!isSwipedOff && dismissState.currentValue != SwipeToDismissBoxValue.Settled) { + dismissState.snapTo(SwipeToDismissBoxValue.Settled) + } + } + + val shape = RoundedCornerShape(12.dp) + var isExpanded by remember { mutableStateOf(false) } + + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = false, + backgroundContent = { + val color = + when (dismissState.dismissDirection) { + SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.errorContainer + else -> Color.Transparent + } + + Box( + modifier = Modifier.fillMaxSize().padding(bottom = 16.dp).clip(shape).background(color), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(end = 16.dp), + ) + } + }, + ) { + Card( + modifier = + Modifier.fillMaxWidth() + .padding(bottom = 16.dp) + .border(1.dp, MaterialTheme.colorScheme.outline, shape) + .dropShadow(shape = shape) { + radius = 0f + spread = 0f + offset = Offset(x = 4.dp.toPx(), y = 6.dp.toPx()) + color = Color(0xFF20290A) + }, + shape = shape, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = note.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(12.dp)) + Column( + modifier = + Modifier.fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceContainerHighest, + RoundedCornerShape(8.dp), + ) + .padding(12.dp) + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, Color(0xFF299EAF)), + modifier = Modifier.padding(bottom = 8.dp), + ) { + Text( + text = "Transcription", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + } + + val separator = " |||| " + val transParts = note.transcription.split(separator) + val translatedText = transParts.getOrNull(0).orEmpty().trim() + val originalText = transParts.getOrNull(1).orEmpty().trim() + + Text( + text = + if (translatedText.isNotEmpty()) "“$translatedText”" else "“${note.transcription}”", + maxLines = if (isExpanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + style = + MaterialTheme.typography.bodySmall.copy( + fontStyle = FontStyle.Italic, + letterSpacing = 0.4.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + + if (originalText.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "“$originalText”", + maxLines = if (isExpanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + style = + MaterialTheme.typography.bodySmall.copy( + fontStyle = FontStyle.Italic, + letterSpacing = 0.4.sp, + ), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + + TextButton( + onClick = { isExpanded = !isExpanded }, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.height(32.dp), + ) { + Text( + text = if (isExpanded) "Collapse" else "Expand", + style = MaterialTheme.typography.bodySmall, + ) + } + } + + val matches = + remember(note.matchingEventsJson) { + try { + val json = org.json.JSONArray(note.matchingEventsJson) + val list = mutableListOf>() + for (i in 0 until json.length()) { + val obj = json.getJSONObject(i) + list.add(Pair(obj.getString("eventId"), obj.getString("extract"))) + } + list + } catch (e: Exception) { + emptyList>() + } + } + + if (matches.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Matching Events", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(6.dp)) + matches.forEach { (eventId, extract) -> + val matchedEvent = events.find { it.id == eventId } + if (matchedEvent != null) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "• ${matchedEvent.title}: ", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = extract, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeleteVoiceNoteSheet( + note: VoiceNoteEntity, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + var isConfirmChecked by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp).padding(bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier.size(80.dp) + .background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(36.dp), + ) + } + + Text( + "Delete Voice Note?", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + "Are you sure you want to delete voice note '${note.title}'? This action cannot be undone.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier.clickable { isConfirmChecked = !isConfirmChecked } + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = isConfirmChecked, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.error, + checkmarkColor = MaterialTheme.colorScheme.onError, + ), + onCheckedChange = { isConfirmChecked = it } + ) + Text( + "I am sure I want to delete this voice note.", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton( + colors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ), + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) { + Text("Cancel") + } + Button( + onClick = onConfirm, + enabled = isConfirmChecked, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + shape = MaterialTheme.shapes.medium, + ) { + Text("Delete") + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun VoiceNoteItemPreview() { + JetPackerTheme { + VoiceNoteItem( + note = DummyData.voiceNotes.first(), + events = emptyList(), + isSwipedOff = false, + onDelete = {}, + ) + } +} diff --git a/jetpacker/android/feature/trip/voice_notes/src/main/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesViewModel.kt b/jetpacker/android/feature/trip/voice_notes/src/main/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesViewModel.kt new file mode 100644 index 00000000..5ba821ce --- /dev/null +++ b/jetpacker/android/feature/trip/voice_notes/src/main/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesViewModel.kt @@ -0,0 +1,254 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.voice_notes + +import android.annotation.SuppressLint +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.core.speech.VoiceInputManager +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.GenerativeModel +import com.google.mlkit.genai.prompt.ModelPreference +import com.google.mlkit.genai.prompt.ModelReleaseStage +import com.google.mlkit.genai.prompt.TextPart +import com.google.mlkit.genai.prompt.generateContentRequest +import com.google.mlkit.genai.prompt.generationConfig +import com.google.mlkit.genai.prompt.modelConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject + +@SuppressLint("GlobalCoroutineDispatchers") +@HiltViewModel +class VoiceNotesViewModel +@Inject +constructor( + savedStateHandle: SavedStateHandle, + private val eventDao: EventDao, + val voiceInputManager: VoiceInputManager, +) : ViewModel() { + + private val _tripIdFlow = MutableStateFlow(savedStateHandle.get("tripId") ?: "") + internal val tripId: String get() = _tripIdFlow.value + + fun loadForTrip(id: String) { + if (id.isNotEmpty() && id != _tripIdFlow.value) { + _tripIdFlow.value = id + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + val events: StateFlow> = + _tripIdFlow + .flatMapLatest { id -> eventDao.getEventsForTrip(id) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + @OptIn(ExperimentalCoroutinesApi::class) + val voiceNotes: StateFlow> = + _tripIdFlow + .flatMapLatest { id -> eventDao.getVoiceNotesForTrip(id) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val processingNotes = mutableStateListOf() + + // Visible for testing + internal var generativeModelTesting: GenerativeModel? = null + + private val generativeModel by lazy { + generativeModelTesting + ?: run { + val previewFastConfig = generationConfig { + modelConfig = modelConfig { + releaseStage = ModelReleaseStage.PREVIEW + preference = ModelPreference.FAST + } + } + Generation.getClient(previewFastConfig) + } + } + + suspend fun insertVoiceNote(note: VoiceNoteEntity) { + eventDao.insertVoiceNote(note) + } + + fun deleteVoiceNote(note: VoiceNoteEntity) { + viewModelScope.launch { + try { + val json = JSONArray(note.matchingEventsJson) + for (i in 0 until json.length()) { + val obj = json.getJSONObject(i) + val eventId = obj.getString("eventId") + val extract = obj.getString("extract") + + val event = events.value.find { it.id == eventId } + if (event != null) { + val updatedAudioNotes = event.audioNotes.filter { it != extract } + updateEvent(event.copy(audioNotes = updatedAudioNotes)) + } + } + } catch (e: Exception) { + // Silently fail if JSON is malformed + } + eventDao.deleteVoiceNoteById(note.id) + } + } + + suspend fun updateEvent(event: TimelineEvent) { + eventDao.insertEvent(event) + } + + @SuppressLint("GlobalCoroutineDispatchers") + fun processVoiceNote(original: String, translated: String) { + val placeholder = + VoiceNotePlaceholder( + id = UUID.randomUUID().toString(), + title = "Processing...", + // TODO: Remove hardcoded date + date = "2026-05-20", + ) + processingNotes.add(0, placeholder) + viewModelScope.launch { + withContext(Dispatchers.Main.immediate) { + try { + delay(150) + val status = generativeModel.checkStatus() + if (status == 3) { // 3 is AVAILABLE + // TODO: Refactor to use real current date/time instead of hardcoded May 20 12PM + val ioBaseTime = Instant.parse("2026-05-17T00:00:00Z").toEpochMilli() + val dayMillis = 24 * 3600 * 1000L + val hourMillis = 3600 * 1000L + val nowTs = ioBaseTime + 3 * dayMillis + 12 * hourMillis + val eventsListStr = + events.value + .filter { it.timestamp <= nowTs } + .joinToString("\n") { "${it.id} - ${it.type.name} - ${it.title}" } + val prompt = + """ + Given the voice note transcription: "$translated" + And the following events for this trip: + ${eventsListStr} + + Rewrite this transcription to remove filler words, fluff words, or nonsensical parts, while preserving its meaning. + The rewritten text should be a clean version of user's voice note. + Then, identify which events from the list this rewritten transcription matches to. For each matching event, extract the specific part of the REWRITTEN transcription that relates to it. Make the "extract" succinct and directly to the point, stripping out transitional context (e.g., rather than "During the flight I saw a beautiful sunset", output "I saw a beautiful sunset"). Never use the event's title or name as the "extract". + + Your response must be a valid JSON object strictly matching this structure: + {"rewritten": "...", "matches": [{"eventId": "...", "extract": "..."}]} + Respond strictly in valid JSON. + """ + .trimIndent() + + var attempts = 0 + val maxAttempts = 4 + var currentDelay = 1000L + var responseText = "" + + while (attempts < maxAttempts) { + try { + val request = generateContentRequest(TextPart(prompt)) {} + val response = generativeModel.generateContent(request) + responseText = response.candidates.firstOrNull()?.text?.trim() ?: "" + break + } catch (e: GenAiException) { + if (e.errorCode == 9) { // 9 is ErrorCode.BUSY + attempts++ + if (attempts >= maxAttempts) { + throw e + } + println( + "AICore is busy (ErrorCode 9), retrying in ${currentDelay}ms... (Attempt $attempts/$maxAttempts)" + ) + delay(currentDelay) + currentDelay *= 2 + } else { + throw e + } + } + } + + val json = JSONObject(responseText.removeSurrounding("```json", "```").trim()) + val matchesArray = json.optJSONArray("matches") + // TODO: Remove hardcoded title + val noteTitle = "May 20, 10:00 AM" + + if (matchesArray != null) { + for (i in 0 until matchesArray.length()) { + val obj = matchesArray.getJSONObject(i) + val eventId = obj.getString("eventId") + val extract = obj.getString("extract") + + events.value + .find { it.id == eventId } + ?.let { event -> + updateEvent(event.copy(audioNotes = event.audioNotes + extract)) + } + } + } + + val noteId = UUID.randomUUID().toString() + insertVoiceNote( + VoiceNoteEntity( + id = noteId, + tripId = tripId, + title = noteTitle, + transcription = "$translated |||| $original", + timestamp = System.currentTimeMillis(), + matchingEventsJson = matchesArray?.toString() ?: "", + ) + ) + } else { + throw Exception("LLM Model not available (status $status)") + } + } catch (e: Exception) { + println("Voice note processing failed: ${e.message}") + val errorIdx = processingNotes.indexOf(placeholder) + if (errorIdx != -1) { + processingNotes[errorIdx] = + placeholder.copy( + title = "Failed", + isError = true, + errorMessage = e.message ?: "Unknown error", + ) + delay(5000) + } + } finally { + processingNotes.remove(placeholder) + } + } + } + } +} diff --git a/jetpacker/android/feature/trip/voice_notes/src/screenshotTest/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesScreenshotTest.kt b/jetpacker/android/feature/trip/voice_notes/src/screenshotTest/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesScreenshotTest.kt new file mode 100644 index 00000000..bde20350 --- /dev/null +++ b/jetpacker/android/feature/trip/voice_notes/src/screenshotTest/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesScreenshotTest.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.voice_notes + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.android.tools.screenshot.PreviewTest + +class VoiceNotesScreenshotTest { + @PreviewTest + @Preview(showBackground = true) + @Composable + fun VoiceNoteScreenshotPreview() { + VoiceNoteItemPreview() + } +} diff --git a/jetpacker/android/feature/trip/voice_notes/src/screenshotTestDebug/reference/com/example/jetpacker/feature/voice_notes/VoiceNotesScreenshotTest/VoiceNoteScreenshotPreview_748aa731_0.png b/jetpacker/android/feature/trip/voice_notes/src/screenshotTestDebug/reference/com/example/jetpacker/feature/voice_notes/VoiceNotesScreenshotTest/VoiceNoteScreenshotPreview_748aa731_0.png new file mode 100644 index 00000000..e47680ee Binary files /dev/null and b/jetpacker/android/feature/trip/voice_notes/src/screenshotTestDebug/reference/com/example/jetpacker/feature/voice_notes/VoiceNotesScreenshotTest/VoiceNoteScreenshotPreview_748aa731_0.png differ diff --git a/jetpacker/android/feature/trip/voice_notes/src/test/AndroidManifest.xml b/jetpacker/android/feature/trip/voice_notes/src/test/AndroidManifest.xml new file mode 100644 index 00000000..b2d3ea12 --- /dev/null +++ b/jetpacker/android/feature/trip/voice_notes/src/test/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/jetpacker/android/feature/trip/voice_notes/src/test/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesViewModelTest.kt b/jetpacker/android/feature/trip/voice_notes/src/test/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesViewModelTest.kt new file mode 100644 index 00000000..b30ed412 --- /dev/null +++ b/jetpacker/android/feature/trip/voice_notes/src/test/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesViewModelTest.kt @@ -0,0 +1,399 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.voice_notes + +import androidx.lifecycle.SavedStateHandle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.jetpacker.core.speech.VoiceInputManager +import com.example.jetpacker.data.itinerary.ActivityDetail +import com.example.jetpacker.data.itinerary.DiningDetail +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.FlightDetail +import com.example.jetpacker.data.itinerary.HotelDetail +import com.example.jetpacker.data.itinerary.MuseumDetail +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TripSessionIdentifier +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.google.common.util.concurrent.ListenableFuture +import com.google.mlkit.genai.common.DownloadCallback +import com.google.mlkit.genai.common.DownloadStatus +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.common.StreamingCallback +import com.google.mlkit.genai.prompt.Caches +import com.google.mlkit.genai.prompt.CountTokensResponse +import com.google.mlkit.genai.prompt.GenerateContentRequest +import com.google.mlkit.genai.prompt.GenerateContentResponse +import com.google.mlkit.genai.prompt.GenerativeModel +import java.util.concurrent.ExecutorService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +class VoiceNotesViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @Test + fun deleteVoiceNote_removesExtractsFromEvents() = runTest { + val tripId = "trip123" + val eventId = "event456" + val extractText = "Meeting at 10am" + val matchingEventsJson = "[{\"eventId\":\"$eventId\", \"extract\":\"$extractText\"}]" + + val voiceNote = + VoiceNoteEntity( + id = "vn1", + tripId = tripId, + title = "Voice Note 1", + transcription = "transcription", + timestamp = 1000L, + matchingEventsJson = matchingEventsJson, + ) + + val event = + TimelineEvent( + id = eventId, + tripId = tripId, + type = EventType.ACTIVITY, + timestamp = 2000L, + title = "Activity", + location = "Office", + audioNotes = listOf(extractText, "Another note"), + ) + + var updatedEvent: TimelineEvent? = null + var deletedVoiceNoteId: String? = null + + val fakeEventDao = + object : EventDao { + override fun getAllSessionIds() = + flowOf(emptyList()) + + override fun getEventsForTrip(tripId: String) = flowOf(listOf(event)) + + override fun getVoiceNotesForTrip(tripId: String) = flowOf(listOf(voiceNote)) + + override suspend fun insertEvent(event: TimelineEvent) { + updatedEvent = event + } + + override suspend fun insertEvents(events: List) {} + + override suspend fun deleteEventsForTrip(tripId: String) {} + + override suspend fun deleteAllActivityDetails() {} + + override suspend fun deleteEvent(event: TimelineEvent) {} + + override suspend fun insertFlightDetail( + detail: FlightDetail + ) {} + + override suspend fun insertHotelDetail( + detail: HotelDetail + ) {} + + override suspend fun insertDiningDetail( + detail: DiningDetail + ) {} + + override suspend fun insertActivityDetail( + detail: ActivityDetail + ) {} + + override suspend fun insertMuseumDetail( + detail: MuseumDetail + ) {} + + override fun getFlightDetail(eventId: String) = flowOf(null) + + override fun getHotelDetail(eventId: String) = flowOf(null) + + override fun getDiningDetail(eventId: String) = flowOf(null) + + override fun getMuseumDetail(eventId: String) = flowOf(null) + + override fun getEventById(eventId: String) = flowOf(null) + + override suspend fun deleteAllEvents() {} + + override suspend fun deleteAllFlightDetails() {} + + override suspend fun deleteAllHotelDetails() {} + + override suspend fun deleteAllDiningDetails() {} + + override suspend fun insertVoiceNote(note: VoiceNoteEntity) {} + + override suspend fun deleteVoiceNoteById(id: String) { + deletedVoiceNoteId = id + } + } + + val viewModel = + VoiceNotesViewModel( + SavedStateHandle(mapOf("tripId" to tripId)), + fakeEventDao, + VoiceInputManager(), + ) + + // Start collecting to make StateFlow hot + val collectJob = launch { viewModel.events.collect {} } + advanceUntilIdle() + + viewModel.deleteVoiceNote(voiceNote) + advanceUntilIdle() + + assertEquals(listOf("Another note"), updatedEvent?.audioNotes) + assertEquals("vn1", deletedVoiceNoteId) + collectJob.cancel() + } + + @Test + fun processVoiceNote_retriesOnBusyWithExponentialBackoff() = runTest { + val tripId = "trip123" + val eventId = "event456" + + val event = + TimelineEvent( + id = eventId, + tripId = tripId, + type = EventType.ACTIVITY, + timestamp = 2000L, + title = "Activity", + location = "Office", + audioNotes = emptyList(), + ) + + var insertedVoiceNote: VoiceNoteEntity? = null + var updatedEvent: TimelineEvent? = null + + val fakeEventDao = + object : EventDao { + override fun getAllSessionIds() = + flowOf(emptyList()) + + override fun getEventsForTrip(tripId: String) = flowOf(listOf(event)) + + override fun getVoiceNotesForTrip(tripId: String) = flowOf(emptyList()) + + override suspend fun insertEvent(event: TimelineEvent) { + updatedEvent = event + } + + override suspend fun insertEvents(events: List) {} + + override suspend fun deleteEventsForTrip(tripId: String) {} + + override suspend fun deleteAllActivityDetails() {} + + override suspend fun deleteEvent(event: TimelineEvent) {} + + override suspend fun insertFlightDetail( + detail: com.example.jetpacker.data.itinerary.FlightDetail + ) {} + + override suspend fun insertHotelDetail( + detail: com.example.jetpacker.data.itinerary.HotelDetail + ) {} + + override suspend fun insertDiningDetail( + detail: com.example.jetpacker.data.itinerary.DiningDetail + ) {} + + override suspend fun insertActivityDetail( + detail: com.example.jetpacker.data.itinerary.ActivityDetail + ) {} + + override suspend fun insertMuseumDetail( + detail: com.example.jetpacker.data.itinerary.MuseumDetail + ) {} + + override fun getFlightDetail(eventId: String) = flowOf(null) + + override fun getHotelDetail(eventId: String) = flowOf(null) + + override fun getDiningDetail(eventId: String) = flowOf(null) + + override fun getMuseumDetail(eventId: String) = flowOf(null) + + override fun getEventById(eventId: String) = flowOf(null) + + override suspend fun deleteAllEvents() {} + + override suspend fun deleteAllFlightDetails() {} + + override suspend fun deleteAllHotelDetails() {} + + override suspend fun deleteAllDiningDetails() {} + + override suspend fun insertVoiceNote(note: VoiceNoteEntity) { + insertedVoiceNote = note + } + + override suspend fun deleteVoiceNoteById(id: String) {} + } + + val viewModel = + VoiceNotesViewModel( + SavedStateHandle(mapOf("tripId" to tripId)), + fakeEventDao, + VoiceInputManager(), + ) + + var callsCount = 0 + val busyException = GenAiException(null, 9) + val expectedRewriteJson = + """ + { + "rewritten": "Clean Rewritten Voice Note", + "matches": [ + { + "eventId": "$eventId", + "extract": "Clean Rewritten Voice Note" + } + ] + } + """ + .trimIndent() + + val fakeModel = FakeGenerativeModel { + callsCount++ + if (callsCount == 1) { + throw busyException + } + val candidateClass = Class.forName("com.google.mlkit.genai.prompt.Candidate") + val candidateCompanionField = candidateClass.getField("Companion") + val candidateCompanion = candidateCompanionField.get(null) + val createCandidateMethod = candidateCompanion.javaClass.getMethod("zza", String::class.java, java.lang.Integer::class.java) + val candidate = createCandidateMethod.invoke(candidateCompanion, expectedRewriteJson, 1) + + val responseClass = Class.forName("com.google.mlkit.genai.prompt.GenerateContentResponse") + val responseCompanionField = responseClass.getField("Companion") + val responseCompanion = responseCompanionField.get(null) + val createResponseMethod = responseCompanion.javaClass.getMethod("zza", java.util.List::class.java) + val response = createResponseMethod.invoke(responseCompanion, listOf(candidate)) as GenerateContentResponse + response + } + + viewModel.generativeModelTesting = fakeModel + + // Start collecting to make StateFlow hot + val collectJob = launch { viewModel.events.collect {} } + advanceUntilIdle() + + viewModel.processVoiceNote( + "Dirty raw voice note transcription.", + "Dirty raw voice note transcription.", + ) + advanceUntilIdle() + + assertEquals(2, callsCount) // 1st was BUSY, 2nd succeeded + assertEquals("Clean Rewritten Voice Note", updatedEvent?.audioNotes?.firstOrNull()) + assertEquals( + "Dirty raw voice note transcription. |||| Dirty raw voice note transcription.", + insertedVoiceNote?.transcription, + ) + + collectJob.cancel() + } + + private class FakeGenerativeModel( + private val onGenerate: () -> GenerateContentResponse + ) : GenerativeModel { + override val caches: Caches + get() = TODO("Not implemented") + + override suspend fun getBaseModelName(): String = TODO("Not implemented") + + override suspend fun checkStatus(): Int = 3 + + override suspend fun isCachingFeatureAvailable(): Boolean = TODO("Not implemented") + + + override fun download(): + Flow = + TODO("Not implemented") + + override fun downloadForFutures( + callback: DownloadCallback + ): ListenableFuture = TODO("Not implemented") + + override suspend fun warmup() = TODO("Not implemented") + + override fun warmupForFutures(): ListenableFuture = + TODO("Not implemented") + + override suspend fun countTokens( + request: GenerateContentRequest + ): CountTokensResponse = TODO("Not implemented") + + + override fun countTokensForFutures( + request: GenerateContentRequest + ): ListenableFuture< + CountTokensResponse + > = TODO("Not implemented") + + override suspend fun getTokenLimit(): Int = TODO("Not implemented") + + override fun getTokenLimitForFutures(): + ListenableFuture = TODO("Not implemented") + + override suspend fun generateContent( + request: GenerateContentRequest + ): GenerateContentResponse = onGenerate() + + override suspend fun generateContent( + request: GenerateContentRequest, + streamingCallback: StreamingCallback, + ): GenerateContentResponse = TODO("Not implemented") + + override fun generateContentStream( + request: GenerateContentRequest + ): Flow = + TODO("Not implemented") + + + override suspend fun clearImplicitCaches() = TODO("Not implemented") + + override fun clearImplicitCachesForFutures(): + ListenableFuture = TODO("Not implemented") + + override fun close() {} + + override fun getWorkerExecutor(): ExecutorService = TODO("Not implemented") + } +} diff --git a/jetpacker/android/gradle.properties b/jetpacker/android/gradle.properties new file mode 100644 index 00000000..46450222 --- /dev/null +++ b/jetpacker/android/gradle.properties @@ -0,0 +1,19 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# Kotlin code style for this project: "official" or "obsolete": +android.experimental.enableScreenshotTest=true +ksp.incremental=true +ksp.incremental.intermodule=true +android.nonTransitiveRClass=false + diff --git a/jetpacker/android/gradle/gradle-daemon-jvm.properties b/jetpacker/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..6c1139ec --- /dev/null +++ b/jetpacker/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/jetpacker/android/gradle/libs.versions.toml b/jetpacker/android/gradle/libs.versions.toml new file mode 100644 index 00000000..f117b439 --- /dev/null +++ b/jetpacker/android/gradle/libs.versions.toml @@ -0,0 +1,133 @@ +[versions] +accompanistPermissions = "0.37.3" +activityCompose = "1.13.0" +agp = "9.2.1" +cameraCamera2 = "1.5.0" +cameraCore = "1.5.0" +cameraLifecycle = "1.5.0" +cameraView = "1.5.0" +coilCompose = "2.7.0" +compileSdk = "37" +compileSdkMinor = "0" +composeBom = "2026.03.00" +composeScreenshot = "0.0.1-alpha15" +converterMoshi = "2.12.0" +core = "1.6.1" +coreKtx = "1.17.0" +coreSplashscreen = "1.2.0" +datastoreCore = "1.1.7" +espressoCore = "3.7.0" +firebaseAi = "17.10.1" +firebaseAiOndevice = "16.0.0-beta01" +firebaseBom = "34.12.0" +googleDevtoolsKsp = "2.3.5" +googleServices = "4.4.1" +hilt = "2.59.2" +junit = "4.13.2" +junitVersion = "1.3.0" +kotlin = "2.3.10" +kotlinxCoroutinesAndroid = "1.10.2" +kotlinxCoroutinesCore = "1.10.2" +kotlinxCoroutinesTest = "1.10.2" +kotlinxSerializationJson = "1.8.0" +lifecycleRuntimeCompose = "2.8.7" +lifecycleRuntimeKtx = "2.8.7" +lifecycleViewmodelCompose = "2.8.7" +loggingInterceptor = "4.10.0" +material = "1.14.0-alpha01" +minSdk = "26" +moshiKotlin = "1.15.2" +moshiKotlinCodegen = "1.15.2" +nav3Core = "1.1.3" +okhttp = "4.10.0" +playServicesLocation = "21.3.0" +retrofit = "2.12.0" +robolectric = "4.12.2" +roomCompiler = "2.7.0" +roomKtx = "2.7.0" +roomRuntime = "2.7.0" +runner = "1.6.2" +targetSdk = "36" +truth = "1.4.2" +versionCode = "1" +versionName = "1.0" +appfunctions = "1.0.0-alpha10" + +[libraries] +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraCamera2" } +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraCore" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraLifecycle" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraView" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.5.0-alpha16" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } +androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomCompiler" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomKtx" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomRuntime" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } +converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "converterMoshi" } +firebase-ai = { group = "com.google.firebase", name = "firebase-ai", version.ref = "firebaseAi" } +firebase-ai-ondevice = { group = "com.google.firebase", name = "firebase-ai-ondevice", version.ref = "firebaseAiOndevice" } +firebase-auth-ktx = { group = "com.google.firebase", name = "firebase-auth" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore" } +google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesCore" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version = "1.0.0-beta2" } +mlkit-genai-speech = { group = "com.google.mlkit", name = "genai-speech-recognition", version = "1.0.0-alpha1" } +mlkit-translate = { group = "com.google.mlkit", name = "translate", version = "17.0.3" } +moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshiKotlin" } +moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshiKotlinCodegen" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "composeScreenshot" } +mlkit-language-id = { group = "com.google.android.gms", name = "play-services-mlkit-language-id", version = "17.0.0" } +firebase-appcheck-playintegrity = { group = "com.google.firebase", name = "firebase-appcheck-playintegrity" } +firebase-appcheck-debug = { group = "com.google.firebase", name = "firebase-appcheck-debug" } +androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" } +androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "composeScreenshot" } +android-library = { id = "com.android.library", version.ref = "agp" } +dependency-license-report = { id = "com.github.jk1.dependency-license-report", version = "3.1.2" } +google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "googleDevtoolsKsp" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } + diff --git a/jetpacker/android/gradle/scripts/add_license_headers.py b/jetpacker/android/gradle/scripts/add_license_headers.py new file mode 100755 index 00000000..5ae4ef1b --- /dev/null +++ b/jetpacker/android/gradle/scripts/add_license_headers.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys + +KOTLIN_HEADER = """/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +""" + +SCRIPT_HEADER = """# Copyright 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" + +XML_HEADER = """ + +""" + +def process_file(file_path): + # Handle .gradle.kts correctly + if file_path.endswith('.gradle.kts'): + ext = '.gradle.kts' + else: + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + if ext not in ['.kt', '.py', '.sh', '.xml', '.gradle.kts', '.gradle']: + return + + # Skip gradle wrapper + if 'gradlew' in file_path: + return + + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + # Check if copyright notice is already present + if "The Android Open Source Project" in content or "Copyright (C)" in content or "Copyright ©" in content or "Copyright 2015" in content: + print(f"Skipping (already has header): {file_path}") + return + + print(f"Adding header to: {file_path}") + + if ext in ['.kt', '.gradle.kts', '.gradle']: + new_content = KOTLIN_HEADER + content + elif ext in ['.py', '.sh']: + # If it has a shebang, insert header after it + if content.startswith('#!'): + lines = content.split('\n') + shebang = lines[0] + rest = '\n'.join(lines[1:]) + new_content = shebang + '\n' + SCRIPT_HEADER + rest + else: + new_content = SCRIPT_HEADER + content + elif ext == '.xml': + # If it has an xml declaration, insert header after it + if content.strip().startswith(' '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/jetpacker/android/gradlew.bat b/jetpacker/android/gradlew.bat new file mode 100644 index 00000000..4f71c6bb --- /dev/null +++ b/jetpacker/android/gradlew.bat @@ -0,0 +1,189 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/jetpacker/android/local.properties.example b/jetpacker/android/local.properties.example new file mode 100644 index 00000000..3d092779 --- /dev/null +++ b/jetpacker/android/local.properties.example @@ -0,0 +1,10 @@ +# +# This is an example local.properties file. +# Copy this file to local.properties and customize the values for your local environment. +# + +# Location of the Android SDK on your machine. +# On macOS: /Users//Library/Android/sdk +# On Windows: C:\\Users\\\\AppData\\Local\\Android\\Sdk +# On Linux: /home//Android/Sdk +sdk.dir=/path/to/your/sdk diff --git a/jetpacker/android/settings.gradle.kts b/jetpacker/android/settings.gradle.kts new file mode 100644 index 00000000..b19b5638 --- /dev/null +++ b/jetpacker/android/settings.gradle.kts @@ -0,0 +1,75 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "JetPacker" + +include(":app") + +include(":core:flags") + +include(":core:speech") + +include(":core:ui") + +include(":data:db") + +include(":data:itinerary") + +include(":data:trips") + +include(":feature:create_trip") + +include(":feature:detail") +include(":feature:detail:museum_assistant") +include(":feature:detail:hotel_chat") +include(":feature:detail:review") + +include(":feature:home") + +include(":feature:trip") + +include(":feature:trip:itinerary") + +include(":feature:trip:expenses") + +include(":feature:trip:voice_notes") + +include(":feature:trip:itinerary:enrichment") +include(":feature:appfunctions") diff --git a/jetpacker/install.sh b/jetpacker/install.sh new file mode 100755 index 00000000..28b51a8f --- /dev/null +++ b/jetpacker/install.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Copyright 2026 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# install.sh +# Deploys the JetPacker server locally, sets up port forwarding, and installs the app. + +set -e + +echo "==================================================================" +echo " IMPORTANT: JetPacker ML features require Nano 4 to be installed." +echo " Please see the developer guide for setup instructions." +echo "==================================================================" +echo "" + +# Check for adb +if ! command -v adb &> /dev/null; then + echo "Error: adb is not found in your PATH." + echo "Please ensure Android SDK Platform-Tools are installed." + exit 1 +fi + +echo "[1/4] Installing JetPacker app on the connected device..." +adb install -r app.apk + +echo "[2/4] Setting up port forwarding (tcp:8000 -> tcp:8000)..." +adb reverse tcp:8000 tcp:8000 + +echo "[3/4] Setting up Python virtual environment..." +python3 -m venv .venv +source .venv/bin/activate + +echo "Installing server dependencies inside virtualenv..." +python3 -m pip install --quiet --extra-index-url https://pypi.org/simple fastapi uvicorn pydantic + +echo "[4/4] Starting local server..." +echo "The server will run on port 8000. Press Ctrl+C to stop." +cd server +python3 -m uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/jetpacker/journeys/basic_app_verification.xml b/jetpacker/journeys/basic_app_verification.xml new file mode 100644 index 00000000..509888f7 --- /dev/null +++ b/jetpacker/journeys/basic_app_verification.xml @@ -0,0 +1,71 @@ + + + + Verifies the core UI layout, itinerary timeline navigation, detail screens rendering, and trip creation flow for the JetPacker base-app version without intelligent features using live visual screenshot comparisons. + + + + Launch the JetPacker MainActivity (`com.example.jetpacker/.MainActivity`) + + + Verify by capturing a live screenshot against baseline expectations that the app is on its Home screen dashboard, showing the top toolbar and prepopulated trip cards without text truncation or layout overlap + + + Tap on the first trip card ("California I/O") + + + Verify by capturing a live screenshot that the app navigates to the Itinerary screen showing timeline events (Flights, Hotels, Dining) and confirm via visual inspection that no AI summary card ("Trip summaries and tips") is rendered + + + Tap on the Flight event card in the timeline + + + Verify by capturing a live screenshot that the Flight Detail screen is displayed with complete airline, route, and boarding schedule information cleanly formatted + + + Tap the system back button + + + Tap on the Hotel event card in the timeline + + + Verify by capturing a live screenshot that the Hotel Detail screen is displayed with check-in and check-out schedule cards properly aligned + + + Tap the system back button twice to return to the Home screen dashboard + + + Tap the "+ Create trip" Extended Floating Action Button + + + Verify by capturing a live screenshot that the Create Trip screen form is displayed with input fields for title, location, date picker, and participants cleanly rendered + + + Enter trip title "Alpine Escape" and location "Swiss Alps", pick dates, and tap the Save Trip button + + + Verify that the app returns to the Home screen dashboard and displays the newly created "Alpine Escape" trip card + + + Tap on the newly created "Alpine Escape" trip card to open its Itinerary screen + + + Tap the "Edit Trip" action icon in the top navigation bar + + + Update the trip title from "Alpine Escape" to "Alpine Adventure" and save the changes + + + Verify that the Itinerary top bar reflects the updated title "Alpine Adventure" + + + Tap the system back button to return to the Home screen dashboard + + + Perform a swipe-to-delete gesture on the "Alpine Adventure" trip card and confirm deletion in the confirmation bottom sheet + + + Verify by capturing a live screenshot that the "Alpine Adventure" trip has been cleanly removed from the Home screen dashboard + + + diff --git a/jetpacker/journeys/cloud_hybrid_features_verification.xml b/jetpacker/journeys/cloud_hybrid_features_verification.xml new file mode 100644 index 00000000..375d6ea1 --- /dev/null +++ b/jetpacker/journeys/cloud_hybrid_features_verification.xml @@ -0,0 +1,71 @@ + + + + Verifies the cloud-hybrid and online intelligent features of JetPacker including the Museum Assistant, Review Generation, and Hotel Chat with Translation using live visual screenshot comparisons. + + + + Launch the JetPacker MainActivity (`com.example.jetpacker/.MainActivity`) + + + Simulate a device shake gesture to open the AI Feature Debug Screen + + + Verify by capturing a live screenshot that the Online Features are enabled (Review Generation, Museum Assistant, and Grounding flags checked) + + + Tap the back button to return to the Home screen dashboard + + + Tap on the "Romantic Paris" trip card to open its Itinerary screen + + + Tap on the "Visit to the Louvre" event card in the timeline + + + Scroll down on the Museum Detail screen and tap the "Open assistant" button + + + Verify that the Museum Assistant chat screen is displayed, type "What are the opening hours?" and tap Send + + + Verify by capturing a live screenshot that the message is sent and a response is returned from the AI assistant + + + Tap the system back button twice to return to the Itinerary screen + + + Tap on the "Dinner at Le Jules Verne" dining event card in the timeline + + + Tap the "Write a review" button on the Restaurant Detail screen + + + Select "Food Quality" and "Service" positive chips on the Review Topics screen and tap the "Generate Review" button + + + Verify by capturing a live screenshot that the AI generated review text field is populated and the "Post Review" action is visible + + + Tap the system back button twice to return to the Itinerary screen + + + Tap on the "Check-in: Hotel Le Meurice" stay event card in the timeline + + + Tap the "Chat with staff" button on the Hotel Detail screen + + + Verify that the Hotel Support Chat screen is displayed, type "I would like to check in early." and tap Send + + + Verify by capturing a live screenshot that the message is sent, a receptionist response is returned (e.g. in French), and the "Translate to English" option is visible + + + Tap "Translate to English" + + + Verify by capturing a live screenshot that the message gets translated in-place + + + diff --git a/jetpacker/journeys/intelligent_features_verification.xml b/jetpacker/journeys/intelligent_features_verification.xml new file mode 100644 index 00000000..0b63b777 --- /dev/null +++ b/jetpacker/journeys/intelligent_features_verification.xml @@ -0,0 +1,32 @@ + + + + Verifies the on-device intelligent features of JetPacker including Trip Summaries and Tips, Voice Notes recording, and Expense Management using live visual screenshot comparisons. + + + + Launch the JetPacker MainActivity (`com.example.jetpacker/.MainActivity`) + + + Verify by capturing a live screenshot that the Home screen renders the Extended Floating Action Button containing both the standard add trip icon and the voice trip microphone input trigger + + + Tap on the first trip card ("California I/O") + + + Verify by capturing a live screenshot that the Itinerary screen renders the AI summary card ("Trip summaries and tips") prominently formatted above the timeline events list + + + Tap on the Voice Notes tab icon in the bottom navigation bar (`x=688, y=2221`) + + + Verify by capturing a live screenshot against visual baseline expectations that the Voice Notes screen displays the audio recorder controls and any recorded note cards without clipping + + + Tap on the Expenses tab icon in the bottom navigation bar (`x=450, y=2221`) + + + Verify by capturing a live screenshot against visual baseline expectations that the Manage Expenses screen renders the currency conversion rate cards and receipt scanner action button cleanly + + + diff --git a/jetpacker/screenshots/detail.png b/jetpacker/screenshots/detail.png new file mode 100644 index 00000000..fb47e85c Binary files /dev/null and b/jetpacker/screenshots/detail.png differ diff --git a/jetpacker/screenshots/home.png b/jetpacker/screenshots/home.png new file mode 100644 index 00000000..a7d87695 Binary files /dev/null and b/jetpacker/screenshots/home.png differ diff --git a/jetpacker/screenshots/trip.png b/jetpacker/screenshots/trip.png new file mode 100644 index 00000000..d63408fd Binary files /dev/null and b/jetpacker/screenshots/trip.png differ