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**
 | 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**
 | 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 Screen |
+ Trip Itinerary |
+ Flight Detail |
+
+
+  |
+  |
+  |
+
+
+
+## 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