From 2b384b8c67746b5500429e6d467a97ed13d62ac6 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Tue, 27 Oct 2026 15:01:16 +0000 Subject: [PATCH 1/3] Part 2: Trip Assistant feature (FAB, Chat History & Assistant ViewModel) - Port Gemini Nano on-device travel assistant (:feature:assistant) - Add Ambient FAB with overlay bottom sheet on TripScreen - Implement ChatHistoryScreen with SQLite persistence via AssistantConversationDao - Integrate TravelAssistantViewModel with AppFunctions runtime - Add VoiceInputManager with live on-device speech recognition and translation - Address review comments: exact title matching for expenses, dynamic budget calculation in USD, and clean Google Maven GenAI dependencies --- jetpacker/android/app/build.gradle.kts | 1 + .../jetpacker/core/flags/FeatureFlags.kt | 76 +- .../core/speech/VoiceInputManager.kt | 254 +-- .../jetpacker/data/db/DatabaseModule.kt | 6 + .../jetpacker/data/db/JetPackerDatabase.kt | 7 +- .../data/itinerary/AssistantConversation.kt | 36 + .../itinerary/AssistantConversationDao.kt | 54 + .../jetpacker/data/itinerary/EventDao.kt | 4 +- .../data/itinerary/EventSubclassEntities.kt | 2 +- .../jetpacker/data/itinerary/ExpenseDao.kt | 4 +- .../jetpacker/data/itinerary/TimelineEvent.kt | 2 +- .../jetpacker/data/itinerary/TourDetailDao.kt | 4 +- .../example/jetpacker/data/trips/DummyData.kt | 462 +++-- .../com/example/jetpacker/data/trips/Trip.kt | 2 + .../JetPackerAppFunctionService.kt | 1255 ++++++++++--- .../feature/appfunctions/TripSerializable.kt | 216 ++- .../feature/assistant/build.gradle.kts | 87 + .../assistant/src/main/AndroidManifest.xml | 20 + .../assistant/TravelAssistantViewModel.kt | 1559 ++++++++++++++++ .../assistant/history/ChatHistoryScreen.kt | 656 +++++++ .../assistant/history/ChatHistoryViewModel.kt | 180 ++ .../history/ConversationSerialization.kt | 134 ++ .../assistant/model/TravelAgentUiState.kt | 177 ++ .../assistant/model/VoiceTranscriptUi.kt | 42 + .../feature/assistant/tools/TravelDaoTools.kt | 1099 ++++++++++++ .../assistant/ui/TravelAssistantCard.kt | 1562 +++++++++++++++++ .../assistant/ChatHistoryViewModelTest.kt | 184 ++ .../ConversationSerializationTest.kt | 159 ++ .../feature/assistant/TravelDaoToolsTest.kt | 425 +++++ .../android/feature/trip/build.gradle.kts | 1 + .../feature/expenses/ManageExpensesScreen.kt | 18 +- .../jetpacker/feature/trip/TripScreen.kt | 210 ++- .../voice_notes/VoiceNotesViewModelTest.kt | 44 +- jetpacker/android/gradle/libs.versions.toml | 23 +- jetpacker/android/settings.gradle.kts | 1 + 35 files changed, 8325 insertions(+), 641 deletions(-) create mode 100644 jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/AssistantConversation.kt create mode 100644 jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/AssistantConversationDao.kt create mode 100644 jetpacker/android/feature/assistant/build.gradle.kts create mode 100644 jetpacker/android/feature/assistant/src/main/AndroidManifest.xml create mode 100644 jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt create mode 100644 jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ChatHistoryScreen.kt create mode 100644 jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ChatHistoryViewModel.kt create mode 100644 jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ConversationSerialization.kt create mode 100644 jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/TravelAgentUiState.kt create mode 100644 jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/VoiceTranscriptUi.kt create mode 100644 jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt create mode 100644 jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/ui/TravelAssistantCard.kt create mode 100644 jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/ChatHistoryViewModelTest.kt create mode 100644 jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/ConversationSerializationTest.kt create mode 100644 jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelDaoToolsTest.kt diff --git a/jetpacker/android/app/build.gradle.kts b/jetpacker/android/app/build.gradle.kts index 4f792c4a..c1a0081d 100644 --- a/jetpacker/android/app/build.gradle.kts +++ b/jetpacker/android/app/build.gradle.kts @@ -105,6 +105,7 @@ dependencies { implementation(project(":data:trips")) implementation(project(":feature:appfunctions")) implementation(libs.androidx.appfunctions) + implementation(project(":feature:assistant")) implementation(project(":feature:create_trip")) implementation(project(":feature:detail")) implementation(project(":feature:detail:museum_assistant")) 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 index 19ca20ca..c5b1bf40 100644 --- 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 @@ -19,6 +19,11 @@ package com.example.jetpacker.core.flags import android.content.Context object FeatureFlags { + const val KEY_DEMO_PRESET = "demo_preset" + const val PRESET_ADS_003 = "ads_003" + const val PRESET_DEMO_BOOTH = "demo_booth" + const val PRESET_CUSTOM = "custom" + 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" @@ -32,11 +37,54 @@ object FeatureFlags { const val KEY_ENABLE_URL_GROUNDING = "enable_url_grounding" const val KEY_ENABLE_SEARCH_GROUNDING = "enable_search_grounding" const val KEY_ENABLE_BOOKING_ASSISTANT = "enable_booking_assistant" + const val KEY_ENABLE_TRAVEL_ASSISTANT = "enable_travel_assistant" + const val KEY_ENABLE_DOCUMENTS = "enable_documents" private var appContext: Context? = null fun initialize(context: Context) { appContext = context.applicationContext + val prefs = appContext?.getSharedPreferences("debug_settings", Context.MODE_PRIVATE) + if (prefs != null && !prefs.contains(KEY_DEMO_PRESET)) { + applyPreset(context, PRESET_ADS_003) + } + } + + fun applyPreset(context: Context, preset: String) { + val editor = context.getSharedPreferences("debug_settings", Context.MODE_PRIVATE).edit() + when (preset) { + PRESET_ADS_003 -> { + editor + .putString(KEY_DEMO_PRESET, PRESET_ADS_003) + .putBoolean(KEY_ENABLE_TRAVEL_ASSISTANT, true) + .putBoolean(KEY_ENABLE_EXPENSE_MANAGEMENT, true) + .putBoolean(KEY_ENABLE_ITINERARY_ENRICHMENT, false) + .putBoolean(KEY_ENABLE_TRIP_SUMMARY_AND_TIPS, false) + .putBoolean(KEY_ENABLE_VOICE_NOTES, false) + .putBoolean(KEY_ENABLE_DOCUMENTS, false) + .putBoolean(KEY_ENABLE_BOOKING_ASSISTANT, false) + .putBoolean(KEY_ENABLE_REVIEW_GENERATION, false) + .putBoolean(KEY_ENABLE_MUSEUM_ASSISTANT, false) + .putBoolean(KEY_ENABLE_URL_GROUNDING, false) + .putBoolean(KEY_ENABLE_SEARCH_GROUNDING, false) + } + PRESET_DEMO_BOOTH -> { + editor + .putString(KEY_DEMO_PRESET, PRESET_DEMO_BOOTH) + .putBoolean(KEY_ENABLE_TRAVEL_ASSISTANT, true) + .putBoolean(KEY_ENABLE_EXPENSE_MANAGEMENT, true) + .putBoolean(KEY_ENABLE_ITINERARY_ENRICHMENT, true) + .putBoolean(KEY_ENABLE_TRIP_SUMMARY_AND_TIPS, true) + .putBoolean(KEY_ENABLE_VOICE_NOTES, true) + .putBoolean(KEY_ENABLE_DOCUMENTS, true) + .putBoolean(KEY_ENABLE_BOOKING_ASSISTANT, true) + .putBoolean(KEY_ENABLE_REVIEW_GENERATION, true) + .putBoolean(KEY_ENABLE_MUSEUM_ASSISTANT, true) + .putBoolean(KEY_ENABLE_URL_GROUNDING, true) + .putBoolean(KEY_ENABLE_SEARCH_GROUNDING, true) + } + } + editor.apply() } private fun readFlag(keyName: String, defaultValue: Boolean): Boolean { @@ -53,44 +101,53 @@ object FeatureFlags { .getString(keyName, defaultValue) ?: defaultValue } + val DEMO_PRESET: String + get() = readStringFlag(KEY_DEMO_PRESET, PRESET_ADS_003) + // Voice / Demo Language Settings - val DEFAULT_DEMO_LANGUAGE = "nl" + val DEFAULT_DEMO_LANGUAGE = "en" 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) + get() = readFlag(KEY_ENABLE_TRIP_SUMMARY_AND_TIPS, false) 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) + get() = readFlag(KEY_ENABLE_ITINERARY_ENRICHMENT, false) val ENABLE_EXPENSE_MANAGEMENT: Boolean get() = readFlag(KEY_ENABLE_EXPENSE_MANAGEMENT, true) val ENABLE_VOICE_NOTES: Boolean - get() = readFlag(KEY_ENABLE_VOICE_NOTES, true) + get() = readFlag(KEY_ENABLE_VOICE_NOTES, false) + + val ENABLE_DOCUMENTS: Boolean + get() = readFlag(KEY_ENABLE_DOCUMENTS, false) // Online features val ENABLE_REVIEW_GENERATION: Boolean - get() = readFlag(KEY_ENABLE_REVIEW_GENERATION, true) + get() = readFlag(KEY_ENABLE_REVIEW_GENERATION, false) val ENABLE_MUSEUM_ASSISTANT: Boolean - get() = readFlag(KEY_ENABLE_MUSEUM_ASSISTANT, true) + get() = readFlag(KEY_ENABLE_MUSEUM_ASSISTANT, false) val ENABLE_URL_GROUNDING: Boolean - get() = readFlag(KEY_ENABLE_URL_GROUNDING, true) + get() = readFlag(KEY_ENABLE_URL_GROUNDING, false) val ENABLE_SEARCH_GROUNDING: Boolean - get() = readFlag(KEY_ENABLE_SEARCH_GROUNDING, true) + get() = readFlag(KEY_ENABLE_SEARCH_GROUNDING, false) val ENABLE_BOOKING_ASSISTANT: Boolean - get() = readFlag(KEY_ENABLE_BOOKING_ASSISTANT, true) + get() = readFlag(KEY_ENABLE_BOOKING_ASSISTANT, false) + + val ENABLE_TRAVEL_ASSISTANT: Boolean + get() = readFlag(KEY_ENABLE_TRAVEL_ASSISTANT, true) private fun readLongFlag(keyName: String, defaultValue: Long): Long { val context = appContext ?: return defaultValue @@ -110,6 +167,7 @@ object FeatureFlags { .getSharedPreferences("debug_settings", Context.MODE_PRIVATE) .edit() .putBoolean(keyName, value) + .putString(KEY_DEMO_PRESET, PRESET_CUSTOM) .apply() } 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 index f1ea8296..81650216 100644 --- 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 @@ -35,10 +35,13 @@ import com.google.mlkit.nl.translate.TranslatorOptions import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -49,15 +52,21 @@ import kotlinx.coroutines.tasks.await @Singleton @SuppressLint("NewApi", "GlobalCoroutineDispatchers") class VoiceInputManager @Inject constructor() { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + Log.e("VoiceInputManager", "Uncaught exception in VoiceInputManager scope", throwable) + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + exceptionHandler) 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 autoStopJob: Job? = null + private var statusCheckJob: Job? = null private var hasRecordAudioPermission = false private var autoStartListeningWhenReady = false + private var hasDeliveredResult = false private var queuedOnResult: ((original: String, translated: String) -> Unit)? = null init { @@ -176,101 +185,114 @@ class VoiceInputManager @Inject constructor() { ) 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 { + if (statusCheckJob?.isActive == true) { + Log.d( + "VoiceInputManager", + "checkModelStatus: Status check already active, skipping duplicate invocation.", + ) + return + } + statusCheckJob = scope.launch { + try { + 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: Preemptively warming up SpeechRecognizer model...", + "checkModelStatus collect: Download completed successfully!", ) - try { - // recognizer.warmup() + _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: SpeechRecognizer model warm up completed successfully.", - ) - } catch (e: Exception) { - Log.e( - "VoiceInputManager", - "checkModelStatus: Preemptive SpeechRecognizer warm up failed.", - e, + "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) } - 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 { + } else if (status == FeatureStatus.AVAILABLE) { Log.d( "VoiceInputManager", - "checkModelStatus: Preemptively warming up SpeechRecognizer model...", + "checkModelStatus: Speech model is fully available locally. Making ready.", ) - try { - // recognizer.warmup() + _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: SpeechRecognizer model warm up completed successfully.", - ) - } catch (e: Exception) { - Log.e( - "VoiceInputManager", - "checkModelStatus: Preemptive SpeechRecognizer warm up failed.", - e, + "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) } - } 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) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e("VoiceInputManager", "checkModelStatus: Error checking or downloading speech model", e) } } } @@ -287,8 +309,9 @@ class VoiceInputManager @Inject constructor() { ) autoStartListeningWhenReady = false val recognizer = speechRecognizer ?: return - val onResult = queuedOnResult - startListening(recognizer, onResult) + val callback = queuedOnResult + queuedOnResult = null + startListening(recognizer, callback) } } @@ -326,6 +349,33 @@ class VoiceInputManager @Inject constructor() { initSpeechAndTranslation() + if (speechRecognizer == null) { + _uiState.value = + _uiState.value.copy( + statusText = "Speech recognizer could not be initialized.", + isReady = false, + isError = true, + showDialog = true, + ) + 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: Constructing fresh single-use SpeechRecognizer for instant unshared rebind.", @@ -368,21 +418,6 @@ class VoiceInputManager @Inject constructor() { 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) } @@ -396,6 +431,8 @@ class VoiceInputManager @Inject constructor() { "VoiceInputManager", "startListening: Waking up microphone and opening SODA continuous streaming collection.", ) + autoStopJob?.cancel() + hasDeliveredResult = false _uiState.value = _uiState.value.copy( isListening = true, @@ -425,9 +462,11 @@ class VoiceInputManager @Inject constructor() { var lastError: Throwable? = null var lastVoiceActivityTime = System.currentTimeMillis() - val autoStopJob = scope.launch { + autoStopJob?.cancel() + autoStopJob = scope.launch { while (isActive) { - kotlinx.coroutines.delay(500) + delay(500) + if (!_uiState.value.isListening || hasDeliveredResult) break if (System.currentTimeMillis() - lastVoiceActivityTime > 3000L) { Log.d("VoiceInputManager", "startListening: Auto-stopping due to silence.") stopListening(onResult) @@ -484,6 +523,7 @@ class VoiceInputManager @Inject constructor() { "VoiceInputManager", "startRecognition collect: CompletedResponse received naturally from SODA stream.", ) + autoStopJob?.cancel() if (_uiState.value.isListening) { finishListening(onResult) } @@ -511,8 +551,6 @@ class VoiceInputManager @Inject constructor() { } } - autoStopJob.cancel() - if (errorOccurred) { val isRetryable = lastError?.message?.contains("ERROR_TYPE_INVALID_REQUEST") == true if (isRetryable && retryCount < maxRetries - 1) { @@ -526,7 +564,7 @@ class VoiceInputManager @Inject constructor() { try { currentRecognizer.close() } catch (e: Exception) {} - kotlinx.coroutines.delay(1500) + delay(1500) val currentLang = FeatureFlags.DEMO_LANGUAGE try { val speechOptions = speechRecognizerOptions { @@ -553,7 +591,7 @@ class VoiceInputManager @Inject constructor() { break } } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e + if (e is CancellationException) throw e Log.e( "VoiceInputManager", "startListening exception: Continuous recognition loop failed.", @@ -567,7 +605,7 @@ class VoiceInputManager @Inject constructor() { try { currentRecognizer.close() } catch (ex: Exception) {} - kotlinx.coroutines.delay(1500) + delay(1500) val currentLang = FeatureFlags.DEMO_LANGUAGE try { val speechOptions = speechRecognizerOptions { @@ -589,6 +627,7 @@ class VoiceInputManager @Inject constructor() { break } } finally { + autoStopJob?.cancel() Log.d( "VoiceInputManager", "startListening: stopping recognizer in attempt loop finalizer.", @@ -604,6 +643,7 @@ class VoiceInputManager @Inject constructor() { private suspend fun stopListening( onResult: ((original: String, translated: String) -> Unit)? = null ) { + autoStopJob?.cancel() _uiState.value = _uiState.value.copy( isListening = false, @@ -613,7 +653,7 @@ class VoiceInputManager @Inject constructor() { ) try { speechRecognizer?.stopRecognition() - kotlinx.coroutines.delay(200) + delay(200) } catch (e: Exception) { Log.e("VoiceInputManager", "Failed to stop recognition gracefully", e) } @@ -622,6 +662,8 @@ class VoiceInputManager @Inject constructor() { } private fun finishListening(onResult: ((original: String, translated: String) -> Unit)? = null) { + if (hasDeliveredResult) return + hasDeliveredResult = true val original = (_uiState.value.transcription + _uiState.value.partialTranscription).trim() val translated = _uiState.value.translatedTranscription.trim() val finalResult = translated.ifEmpty { original } @@ -654,6 +696,7 @@ class VoiceInputManager @Inject constructor() { fun cancelListening() { Log.d("VoiceInputManager", "cancelListening called") + autoStopJob?.cancel() autoStartListeningWhenReady = false queuedOnResult = null _uiState.value = @@ -668,7 +711,7 @@ class VoiceInputManager @Inject constructor() { scope.launch { try { recognizer?.stopRecognition() - kotlinx.coroutines.delay(200) + delay(200) } catch (e: Exception) { Log.e("VoiceInputManager", "Failed to stop recognition during cancel", e) } @@ -683,7 +726,7 @@ class VoiceInputManager @Inject constructor() { return try { currentTranslator.translate(text).await() } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e + if (e is CancellationException) throw e text } } @@ -707,6 +750,7 @@ class VoiceInputManager @Inject constructor() { } fun close() { + autoStopJob?.cancel() listenJob?.cancel() } } 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 index 356828b3..7ae4d4d2 100644 --- 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 @@ -18,6 +18,7 @@ package com.example.jetpacker.data.db import android.content.Context import androidx.room.Room +import com.example.jetpacker.data.itinerary.AssistantConversationDao import com.example.jetpacker.data.itinerary.DayThemeDao import com.example.jetpacker.data.itinerary.EventDao import com.example.jetpacker.data.itinerary.ExpenseDao @@ -66,4 +67,9 @@ object DatabaseModule { fun provideExpenseDao(db: JetPackerDatabase): ExpenseDao { return db.expenseDao() } + + @Provides + fun provideAssistantConversationDao(db: JetPackerDatabase): AssistantConversationDao { + return db.assistantConversationDao() + } } 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 index b3a9fb7a..d2cfe3c0 100644 --- 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 @@ -19,6 +19,8 @@ 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.AssistantConversation +import com.example.jetpacker.data.itinerary.AssistantConversationDao import com.example.jetpacker.data.itinerary.DayTheme import com.example.jetpacker.data.itinerary.DayThemeDao import com.example.jetpacker.data.itinerary.DiningDetail @@ -49,8 +51,9 @@ import com.example.jetpacker.data.trips.TripDao MuseumDetail::class, Expense::class, VoiceNoteEntity::class, + AssistantConversation::class, ], - version = 1, + version = 4, exportSchema = false, ) abstract class JetPackerDatabase : RoomDatabase() { @@ -63,4 +66,6 @@ abstract class JetPackerDatabase : RoomDatabase() { abstract fun dayThemeDao(): DayThemeDao abstract fun expenseDao(): ExpenseDao + + abstract fun assistantConversationDao(): AssistantConversationDao } diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/AssistantConversation.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/AssistantConversation.kt new file mode 100644 index 00000000..9d4af4dd --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/AssistantConversation.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.itinerary + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Represents a historical conversation session between the traveler and the Travel Assistant. + * + * Stored per trip with [timestamp] for reverse chronological ordering ("most recent at the top"). + */ +@Entity(tableName = "assistant_conversations") +data class AssistantConversation( + @PrimaryKey val id: String, + val tripId: String, + val title: String, + val timestamp: Long, + val lastMessage: String, + val turnCount: Int = 1, + val turnsJson: String = "[]", +) diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/AssistantConversationDao.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/AssistantConversationDao.kt new file mode 100644 index 00000000..da574d09 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/AssistantConversationDao.kt @@ -0,0 +1,54 @@ +/* + * 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 historical conversations with the Travel Assistant. + */ +@Dao +interface AssistantConversationDao { + /** + * Retrieves all historical conversations for a specific trip, ordered with the most recent at the top. + */ + @Query("SELECT * FROM assistant_conversations WHERE tripId = :tripId ORDER BY timestamp DESC") + fun getConversationsForTrip(tripId: String): Flow> + + @Query("SELECT * FROM assistant_conversations WHERE id = :id LIMIT 1") + suspend fun getConversationById(id: String): AssistantConversation? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertConversation(conversation: AssistantConversation) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertConversations(conversations: List) + + @Delete + suspend fun deleteConversation(conversation: AssistantConversation) + + @Query("DELETE FROM assistant_conversations WHERE id = :id") + suspend fun deleteConversationById(id: String) + + @Query("DELETE FROM assistant_conversations WHERE tripId = :tripId") + suspend fun deleteAllForTrip(tripId: String) +} 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 index f5c20dbb..6370d547 100644 --- 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 @@ -26,8 +26,8 @@ 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. + * 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 { 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 index ab2472b0..a6087772 100644 --- 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 @@ -20,7 +20,6 @@ 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", @@ -42,6 +41,7 @@ data class FlightDetail( @JvmField val destination: String = "", @JvmField val gate: String? = null, @JvmField val seat: String? = null, + @JvmField val passengerName: String? = null, @JvmField val departureTerminal: String? = null, @JvmField val arrivalTerminal: String? = null, @JvmField val arrivalGate: String? = null, 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 index 66947785..406e90be 100644 --- 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 @@ -23,9 +23,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow -/** - * Data Access Object (DAO) for managing financial expenses logged during trips. - */ +/** 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> 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 index 4d954cf2..d22b7bdd 100644 --- 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 @@ -46,7 +46,7 @@ data class TimelineEvent( @JvmField val audioNote: String? = null, @JvmField val audioNotes: List = emptyList(), @JvmField val placeId: String? = null, - @JvmField val language: String? = null + @JvmField val language: String? = null, ) @Entity(tableName = "voice_notes") 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 index 121fe65e..a997a051 100644 --- 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 @@ -22,9 +22,7 @@ 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. - */ +/** Data Access Object (DAO) for managing custom tour details associated with timeline events. */ @Dao interface TourDetailDao { @Query("SELECT * FROM tour_details WHERE eventId = :eventId") 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 index 3820fe88..0517583b 100644 --- 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 @@ -35,9 +35,10 @@ object DummyData { 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 ioBaseTime = Instant.parse("2026-10-27T10: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() + private val parisBase = Instant.parse("2026-11-10T10:00:00Z").toEpochMilli() + private val tokyoBase = Instant.parse("2026-12-10T10:00:00Z").toEpochMilli() val trips = listOf( @@ -49,6 +50,8 @@ object DummyData { romeBase + 8 * dayMillis, R.drawable.img_rome, participants = listOf("Alice", "Bob", "Charlie"), + budget = 2000.0, + currency = "EUR", ), Trip( "2025-2", @@ -58,15 +61,23 @@ object DummyData { seoulBase + 7 * dayMillis, R.drawable.img_seoul, participants = listOf("David", "Eva"), + budget = 3000000.0, + currency = "KRW", ), Trip( "2026-1", - "California I/O", - "California, USA", + "ADS '26 Summit", + "Mountain View, CA", ioBaseTime, ioBaseTime + 4 * dayMillis, R.drawable.img_california, participants = listOf("Frank", "Grace"), + tripSummaryAndTips = + "Flagship Android Developer Summit '26 at Google Bay View 300 in Mountain View, paired with Bay Area sightseeing.\n\n" + + "**Tip**\nPick up your speaker & attendee badge early at the Bay View 300 registration desk before the Developer Keynote.\n\n" + + "**Key Venue**\n* Bay View 300 (BV300), Mountain View, CA — Oct 28–29, 2026", + budget = 2000.0, + currency = "USD", ), Trip( "2026-2", @@ -76,6 +87,8 @@ object DummyData { dublinBase + 2 * dayMillis, R.drawable.img_dublin_skyline, participants = listOf("Hank", "Ivy"), + budget = 1500.0, + currency = "EUR", ), Trip( "2026-3", @@ -83,8 +96,25 @@ object DummyData { "Paris, France", parisBase, parisBase + 7 * dayMillis, - R.drawable.img_paris_skyline, - participants = listOf("Jack", "Kelly", "Liam"), + R.drawable.img_paris, + participants = listOf("Jack", "Alice", "Eva"), + budget = 2000.0, + currency = "EUR", + ), + Trip( + "2026-4", + "Tokyo Explorer", + "Tokyo, Japan", + tokyoBase, + tokyoBase + 6 * dayMillis, + R.drawable.img_tokyo, + participants = listOf("David", "Charlie"), + tripSummaryAndTips = + "An exciting blend of immersive digital art, vibrant Shibuya nights, and authentic Tokyo ramen.\n\n" + + "**Tip**\nAdd a digital Suica card to your phone wallet before landing at Haneda for seamless train transit.\n\n" + + "**Useful Phrases**\n* Konnichiwa (こんにちは) - Hello\n* Arigatou gozaimasu (ありがとうございます) - Thank you", + budget = 300000.0, + currency = "JPY", ), ) @@ -100,28 +130,32 @@ object DummyData { val voiceNotes = mutableListOf() init { - // 1. California work (2026-1) + // 1. Android Dev Summit '26 in Mountain View, CA (2026-1) events.addAll( listOf( - // Day 1: Flight in from Amsterdam, car rental pickup and hotel check-in + // Day 1 (Tue, Oct 27): Flight in from Paris Charles de Gaulle, 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.", + title = "CDG > SFO", + location = "SFO International Terminal", + extraInfo = "UA 991", + description = + "Direct inbound flight UA 991 from Paris Charles de Gaulle to San Francisco for Android Dev Summit '26.", ), TimelineEvent( id = "io_2", tripId = "2026-1", type = EventType.CAR_RENTAL, timestamp = ioBaseTime + 11 * hourMillis, - title = "Car pickup", + title = "Car Pickup @ SFO", location = "Zephyr Wheels Rental Center", - description = "Picking up pre-booked premium sedan @ Zephyr Wheels.", + description = + "Picking up pre-booked EV sedan @ Zephyr Wheels for the drive to Mountain View.", ), TimelineEvent( id = "io_3", @@ -131,75 +165,82 @@ object DummyData { 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.", + description = + "Checking into The Redwood Grove Hotel in Mountain View before Android Dev Summit '26 begins.", ), - 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 + // Day 2 (Wed, Oct 28): ADS '26 Day 1 at Bay View 300 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.", + timestamp = ioBaseTime + dayMillis + 9 * hourMillis, + title = "ADS '26 Developer Keynote", + location = "Bay View 300 (BV300), Mountain View", + description = + "Opening Developer Keynote at Bay View 300 covering the latest in Android, Gemini Nano, and on-device AI.", ), 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.", + timestamp = ioBaseTime + dayMillis + 13 * hourMillis, + title = "Speaker & Attendee Lunch", + location = "Bay View 300 Courtyard", + description = "Catching up with Android developers and teammates over lunch at Bay View.", ), 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.", + timestamp = ioBaseTime + dayMillis + 14 * hourMillis, + title = "Session: Building Agentic Android Apps", + location = "Bay View 300 — Stage 2", + description = + "ADS 003 technical session on AppFunctions, ML Kit GenAI Prompt API, and multi-step on-device agents.", ), TimelineEvent( id = "io_9", tripId = "2026-1", type = EventType.FOOD_AND_DRINK, - timestamp = ioBaseTime + 2 * dayMillis + 19 * hourMillis, + timestamp = ioBaseTime + dayMillis + 19 * hourMillis, title = "Dinner @ Quantum Bites", - location = "Quantum Bites", - description = "Celebrating a fruitful day with delicious, contemporary dining.", + location = "Castro Street, Mountain View", + description = + "Celebrating Day 1 of Android Dev Summit '26 with dinner in downtown Mountain View.", ), - // Day 4: Sessions all day + // Day 3 (Thu, Oct 29): ADS '26 Day 2 at Bay View 300 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.", + timestamp = ioBaseTime + 2 * dayMillis + 9 * hourMillis, + title = "On-Device AI Workshop & Demo Showcase", + location = "Bay View 300 (BV300), Mountain View", + description = + "Hands-on 90-minute workshop and interactive demo booth showcase with the Android engineering team.", + ), + // Day 4 (Fri, Oct 30): Bay Area sightseeing & SFMOMA museum visit + TimelineEvent( + id = "io_4", + tripId = "2026-1", + type = EventType.ACTIVITY, + timestamp = ioBaseTime + 3 * dayMillis + 10 * hourMillis, + title = "Golden Gate Bay Cruise", + location = "Pier 39, San Francisco", + description = + "Scenic post-summit bay cruise past Alcatraz and under the Golden Gate Bridge.", ), - // Day 5: Hotel checkout, car rental return, flight out + TimelineEvent( + id = "io_5", + tripId = "2026-1", + type = EventType.CULTURE, + timestamp = ioBaseTime + 3 * dayMillis + 14 * hourMillis, + title = "SFMOMA Museum Visit", + location = "San Francisco Museum of Modern Art", + description = + "Exploring modern art and interactive design exhibitions in downtown San Francisco.", + ), + // Day 5 (Sat, Oct 31): Hotel checkout, car rental return, flight out TimelineEvent( id = "io_11", tripId = "2026-1", @@ -224,9 +265,11 @@ object DummyData { 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.", + title = "SFO > CDG", + location = "SFO International Terminal", + extraInfo = "UA 992", + description = + "Heading home on an overnight outbound flight back to Paris Charles de Gaulle.", ), ) ) @@ -235,17 +278,20 @@ object DummyData { TourDetail( "io_detail_1", "io_6", - "Google I/O Keynotes", + "ADS '26 Developer Keynote", "CONFERENCE", R.drawable.img_california, - "Tuesday, May 19", - "09:00 AM - 12:00 PM", - "Shoreline Amphitheatre", - "1 Amphitheatre Pkwy, Mountain View, CA 94043", + "Wednesday, Oct 28", + "09:00 AM - 10:30 AM", + "Bay View 300 (BV300)", + "300 Bay View Dr, 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"), + "The flagship opening keynote of Android Developer Summit '26 at Bay View 300, featuring the latest announcements across Android, Gemini Nano, and agentic app experiences.", + "Main registration atrium at Bay View 300.", + listOf( + "Pick up your speaker badge at the BV300 desk 45 minutes early", + "Keynote is livestreamed to the London extended event at Major Tom", + ), ) ) @@ -256,12 +302,12 @@ object DummyData { "Zephyr Wheels Car Rental", "CAR_RENTAL", R.drawable.img_california, - "Sunday, May 17", + "Tuesday, Oct 27", "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.", + "EV Sedan car pickup. Rental includes unlimited mileage, FastTrak toll pass, and full insurance coverage.", "Main pickup desk at Zephyr Wheels Center.", listOf("Bring your valid driver license", "Credit card required for deposit"), ) @@ -272,32 +318,71 @@ object DummyData { TourDetail( "io_detail_boat", "io_4", - "SFO Golden Gate Boat Trip", + "Golden Gate Bay Cruise", "ACTIVITY", R.drawable.img_california, - "Monday, May 18", + "Friday, Oct 30", "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"), + listOf("Wear layers, can get windy on the bay", "Arrive 20 minutes before boarding"), + ), + TourDetail( + "io_detail_museum", + "io_5", + "SFMOMA Museum Visit", + "CULTURE", + R.drawable.img_california, + "Friday, Oct 30", + "02:00 PM", + "San Francisco Museum of Modern Art", + "151 3rd St, San Francisco, CA 94103", + null, + "Explore seven floors of modern and contemporary art, photography, and interactive design in downtown San Francisco.", + "Third Street main atrium entrance.", + listOf( + "Check large backpacks and laptop bags at the ground-floor coat check", + "Start on Floor 7 and work your way down", + ), ), TourDetail( "io_detail_sess", "io_8", - "Advanced Tech & AI Sessions", + "Session: Building Agentic Android Apps", "CONFERENCE", R.drawable.img_california, - "Tuesday, May 19", + "Wednesday, Oct 28", "02:00 PM", - "Google I/O Tents", - "Shoreline Amphitheatre", + "Bay View 300 — Stage 2", + "300 Bay View Dr, Mountain View, CA 94043", + null, + "Technical session ADS 003 exploring ML Kit GenAI Prompt API, Android AppFunctions, and multi-step on-device agent workflows.", + "Stage 2 speaker ready desk at Bay View 300.", + listOf( + "Plug in test device at the podium 15 minutes before the session", + "Bring USB-C display adapter backup", + ), + ), + TourDetail( + "io_detail_workshop", + "io_10", + "On-Device AI Workshop & Demo Showcase", + "CONFERENCE", + R.drawable.img_california, + "Thursday, Oct 29", + "09:00 AM", + "Bay View 300 Atrium", + "300 Bay View Dr, Mountain View, CA 94043", 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"), + "Interactive 90-minute hands-on workshop and demo booth showcase with Android engineers.", + "Workshop Room B & Demo Showcase Area.", + listOf( + "Bring a laptop with Android Studio and a USB-C cable", + "Stop by the Jetpacker demo booth in the main atrium", + ), ), TourDetail( "io_detail_check", @@ -305,7 +390,7 @@ object DummyData { "The Redwood Grove - Checkout", "ACCOMMODATION", R.drawable.img_california, - "Thursday, May 21", + "Saturday, Oct 31", "09:00 AM", "Front Desk Express", "The Redwood Grove Hotel", @@ -320,14 +405,14 @@ object DummyData { "Zephyr Wheels - Car Return", "CAR_RENTAL", R.drawable.img_california, - "Thursday, May 21", + "Saturday, Oct 31", "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"), + listOf("Ensure battery charge is above 70%"), ), TourDetail( "par_detail_eiffel", @@ -499,14 +584,14 @@ object DummyData { eventId = "io_1", airline = "United Airlines", flightNum = "UA 991", - origin = "AMS", + origin = "CDG", destination = "SFO", - gate = "G22", + gate = "K42", seat = "32B", - departureTerminal = "Terminal 2", + departureTerminal = "Terminal 2E", arrivalTerminal = "International Terminal", arrivalGate = "G94", - duration = "10h 25m", + duration = "11h 15m", aircraft = "Boeing 777-300ER", baggageAllowance = "1 x 23kg Checked, 1 Carry-on", ), @@ -515,13 +600,13 @@ object DummyData { airline = "United Airlines", flightNum = "UA 992", origin = "SFO", - destination = "AMS", + destination = "CDG", gate = "G94", seat = "34A", departureTerminal = "International Terminal", - arrivalTerminal = "Terminal 2", - arrivalGate = "G22", - duration = "9h 55m", + arrivalTerminal = "Terminal 2E", + arrivalGate = "K42", + duration = "10h 45m", aircraft = "Boeing 777-300ER", baggageAllowance = "1 x 23kg Checked, 1 Carry-on", ), @@ -714,20 +799,20 @@ object DummyData { ), DiningDetail( eventId = "io_7", - restaurantName = "Shoreline Cafe", - address = "Shoreline Amphitheatre, Mountain View", - reservationTime = ioBaseTime + 2 * dayMillis + 13 * hourMillis, + restaurantName = "Bay View Courtyard Cafe", + address = "300 Bay View Dr, Mountain View, CA", + reservationTime = ioBaseTime + dayMillis + 13 * hourMillis, partySize = 4, - rating = "4.2", - reviewCount = "150", + rating = "4.6", + reviewCount = "210", 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, + address = "240 Castro St, Mountain View, CA", + reservationTime = ioBaseTime + dayMillis + 19 * hourMillis, partySize = 2, rating = "4.6", reviewCount = "89", @@ -1117,31 +1202,194 @@ object DummyData { ) ) + // 6. Tokyo (2026-4) - 7 days + events.addAll( + listOf( + TimelineEvent( + id = "tok_1", + tripId = "2026-4", + type = EventType.TRANSPORTATION, + timestamp = tokyoBase, + title = "Flight to Tokyo (HND)", + location = "SFO International Terminal", + extraInfo = "JL 005", + description = "Direct flight JL 005 from San Francisco to Tokyo Haneda Airport.", + ), + TimelineEvent( + id = "tok_2", + tripId = "2026-4", + type = EventType.ACCOMMODATION, + timestamp = tokyoBase + 6 * hourMillis, + title = "Check-in: Shibuya Stream Hotel", + location = "Shibuya, Tokyo", + language = "Japanese", + description = "Checking into Shibuya Stream Hotel above Shibuya Station.", + ), + TimelineEvent( + id = "tok_3", + tripId = "2026-4", + type = EventType.CULTURE, + timestamp = tokyoBase + 24 * hourMillis + 2 * hourMillis, + title = "teamLab Planets Museum", + location = "Toyosu, Tokyo", + description = "Immersive digital art museum experience in Toyosu.", + ), + TimelineEvent( + id = "tok_4", + tripId = "2026-4", + type = EventType.FOOD_AND_DRINK, + timestamp = tokyoBase + 24 * hourMillis + 9 * hourMillis, + title = "Dinner at Ichiran Shibuya", + location = "Shibuya, Tokyo", + description = "Classic tonkotsu ramen dinner in Shibuya.", + ), + TimelineEvent( + id = "tok_5", + tripId = "2026-4", + type = EventType.TRANSPORTATION, + timestamp = tokyoBase + 6 * dayMillis + 4 * hourMillis, + title = "Flight Out", + location = "Haneda Airport Terminal 3", + extraInfo = "JL 006", + description = "Return flight JL 006 from Tokyo Haneda to San Francisco.", + ), + ) + ) + + flightDetails.addAll( + listOf( + FlightDetail( + eventId = "tok_1", + airline = "Japan Airlines", + flightNum = "JL 005", + origin = "SFO", + destination = "HND", + gate = "G92", + seat = "15A", + departureTerminal = "International Terminal", + arrivalTerminal = "Terminal 3", + arrivalGate = "112", + duration = "11h 15m", + aircraft = "Boeing 777-300ER", + baggageAllowance = "2 x 23kg Checked, 1 Carry-on", + ), + FlightDetail( + eventId = "tok_5", + airline = "Japan Airlines", + flightNum = "JL 006", + origin = "HND", + destination = "SFO", + gate = "112", + seat = "16C", + departureTerminal = "Terminal 3", + arrivalTerminal = "International Terminal", + arrivalGate = "G92", + duration = "9h 30m", + aircraft = "Boeing 777-300ER", + baggageAllowance = "2 x 23kg Checked, 1 Carry-on", + ), + ) + ) + + hotelDetails.add( + HotelDetail( + eventId = "tok_2", + name = "Shibuya Stream Hotel", + address = "3-21-3 Shibuya, Tokyo 150-0002", + checkInTime = tokyoBase + 6 * hourMillis, + checkOutTime = tokyoBase + 6 * dayMillis, + confNumber = "SHIB-2026", + rating = "4.8", + ratingCount = "1.4k", + pricePerNight = "€220/night", + guests = "2 Adults", + phone = "+81 3 6434 1515", + ) + ) + + museumDetails.add( + MuseumDetail( + eventId = "tok_3", + description = + "teamLab Planets is an immersive digital art museum in Toyosu, Tokyo where visitors walk barefoot through water and interactive light installations.", + address = "6-1-16 Toyosu, Koto City, Tokyo 135-0061", + openingHours = "Daily: 9:00 AM – 9:00 PM", + admissionPrice = "€28", + ticketWebsite = "https://www.teamlab.art/e/planets/", + rating = "4.8", + phone = "+81 3 6368 4292", + ) + ) + + tourDetails.add( + TourDetail( + id = "tok_detail_museum", + eventId = "tok_3", + title = "teamLab Planets Museum", + type = "CULTURE", + imageRes = R.drawable.img_tokyo, + date = "Sunday, Oct 11", + time = "12:00 PM", + locationName = "teamLab Planets TOKYO", + locationAddress = "6-1-16 Toyosu, Koto City, Tokyo", + locationId = null, + about = "Walk barefoot through immersive water and digital light installations.", + meetingPoint = "Main Toyosu Entrance Plaza", + notes = + listOf( + "Wear clothes that can be rolled up to the knees", + "Arrive 15 minutes before entry time", + ), + ) + ) + + diningDetails.add( + DiningDetail( + eventId = "tok_4", + restaurantName = "Ichiran Shibuya", + address = "1-22-7 Jinnan, Shibuya, Tokyo", + reservationTime = tokyoBase + 24 * hourMillis + 9 * hourMillis, + partySize = 2, + rating = "4.7", + reviewCount = "3,100", + priceRange = "$ - Inexpensive", + phone = "+81 3 3463 3667", + ) + ) + expenses.addAll( listOf( Expense( - title = "SFO Taxi", - amount = 45.0, + title = "Team Coffee @ Red Rock", + amount = 30.0, currency = "USD", - category = "Transport", + category = "Food", tripId = "2026-1", timestamp = 1000L, ), Expense( - title = "SFMOMA Ticket", - amount = 25.0, + title = "Dinner @ Quantum Bites", + amount = 120.0, currency = "USD", - category = "Activity", + category = "Food", tripId = "2026-1", timestamp = 2000L, ), Expense( - title = "Dinner @ Quantum Bites", - amount = 120.0, - currency = "USD", + title = "Café de Flore - Breakfast", + amount = 28.5, + currency = "EUR", category = "Food", - tripId = "2026-1", - timestamp = 3000L, + tripId = "2026-3", + timestamp = parisBase + 2 * hourMillis, + ), + Expense( + title = "Metro Pass", + amount = 16.5, + currency = "EUR", + category = "Transportation", + tripId = "2026-3", + timestamp = parisBase + 4 * hourMillis, ), Expense( title = "Colosseum Ticket", 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 index 646500eb..413139e1 100644 --- 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 @@ -33,4 +33,6 @@ data class Trip( @JvmField val imageUri: String? = null, @JvmField val participants: List = emptyList(), @JvmField val tripSummaryAndTips: String? = null, + @JvmField val budget: Double = 2000.0, + @JvmField val currency: String = "USD", ) 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 index d3653cec..e1e2d127 100644 --- 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 @@ -14,15 +14,14 @@ * limitations under the License. */ +@file:Suppress("GlobalCoroutineDispatchers", "TopLevelName") + 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 @@ -30,304 +29,1058 @@ 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.TourDetailDao import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.data.trips.DummyData import com.example.jetpacker.data.trips.Trip import com.example.jetpacker.data.trips.TripDao +import dagger.hilt.android.AndroidEntryPoint +import java.util.Locale import java.util.UUID +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @RequiresApi(36) -@AndroidEntryPoint +@AndroidEntryPoint(AppFunctionService::class) @AppFunctionServiceEntryPoint( - serviceName = "JetPackerAppFunctionService", - appFunctionXmlFileName = "jetpacker_app_function_service" + 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() } +abstract class BaseJetPackerAppFunctionService : Hilt_BaseJetPackerAppFunctionService() { + @Inject internal lateinit var tripDao: TripDao + @Inject internal lateinit var expenseDao: ExpenseDao + @Inject internal lateinit var eventDao: EventDao + @Inject internal lateinit var tourDetailDao: TourDetailDao + @Inject internal lateinit var dayThemeDao: DayThemeDao + + /** + * Searches for trips based on optional filters like query, id, title (name), location, and dates. + * Call this first when the user refers to a trip by city or name and you need its tripId. + * + * @param query Optional search keyword matching trip title or destination location (e.g. 'Tokyo', + * 'Paris'). + * @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( + query: String? = null, + id: String? = null, + title: String? = null, + location: String? = null, + startDate: Long? = null, + endDate: Long? = null, + ): List { + return withContext(Dispatchers.IO) { + val dbTrips = tripDao.getAllTrips().first() + val allTrips = if (dbTrips.isNotEmpty()) dbTrips else DummyData.trips + allTrips + .filter { trip -> + (query.isNullOrBlank() || + trip.title.contains(query, ignoreCase = true) || + trip.location.contains(query, ignoreCase = true) || + trip.id.equals(query, ignoreCase = true)) && + (id.isNullOrBlank() || trip.id.equals(id, ignoreCase = true)) && + (title.isNullOrBlank() || + trip.title.contains(title, ignoreCase = true) || + trip.location.contains(title, ignoreCase = true)) && + (location.isNullOrBlank() || + trip.location.contains(location, ignoreCase = true) || + trip.title.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 + /** + * 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. Call this ONLY when the user explicitly asks to add, log, or record + * an expense (e.g., 'Please add 45 dollars for the airport Uber to Mountain View' or in a + * follow-up turn 'Great, I just bought it, can you add that to my expenses?'). Do NOT call this + * when the user is only asking how much budget they have left or whether they can afford an item. + * + * @param title The title or description of the expense (e.g., 'Airport Uber to Mountain View', + * 'SFMOMA Museum Ticket', 'Mechanical Keyboard'). + * @param amount The numeric amount of the expense (e.g., 45.0, 25.0, 180.0). + * @param currency The currency code of the expense (e.g., 'USD', 'EUR', 'JPY', 'GBP'). Extract + * from the user's request or event details (e.g., 'dollars'/'$' -> 'USD', 'euros'/'€' -> 'EUR', + * 'yen'/'¥' -> 'JPY', 'pounds'/'£' -> 'GBP'). + * @param tripId Optional ID of the trip to add the expense to (e.g., '2026-1'). Defaults to the + * active trip if omitted. + * @param category Optional category of the expense ('Transport', 'Food', 'Accommodation', + * 'Activity'). Inferred from title if omitted. + * @return The added expense. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun addExpense( + title: String, + amount: Double, + currency: String, + tripId: String? = null, + category: String? = null, + ): ExpenseSerializable { + return withContext(Dispatchers.IO) { + val resolvedTitle = + if ( + title.isBlank() || + title.lowercase() in + setOf( + "it", + "that", + "this", + "expense", + "pass", + "ticket", + "keyboard", + "hoodie", + "cruise", + "bay cruise", + "that expense", + "the expense", + ) + ) { + ActiveTripSession.pendingExpenseTitle?.takeIf { it.isNotBlank() } ?: title + } else { + title + } + val resolvedAmount = + if (amount <= 0.0) { + ActiveTripSession.pendingExpenseAmount ?: amount + } else { + amount + } + val rawCurrency = + if (currency.isBlank() && !ActiveTripSession.pendingExpenseCurrency.isNullOrBlank()) { + ActiveTripSession.pendingExpenseCurrency!! + } else { + currency + } + val resolvedCurrency = normalizeCurrency(rawCurrency, resolvedTitle) + val resolvedCategory = + category?.takeIf { it.isNotBlank() } ?: inferExpenseCategory(resolvedTitle) + val trip = resolveTrip(tripId = tripId, textHint = resolvedTitle) + val targetTripId = + trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1" + + val recentExpenses = expenseDao.getExpensesForTrip(targetTripId).first() + val now = System.currentTimeMillis() + val tripCurrency = trip?.currency ?: "USD" + val tripBudget = trip?.budget ?: 2000.0 + + if (ActiveTripSession.isReadOnlyBudgetCheck) { + ActiveTripSession.pendingExpenseTitle = resolvedTitle + ActiveTripSession.pendingExpenseAmount = resolvedAmount + ActiveTripSession.pendingExpenseCurrency = resolvedCurrency + + val totalSpent = + kotlin.math.round( + recentExpenses.sumOf { convertCurrency(it.amount, it.currency, tripCurrency) } * 100.0 + ) / 100.0 + val remainingBudget = kotlin.math.round((tripBudget - totalSpent) * 100.0) / 100.0 + val candidateInTrip = + kotlin.math.round( + convertCurrency(resolvedAmount, resolvedCurrency, tripCurrency) * 100.0 + ) / 100.0 + val afterBudget = kotlin.math.round((remainingBudget - candidateInTrip) * 100.0) / 100.0 + + val totalSpentUsd = + kotlin.math.round(recentExpenses.sumOf { convertToUsd(it.amount, it.currency) } * 100.0) / + 100.0 + val budgetUsd = convertToUsd(tripBudget, tripCurrency) + val remainingBudgetUsd = kotlin.math.round((budgetUsd - totalSpentUsd) * 100.0) / 100.0 + val remainingBudgetJpy = kotlin.math.round(remainingBudgetUsd * 150.0) + val candidateUsd = + kotlin.math.round(convertToUsd(resolvedAmount, resolvedCurrency) * 100.0) / 100.0 + val candidateJpy = kotlin.math.round(candidateUsd * 150.0).toLong() + val afterUsd = kotlin.math.round((remainingBudgetUsd - candidateUsd) * 100.0) / 100.0 + val afterJpy = kotlin.math.round(afterUsd * 150.0) + + val priceStr = formatCurrency(resolvedAmount, resolvedCurrency) + val candidateStr = + if (resolvedCurrency.equals(tripCurrency, ignoreCase = true)) priceStr + else "$priceStr (${formatCurrency(candidateInTrip, tripCurrency)})" + + return@withContext ExpenseSerializable( + id = "budget-preview", + tripId = targetTripId, + title = resolvedTitle, + amount = resolvedAmount, + currency = resolvedCurrency, + category = resolvedCategory, + timestamp = now, + tripTitle = trip?.title, + amountUsd = candidateUsd, + tripTotalSpentUsd = totalSpentUsd, + tripRemainingBudgetUsd = remainingBudgetUsd, + tripRemainingBudgetJpy = remainingBudgetJpy, + tripCurrency = tripCurrency, + tripBudget = tripBudget, + tripTotalSpent = totalSpent, + tripRemainingBudget = remainingBudget, + status = "SUCCESS", + message = + "Budget check: '$resolvedTitle' costs $candidateStr. You currently have ${formatCurrency(remainingBudget, tripCurrency)} remaining (spent ${formatCurrency(totalSpent, tripCurrency)}). Adding this expense leaves ${formatCurrency(afterBudget, tripCurrency)} remaining from your ${formatCurrency(tripBudget, tripCurrency)} budget.", ) - return withContext(Dispatchers.IO) { - tripDao.insertTrip(trip) - trip.toSerializable() + } + + val duplicate = recentExpenses.firstOrNull { existing -> + existing.title.equals(resolvedTitle, ignoreCase = true) && + kotlin.math.abs(existing.amount - resolvedAmount) < 0.001 && + (now - existing.timestamp) < 15_000L + } + if (duplicate != null) { + val totalSpent = + kotlin.math.round( + recentExpenses.sumOf { convertCurrency(it.amount, it.currency, tripCurrency) } * 100.0 + ) / 100.0 + val remainingBudget = kotlin.math.round((tripBudget - totalSpent) * 100.0) / 100.0 + val totalSpentUsd = + kotlin.math.round(recentExpenses.sumOf { convertToUsd(it.amount, it.currency) } * 100.0) / + 100.0 + val budgetUsd = convertToUsd(tripBudget, tripCurrency) + val remainingBudgetUsd = kotlin.math.round((budgetUsd - totalSpentUsd) * 100.0) / 100.0 + val remainingBudgetJpy = kotlin.math.round(remainingBudgetUsd * 150.0) + return@withContext duplicate.toSerializable( + tripTitle = trip?.title, + amountUsd = convertToUsd(duplicate.amount, duplicate.currency), + tripTotalSpentUsd = totalSpentUsd, + tripRemainingBudgetUsd = remainingBudgetUsd, + tripRemainingBudgetJpy = remainingBudgetJpy, + tripCurrency = tripCurrency, + tripBudget = tripBudget, + tripTotalSpent = totalSpent, + tripRemainingBudget = remainingBudget, + message = + "Expense '$resolvedTitle' (${formatCurrency(resolvedAmount, resolvedCurrency)}) is already logged in ${trip?.title ?: "your trip"}. Remaining budget: ${formatCurrency(remainingBudget, tripCurrency)}.", + ) + } + + val existingSameTitle = recentExpenses.firstOrNull { existing -> + (now - existing.timestamp) < 600_000L && + existing.title.equals(resolvedTitle, ignoreCase = true) + } + + val expense = + if (existingSameTitle != null) { + existingSameTitle.copy( + title = resolvedTitle, + amount = resolvedAmount, + currency = resolvedCurrency, + category = resolvedCategory, + timestamp = now, + ) + } else { + Expense( + tripId = targetTripId, + title = resolvedTitle, + amount = resolvedAmount, + currency = resolvedCurrency, + category = resolvedCategory, + timestamp = now, + ) } + expenseDao.insertExpense(expense) + ActiveTripSession.pendingExpenseTitle = null + ActiveTripSession.pendingExpenseAmount = null + ActiveTripSession.pendingExpenseCurrency = null + + val updatedExpenses = expenseDao.getExpensesForTrip(targetTripId).first() + val totalSpent = + kotlin.math.round( + updatedExpenses.sumOf { convertCurrency(it.amount, it.currency, tripCurrency) } * 100.0 + ) / 100.0 + val remainingBudget = kotlin.math.round((tripBudget - totalSpent) * 100.0) / 100.0 + val totalSpentUsd = + kotlin.math.round(updatedExpenses.sumOf { convertToUsd(it.amount, it.currency) } * 100.0) / + 100.0 + val budgetUsd = convertToUsd(tripBudget, tripCurrency) + val remainingBudgetUsd = kotlin.math.round((budgetUsd - totalSpentUsd) * 100.0) / 100.0 + val remainingBudgetJpy = kotlin.math.round(remainingBudgetUsd * 150.0) + val verb = if (existingSameTitle != null) "Updated" else "Added" + val priceStr = formatCurrency(resolvedAmount, resolvedCurrency) + val convertedNotice = + if (resolvedCurrency.equals(tripCurrency, ignoreCase = true)) priceStr + else + "$priceStr (approx ${formatCurrency(convertCurrency(resolvedAmount, resolvedCurrency, tripCurrency), tripCurrency)})" + + expense.toSerializable( + tripTitle = trip?.title, + amountUsd = convertToUsd(expense.amount, expense.currency), + tripTotalSpentUsd = totalSpentUsd, + tripRemainingBudgetUsd = remainingBudgetUsd, + tripRemainingBudgetJpy = remainingBudgetJpy, + tripCurrency = tripCurrency, + tripBudget = tripBudget, + tripTotalSpent = totalSpent, + tripRemainingBudget = remainingBudget, + message = + "$verb '$resolvedTitle' ($convertedNotice) to ${trip?.title ?: "your trip"}. Remaining budget: ${formatCurrency(remainingBudget, tripCurrency)}.", + ) } + } - /** - * 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 + /** + * Lists all logged expenses and remaining trip budget for a specific trip. Call this when the + * user asks about their spending, remaining budget, or how much budget they would have left if + * they buy something. + * + * @param tripId Optional ID of the trip (e.g., '2026-1'). Defaults to the active trip if omitted. + * @return A list of expenses and budget summary for the trip. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getExpenses(tripId: String? = null): List { + return withContext(Dispatchers.IO) { + val trip = resolveTrip(tripId = tripId) + val targetTripId = + trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1" + val tripCurrency = trip?.currency ?: "USD" + val tripBudget = trip?.budget ?: 2000.0 + + val expenses = expenseDao.getExpensesForTrip(targetTripId).first() + val totalSpent = + kotlin.math.round( + expenses.sumOf { convertCurrency(it.amount, it.currency, tripCurrency) } * 100.0 + ) / 100.0 + val remainingBudget = kotlin.math.round((tripBudget - totalSpent) * 100.0) / 100.0 + + val totalSpentUsd = + kotlin.math.round(expenses.sumOf { convertToUsd(it.amount, it.currency) } * 100.0) / 100.0 + val budgetUsd = convertToUsd(tripBudget, tripCurrency) + val remainingBudgetUsd = kotlin.math.round((budgetUsd - totalSpentUsd) * 100.0) / 100.0 + val remainingBudgetJpy = kotlin.math.round(remainingBudgetUsd * 150.0) + + val pendingAmt = ActiveTripSession.pendingExpenseAmount + val pendingCurr = ActiveTripSession.pendingExpenseCurrency ?: tripCurrency + val pendingTitle = ActiveTripSession.pendingExpenseTitle ?: "item" + val summaryMsg = + if (pendingAmt != null && pendingAmt > 0.0) { + val pendingInTripCurr = + kotlin.math.round(convertCurrency(pendingAmt, pendingCurr, tripCurrency) * 100.0) / + 100.0 + val afterBudget = kotlin.math.round((remainingBudget - pendingInTripCurr) * 100.0) / 100.0 + val itemPrice = formatCurrency(pendingAmt, pendingCurr) + val itemStr = + if (pendingCurr.equals(tripCurrency, ignoreCase = true)) itemPrice + else "$itemPrice (${formatCurrency(pendingInTripCurr, tripCurrency)})" + "Budget check: You currently have ${formatCurrency(remainingBudget, tripCurrency)} remaining (spent ${formatCurrency(totalSpent, tripCurrency)}). Adding '$pendingTitle' for $itemStr leaves ${formatCurrency(afterBudget, tripCurrency)} remaining from your ${formatCurrency(tripBudget, tripCurrency)} budget." + } else { + "Total spent so far: ${formatCurrency(totalSpent, tripCurrency)} out of ${formatCurrency(tripBudget, tripCurrency)} budget. Remaining budget: ${formatCurrency(remainingBudget, tripCurrency)}." + } + + if (expenses.isEmpty()) { + listOf( + ExpenseSerializable( + id = "summary", + tripId = targetTripId, + title = "Trip Budget Summary", + amount = 0.0, + currency = tripCurrency, + category = "Summary", + timestamp = System.currentTimeMillis(), + tripTitle = trip?.title, + amountUsd = 0.0, + tripTotalSpentUsd = totalSpentUsd, + tripRemainingBudgetUsd = remainingBudgetUsd, + tripRemainingBudgetJpy = remainingBudgetJpy, + tripCurrency = tripCurrency, + tripBudget = tripBudget, + tripTotalSpent = totalSpent, + tripRemainingBudget = remainingBudget, + status = "SUCCESS", + message = summaryMsg, + ) ) - return withContext(Dispatchers.IO) { - expenseDao.insertExpense(expense) - expense.toSerializable() + } else { + expenses.map { + it.toSerializable( + tripTitle = trip?.title, + amountUsd = convertToUsd(it.amount, it.currency), + tripTotalSpentUsd = totalSpentUsd, + tripRemainingBudgetUsd = remainingBudgetUsd, + tripRemainingBudgetJpy = remainingBudgetJpy, + tripCurrency = tripCurrency, + tripBudget = tripBudget, + tripTotalSpent = totalSpent, + tripRemainingBudget = remainingBudget, + message = summaryMsg, + ) } + } } + } - /** - * 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 (with their eventId, title, type, and location) for a specific + * trip. Note: getItinerary returns event summaries only; call getEventDetails(eventId) with a + * specific event's ID to retrieve ticket prices, hotel nightly rates, baggage allowance, or + * visitor tips. + * + * @param tripId Optional ID of the trip (e.g., '2026-1'). Defaults to the active trip if omitted. + * @return A list of events in the itinerary. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getItinerary(tripId: String? = null): List { + return withContext(Dispatchers.IO) { + val trip = resolveTrip(tripId = tripId) + val targetTripId = + trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1" + val dbEvents = eventDao.getEventsForTrip(targetTripId).first() + val events = + if (dbEvents.isNotEmpty()) { + dbEvents + } else { + val fallback = DummyData.events.filter { it.tripId == targetTripId } + if (fallback.isNotEmpty()) { + eventDao.insertEvents(fallback) + } + fallback } + events.map { it.toSerializable(tripTitle = trip?.title) } } + } - /** - * 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() } - } + /** + * Retrieves rich subtype details for a specific itinerary event by its eventId, including museum + * admission price and hours, hotel nightly rate and confirmation number, flight baggage + * allowance, and visitor tips. Call this after getItinerary(tripId) when you need the price, + * rate, baggage allowance, or visitor tips of a specific event. + * + * @param eventId The unique identifier of the itinerary event (e.g., "io_5", "io_3", "tok_3", + * "par_3"). + * @return The detailed event information. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getEventDetails(eventId: String): EventDetailsSerializable { + return withContext(Dispatchers.IO) { + val event = + eventDao.getEventById(eventId).first() ?: DummyData.events.find { it.id == eventId } + val flight = + eventDao.getFlightDetail(eventId).first() + ?: DummyData.flightDetails.find { it.eventId == eventId } + val hotel = + eventDao.getHotelDetail(eventId).first() + ?: DummyData.hotelDetails.find { it.eventId == eventId } + val museum = + eventDao.getMuseumDetail(eventId).first() + ?: DummyData.museumDetails.find { it.eventId == eventId } + val dining = + eventDao.getDiningDetail(eventId).first() + ?: DummyData.diningDetails.find { it.eventId == eventId } + val tour = + tourDetailDao.getTourDetailByEventId(eventId).first() + ?: DummyData.tourDetails.find { it.eventId == eventId } + + val title = + event?.title + ?: tour?.title + ?: hotel?.name + ?: dining?.restaurantName + ?: flight?.let { "${it.airline} ${it.flightNum}" } + ?: eventId + val type = event?.type?.name ?: tour?.type ?: "ACTIVITY" + val location = + event?.location + ?: museum?.address + ?: hotel?.address + ?: dining?.address + ?: tour?.locationAddress + ?: "" + + EventDetailsSerializable( + eventId = eventId, + title = title, + type = type, + location = location, + admissionPrice = museum?.admissionPrice, + openingHours = museum?.openingHours, + pricePerNight = hotel?.pricePerNight, + confirmationNumber = hotel?.confNumber, + flightInfo = + flight?.let { "${it.airline} ${it.flightNum} (${it.origin} -> ${it.destination})" }, + baggageAllowance = flight?.baggageAllowance, + tips = tour?.notes ?: emptyList(), + description = museum?.description ?: tour?.about ?: event?.description, + ) } + } - /** - * 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()) + /** + * Adds a new event or reminder to a trip's itinerary. + * + * @param title The title of the event or reminder (e.g., 'Pick up pocket wifi'). + * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. + * @param type Optional type of event (e.g., ACTIVITY, TRANSPORTATION, ACCOMMODATION, + * FOOD_AND_DRINK, CULTURE). + * @param timestamp Optional time of the event in milliseconds. + * @param location Optional location where the event takes place. + * @param description An optional description of the event. + * @return The added event. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun addItineraryEvent( + title: String, + tripId: String? = null, + type: String? = null, + timestamp: Long? = null, + location: String? = null, + description: String? = null, + ): TimelineEventSerializable { + return withContext(Dispatchers.IO) { + val eventType = + try { + EventType.valueOf((type ?: "ACTIVITY").uppercase()) } catch (_: IllegalArgumentException) { - EventType.ACTIVITY + 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() - } - } + val trip = resolveTrip(tripId = tripId, textHint = "$title ${location ?: ""}") + val targetTripId = + trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1" + val resolvedLocation = + location?.takeIf { it.isNotBlank() } ?: trip?.location ?: "Mountain View, CA" + val resolvedDescription = description ?: "Added via Travel Assistant" + + val existingEvents = eventDao.getEventsForTrip(targetTripId).first() + val duplicateEvent = existingEvents.firstOrNull { existing -> + existing.title.equals(title, ignoreCase = true) + } + if (duplicateEvent != null) { + return@withContext duplicateEvent.toSerializable(tripTitle = trip?.title) + } - /** - * 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() } + val eventTimestamp = + if (timestamp != null && timestamp > 0L) { + timestamp + } else if (trip != null && trip.startDate > 0L) { + trip.startDate + } else { + System.currentTimeMillis() } - } - /** - * 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( + val event = + TimelineEvent( + id = UUID.randomUUID().toString(), + tripId = targetTripId, + type = eventType, + timestamp = eventTimestamp, + title = title, + location = resolvedLocation, + description = resolvedDescription, + ) + eventDao.insertEvent(event) + + val matchedExisting = + existingEvents.find { ev -> + val combined = "$title $resolvedLocation $resolvedDescription".lowercase() + (combined.contains("airport") || + combined.contains("flight") || + combined.contains("sim")) && ev.type == EventType.TRANSPORTATION || + (combined.contains("museum") || combined.contains("sfmoma")) && + ev.type == EventType.CULTURE || + (combined.contains("keynote") || + combined.contains("badge") || + combined.contains("bay view") || + combined.contains("bv300") || + combined.contains("session") || + combined.contains("workshop")) && ev.type == EventType.WORK || + combined.contains("hotel") && ev.type == EventType.ACCOMMODATION + } ?: event + val escapedExtract = title.replace("\"", "\\\"") + val matchingJson = """[{"eventId":"${matchedExisting.id}","extract":"$escapedExtract"}]""" + val existingNotes = eventDao.getVoiceNotesForTrip(targetTripId).first() + if (existingNotes.none { it.title.equals(title, ignoreCase = true) }) { + eventDao.insertVoiceNote( + VoiceNoteEntity( id = UUID.randomUUID().toString(), - tripId = tripId, + tripId = targetTripId, title = title, - transcription = transcription, + transcription = + if (resolvedDescription != "Added via Travel Assistant") { + "$title — $resolvedDescription" + } else { + title + }, timestamp = System.currentTimeMillis(), - matchingEventsJson = "[]" + matchingEventsJson = matchingJson, + ) ) - return withContext(Dispatchers.IO) { - eventDao.insertVoiceNote(note) - note.toSerializable() - } + } + + event.toSerializable( + tripTitle = trip?.title, + matchedEventTitle = matchedExisting.title.takeIf { matchedExisting.id != event.id }, + ) } + } - /** - * 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() } - } + /** + * Retrieves voice notes associated with a trip. + * + * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. + * @return A list of voice notes. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getVoiceNotes(tripId: String? = null): List { + return withContext(Dispatchers.IO) { + val trip = resolveTrip(tripId = tripId) + val targetTripId = + trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1" + eventDao.getVoiceNotesForTrip(targetTripId).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 + /** + * Adds a voice note or reminder transcription to a trip and links it to any matching itinerary + * event (e.g., keynote, airport flight, or museum visit). Call this when the user asks to save a + * voice note, visitor tips, or reminder (e.g., 'Please remind me to pick up my speaker badge at + * Bay View 300' or 'Save the museum visitor tips as a voice note'). + * + * @param title Short title of the voice note or reminder (e.g., 'Pick up speaker badge at Bay + * View 300'). + * @param transcription Optional full text content of the voice note, reminder, or visitor tips. + * Defaults to title if omitted. + * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. + * @param eventId Optional ID of the itinerary event to link this voice note to (e.g., 'io_5', + * 'io_6', 'tok_3'). + * @return The added voice note. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun addVoiceNote( + title: String, + transcription: String? = null, + tripId: String? = null, + eventId: String? = null, + ): VoiceNoteSerializable { + return withContext(Dispatchers.IO) { + val resolvedTranscription = transcription?.takeIf { it.isNotBlank() } ?: title + val trip = resolveTrip(tripId = tripId, textHint = "$title $resolvedTranscription") + val targetTripId = + trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1" + + val existingNotes = eventDao.getVoiceNotesForTrip(targetTripId).first() + val now = System.currentTimeMillis() + val duplicate = existingNotes.firstOrNull { existing -> + existing.title.equals(title, ignoreCase = true) && (now - existing.timestamp) < 15_000L + } + if (duplicate != null) { + return@withContext duplicate.toSerializable() + } + + val dbEvents = eventDao.getEventsForTrip(targetTripId).first() + val events = + if (dbEvents.isNotEmpty()) { + dbEvents + } else { + val seeded = DummyData.events.filter { it.tripId == targetTripId } + if (seeded.isNotEmpty()) { + eventDao.insertEvents(seeded) + } + seeded + } + + val matchedEvent = + if (!eventId.isNullOrBlank()) { + events.find { it.id == eventId } + } else { + val combined = "$title $resolvedTranscription".lowercase() + events.find { ev -> + (combined.contains("airport") || + combined.contains("flight") || + combined.contains("sim") || + combined.contains("baggage")) && ev.type == EventType.TRANSPORTATION || + (combined.contains("museum") || + combined.contains("sfmoma") || + combined.contains("coat check") || + combined.contains("floor 7") || + combined.contains("barefoot") || + combined.contains("knees")) && ev.type == EventType.CULTURE || + (combined.contains("keynote") || + combined.contains("badge") || + combined.contains("bay view") || + combined.contains("bv300") || + combined.contains("session") || + combined.contains("workshop") || + combined.contains("podium")) && ev.type == EventType.WORK || + combined.contains("hotel") && ev.type == EventType.ACCOMMODATION || + ev.title.lowercase().split(" ").any { word -> + word.length > 3 && combined.contains(word) + } + } + } + + val matchingJson = + if (matchedEvent != null) { + val escapedExtract = resolvedTranscription.replace("\"", "\\\"") + """[{"eventId":"${matchedEvent.id}","extract":"$escapedExtract"}]""" + } else { + "[]" + } + + if (matchedEvent != null) { + val updatedNotes = (matchedEvent.audioNotes + resolvedTranscription).distinct() + eventDao.insertEvent(matchedEvent.copy(audioNotes = updatedNotes)) + } + + val note = + VoiceNoteEntity( + id = UUID.randomUUID().toString(), + tripId = targetTripId, + title = title, + transcription = resolvedTranscription, + timestamp = now, + matchingEventsJson = matchingJson, ) - return withContext(Dispatchers.IO) { - dayThemeDao.insertTheme(dayTheme) - dayTheme.toSerializable() + eventDao.insertVoiceNote(note) + note.toSerializable( + matchedEventId = matchedEvent?.id, + matchedEventTitle = matchedEvent?.title, + ) + } + } + + /** + * Retrieves day themes for a specific trip. + * + * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. + * @return A list of day themes. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getDayThemes(tripId: String? = null): List { + return withContext(Dispatchers.IO) { + val trip = resolveTrip(tripId = tripId) + val targetTripId = + trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1" + dayThemeDao.getThemesForTrip(targetTripId).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 matchesTripKeyword(trip: Trip, text: String): Boolean { + val lower = text.lowercase() + val city = trip.location.substringBefore(',').trim().lowercase() + if (city.isNotEmpty() && lower.contains(city)) return true + if (lower.contains(trip.title.lowercase())) return true + if (trip.id == "2026-1") { + return lower.contains("mountain view") || + lower.contains("bay view") || + lower.contains("bv300") || + lower.contains("ads") || + lower.contains("summit") || + lower.contains("california") || + lower.contains("san francisco") || + lower.contains("sfmoma") || + lower.contains("sfo") + } + return false + } + + private suspend fun resolveTrip(tripId: String?, textHint: String? = null): Trip? { + val cleanTripId = tripId?.trim()?.takeIf { it.isNotBlank() } + val activeTripId = ActiveTripSession.activeTripId?.trim()?.takeIf { it.isNotBlank() } + val dbTrips = tripDao.getAllTrips().first() + val allTrips = if (dbTrips.isNotEmpty()) dbTrips else DummyData.trips + + val hintMatch = + if (!textHint.isNullOrBlank()) { + allTrips.find { matchesTripKeyword(it, textHint) } + } else { + null + } + + var trip = + when { + hintMatch != null -> hintMatch + activeTripId != null && + (cleanTripId == null || + cleanTripId.equals(activeTripId, ignoreCase = true) || + allTrips.none { matchesTripKeyword(it, cleanTripId) }) -> + allTrips.find { it.id == activeTripId } + cleanTripId != null -> + allTrips.find { + it.id.equals(cleanTripId, ignoreCase = true) || matchesTripKeyword(it, cleanTripId) + } + else -> null + } + + if (trip == null) { + val fallbackTrip = + DummyData.trips.find { dt -> + (!textHint.isNullOrBlank() && matchesTripKeyword(dt, textHint)) || + (activeTripId != null && dt.id == activeTripId) || + (cleanTripId != null && + (dt.id.equals(cleanTripId, ignoreCase = true) || matchesTripKeyword(dt, cleanTripId))) } + if (fallbackTrip != null) { + tripDao.insertTrips(listOf(fallbackTrip)) + trip = fallbackTrip + } else { + trip = dbTrips.firstOrNull() ?: DummyData.trips.find { it.id == "2026-1" } + } } + return trip + } - private fun Trip.toSerializable() = TripSerializable( - id = id, - title = title, - location = location, - startDate = startDate, - endDate = endDate, - participants = participants - ) + private fun inferExpenseCategory(title: String): String { + val lower = title.lowercase() + return when { + lower.contains("train") || + lower.contains("caltrain") || + lower.contains("bart") || + lower.contains("shuttle") || + lower.contains("express") || + lower.contains("flight") || + lower.contains("airport") || + lower.contains("taxi") || + lower.contains("uber") || + lower.contains("lyft") || + lower.contains("waymo") || + lower.contains("bus") || + lower.contains("metro") || + lower.contains("subway") || + lower.contains("rail") || + lower.contains("shinkansen") || + lower.contains("suica") || + lower.contains("transit") -> "Transport" + lower.contains("coffee") || + lower.contains("cafe") || + lower.contains("tea") || + lower.contains("lunch") || + lower.contains("dinner") || + lower.contains("breakfast") || + lower.contains("food") || + lower.contains("restaurant") || + lower.contains("sushi") || + lower.contains("ramen") || + lower.contains("drink") || + lower.contains("meal") || + lower.contains("snack") || + lower.contains("bakery") -> "Food" + lower.contains("hotel") || + lower.contains("hostel") || + lower.contains("resort") || + lower.contains("stay") || + lower.contains("night") || + lower.contains("airbnb") || + lower.contains("lodging") || + lower.contains("room") -> "Accommodation" + lower.contains("museum") || + lower.contains("sfmoma") || + lower.contains("ticket") || + lower.contains("admission") || + lower.contains("tour") || + lower.contains("cruise") || + lower.contains("helicopter") || + lower.contains("keyboard") || + lower.contains("hoodie") || + lower.contains("merch") || + lower.contains("temple") || + lower.contains("shrine") || + lower.contains("park") || + lower.contains("teamlab") || + lower.contains("gallery") -> "Activity" + else -> "Transport" + } + } - private fun Expense.toSerializable() = ExpenseSerializable( - id = id, - tripId = tripId, - title = title, - amount = amount, - currency = currency, - category = category, - timestamp = timestamp + private fun Trip.toSerializable() = + TripSerializable( + id = id, + title = title, + location = location, + startDate = startDate, + endDate = endDate, + participants = participants, + budget = budget, + currency = currency, ) - private fun TimelineEvent.toSerializable() = TimelineEventSerializable( - id = id, - tripId = tripId, - type = type.name, - timestamp = timestamp, - title = title, - location = location, - description = description + private fun normalizeCurrency(raw: String?, titleHint: String = ""): String { + val clean = raw?.trim()?.lowercase().orEmpty() + return when { + clean.contains("usd") || clean.contains("dollar") || clean.contains("$") -> "USD" + clean.contains("eur") || clean.contains("euro") || clean.contains("€") -> "EUR" + clean.contains("jpy") || clean.contains("yen") || clean.contains("¥") -> "JPY" + clean.contains("gbp") || clean.contains("pound") || clean.contains("£") -> "GBP" + clean.contains("krw") || clean.contains("won") || clean.contains("₩") -> "KRW" + clean.contains("inr") || clean.contains("rupee") || clean.contains("₹") -> "INR" + clean.contains("sgd") -> "SGD" + clean.contains("cny") || clean.contains("rmb") || clean.contains("yuan") -> "CNY" + clean.length == 3 -> clean.uppercase() + titleHint.contains("$") || titleHint.contains("dollar", ignoreCase = true) -> "USD" + titleHint.contains("€") || titleHint.contains("euro", ignoreCase = true) -> "EUR" + titleHint.contains("¥") || titleHint.contains("yen", ignoreCase = true) -> "JPY" + titleHint.contains("£") || titleHint.contains("pound", ignoreCase = true) -> "GBP" + else -> "USD" + } + } + + private fun getUsdRate(currency: String): Double = + when (currency.uppercase()) { + "USD" -> 1.0 + "EUR" -> 1.08 + "GBP" -> 1.27 + "JPY" -> 0.0067 + "KRW" -> 0.00075 + "INR" -> 0.012 + "SGD" -> 0.75 + "CNY" -> 0.14 + "THB" -> 0.029 + "VND" -> 0.00004 + else -> 1.0 + } + + private fun convertToUsd(amount: Double, currency: String): Double { + val rate = getUsdRate(currency) + return kotlin.math.round(amount * rate * 100.0) / 100.0 + } + + private fun convertCurrency(amount: Double, from: String, to: String): Double { + if (from.equals(to, ignoreCase = true)) return amount + val rateFrom = getUsdRate(from) + val rateTo = getUsdRate(to) + val inUsd = amount * rateFrom + return kotlin.math.round((inUsd / rateTo) * 100.0) / 100.0 + } + + private fun getCurrencySymbol(currency: String): String = + when (currency.uppercase()) { + "EUR" -> "€" + "JPY" -> "¥" + "GBP" -> "£" + "KRW" -> "₩" + "INR" -> "₹" + "CNY" -> "¥" + "THB" -> "฿" + "VND" -> "₫" + else -> "$" + } + + private fun formatCurrency(amount: Double, currency: String): String { + val symbol = getCurrencySymbol(currency) + return when (currency.uppercase(Locale.US)) { + "JPY", + "KRW", + "VND" -> "$symbol${String.format(Locale.US, "%,d", amount.toLong())}" + else -> "$symbol${String.format(Locale.US, "%,.2f", amount)}" + } + } + + private fun Expense.toSerializable( + tripTitle: String? = null, + amountUsd: Double? = null, + tripTotalSpentUsd: Double? = null, + tripRemainingBudgetUsd: Double? = null, + tripRemainingBudgetJpy: Double? = null, + tripCurrency: String? = "USD", + tripBudget: Double? = 2000.0, + tripTotalSpent: Double? = null, + tripRemainingBudget: Double? = null, + message: String? = null, + ) = + ExpenseSerializable( + id = id, + tripId = tripId, + title = title, + amount = amount, + currency = currency, + category = category, + timestamp = timestamp, + tripTitle = tripTitle, + amountUsd = amountUsd, + tripTotalSpentUsd = tripTotalSpentUsd, + tripRemainingBudgetUsd = tripRemainingBudgetUsd, + tripRemainingBudgetJpy = tripRemainingBudgetJpy, + tripCurrency = tripCurrency, + tripBudget = tripBudget, + tripTotalSpent = tripTotalSpent, + tripRemainingBudget = tripRemainingBudget, + status = "SUCCESS", + message = message, ) - private fun VoiceNoteEntity.toSerializable() = VoiceNoteSerializable( - id = id, - tripId = tripId, - title = title, - transcription = transcription, - timestamp = timestamp + private fun TimelineEvent.toSerializable( + tripTitle: String? = null, + matchedEventTitle: String? = null, + ) = + TimelineEventSerializable( + id = id, + tripId = tripId, + type = type.name, + timestamp = timestamp, + title = title, + location = location, + description = description, + tripTitle = tripTitle, + matchedEventTitle = matchedEventTitle, ) - private fun DayTheme.toSerializable() = DayThemeSerializable( - id = id, - tripId = tripId, - date = date, - theme = theme + private fun VoiceNoteEntity.toSerializable( + matchedEventId: String? = null, + matchedEventTitle: String? = null, + ) = + VoiceNoteSerializable( + id = id, + tripId = tripId, + title = title, + transcription = transcription, + timestamp = timestamp, + matchedEventId = matchedEventId, + matchedEventTitle = matchedEventTitle, ) + + 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 index e8197598..bb32e44c 100644 --- 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 @@ -19,94 +19,170 @@ package com.example.jetpacker.feature.appfunctions import androidx.appfunctions.AppFunctionSerializable /** - * Represents a trip. + * Tracks the currently active trip ID in the UI session so AppFunctions can resolve omitted + * `tripId` parameters to the user's active trip. */ +object ActiveTripSession { + @Volatile var activeTripId: String? = null + @Volatile var isReadOnlyBudgetCheck: Boolean = false + @Volatile var pendingExpenseTitle: String? = null + @Volatile var pendingExpenseAmount: Double? = null + @Volatile var pendingExpenseCurrency: String? = null +} + +/** 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, + /** 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, + /** The trip's total budget. */ + val budget: Double, + /** The currency code of the trip (e.g. USD, EUR, JPY). */ + val currency: String, ) -/** - * Represents an expense associated with a trip. - */ +/** 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, + /** 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, + /** The title of the trip this expense was added to. */ + val tripTitle: String? = null, + /** The expense amount converted to USD. */ + val amountUsd: Double? = null, + /** Total spent across the trip in USD (matches the Expenses screen total). */ + val tripTotalSpentUsd: Double? = null, + /** Remaining trip budget in USD out of the $2,000 USD budget. */ + val tripRemainingBudgetUsd: Double? = null, + /** Remaining trip budget converted to Japanese Yen (JPY). */ + val tripRemainingBudgetJpy: Double? = null, + /** The native currency code of the trip (e.g. EUR, USD, JPY). */ + val tripCurrency: String? = null, + /** Total budget for the trip in its native currency. */ + val tripBudget: Double? = null, + /** Total spent across the trip in its native currency. */ + val tripTotalSpent: Double? = null, + /** Remaining trip budget in the trip's native currency. */ + val tripRemainingBudget: Double? = null, + /** Execution status of the tool call (e.g., SUCCESS). */ + val status: String? = "SUCCESS", + /** Human-readable completion message instructing the agent not to repeat the call. */ + val message: String? = null, ) -/** - * Represents an event in a trip itinerary. - */ +/** 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?, + /** 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?, + /** The title of the trip this event belongs to. */ + val tripTitle: String? = null, + /** Optional title of a matched itinerary event when added as a reminder. */ + val matchedEventTitle: String? = null, + /** Execution status of the tool call (e.g., SUCCESS). */ + val status: String? = "SUCCESS", + /** Human-readable completion message instructing the agent not to repeat the call. */ + val message: String? = null, ) -/** - * Represents a voice note or memo associated with a trip. - */ +/** 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, + /** 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, + /** Optional ID of the itinerary event this voice note was matched to. */ + val matchedEventId: String? = null, + /** Optional title of the itinerary event this voice note was matched to. */ + val matchedEventTitle: String? = null, + /** Execution status of the tool call (e.g., SUCCESS). */ + val status: String? = "SUCCESS", + /** Human-readable completion message instructing the agent not to repeat the call. */ + val message: String? = null, +) + +/** 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, ) /** - * Represents a theme assigned to a specific day of a trip. + * Represents rich subtype details for a specific itinerary event (flight, hotel, museum, dining, or + * tour). */ @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, +data class EventDetailsSerializable( + /** The unique identifier of the itinerary event. */ + val eventId: String, + /** The title of the event. */ + val title: String, + /** The type of the event (e.g., TRANSPORTATION, ACCOMMODATION, CULTURE, FOOD_AND_DRINK). */ + val type: String, + /** The location or address of the event. */ + val location: String, + /** Museum or attraction admission ticket price (e.g., "€28", "€17", "$25"). */ + val admissionPrice: String? = null, + /** Opening hours for a museum or attraction. */ + val openingHours: String? = null, + /** Hotel nightly rate (e.g., "€220/night"). */ + val pricePerNight: String? = null, + /** Hotel or booking confirmation number. */ + val confirmationNumber: String? = null, + /** Airline and flight number if applicable. */ + val flightInfo: String? = null, + /** Flight baggage allowance (e.g., "2 x 23kg Checked, 1 Carry-on"). */ + val baggageAllowance: String? = null, + /** Visitor tips, notes, or instructions for this event. */ + val tips: List = emptyList(), + /** Summary description of the event details. */ + val description: String? = null, ) diff --git a/jetpacker/android/feature/assistant/build.gradle.kts b/jetpacker/android/feature/assistant/build.gradle.kts new file mode 100644 index 00000000..3943c217 --- /dev/null +++ b/jetpacker/android/feature/assistant/build.gradle.kts @@ -0,0 +1,87 @@ +/* + * 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) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.example.jetpacker.feature.assistant" + compileSdk { + version = + release(libs.versions.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.compileSdkMinor.get().toInt() + } + } + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + buildConfig = true + } +} + +dependencies { + 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(libs.androidx.core.ktx) + implementation(libs.androidx.appfunctions) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + + implementation(libs.hilt.android) + implementation(libs.androidx.hilt.navigation.compose) + "ksp"(libs.hilt.compiler) + + implementation(libs.mlkit.genai.agent) + implementation(libs.mlkit.genai.common) + implementation(libs.mlkit.genai.embedding) + implementation(libs.mlkit.genai.prompt) + implementation(libs.mlkit.genai.schema) + implementation(libs.androidx.appsearch) + implementation(libs.androidx.appsearch.local.storage) + implementation(libs.androidx.concurrent.futures.ktx) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.junit) + testImplementation(libs.google.truth) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation("org.json:json:20240303") +} + +kotlin { jvmToolchain(17) } diff --git a/jetpacker/android/feature/assistant/src/main/AndroidManifest.xml b/jetpacker/android/feature/assistant/src/main/AndroidManifest.xml new file mode 100644 index 00000000..622a83fd --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt new file mode 100644 index 00000000..6a37e21a --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt @@ -0,0 +1,1559 @@ +/* + * 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.assistant + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.util.Log +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.core.speech.VoiceInputManager +import com.example.jetpacker.data.itinerary.AssistantConversation +import com.example.jetpacker.data.itinerary.AssistantConversationDao +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.feature.assistant.BuildConfig +import com.example.jetpacker.data.trips.DummyData +import com.example.jetpacker.data.trips.TripDao +import com.example.jetpacker.feature.appfunctions.ActiveTripSession +import com.example.jetpacker.feature.assistant.history.toConversationTurns +import com.example.jetpacker.feature.assistant.history.toTurnsJson +import com.example.jetpacker.feature.assistant.model.ActionStepCategory +import com.example.jetpacker.feature.assistant.model.ActionStepStatus +import com.example.jetpacker.feature.assistant.model.AssistantOverlayState +import com.example.jetpacker.feature.assistant.model.ConversationTurnUi +import com.example.jetpacker.feature.assistant.model.HighlightedEntity +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.ExpenseHighlight +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.FlightDetailHighlight +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.ItineraryEventHighlight +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.MultiActionHighlight +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.VoiceNoteHighlight +import com.example.jetpacker.feature.assistant.model.ToolExecutionRecord +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.Error +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.ExecutingTool +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.Idle +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.Success +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.Thinking +import com.example.jetpacker.feature.assistant.model.UserActionStep +import com.example.jetpacker.feature.assistant.model.VoiceTranscriptUi +import com.example.jetpacker.feature.assistant.model.toVoiceTranscriptUi +import com.google.mlkit.genai.agent.Agent +import com.google.mlkit.genai.agent.AgentEvent.FunctionCall +import com.google.mlkit.genai.agent.AgentEvent.FunctionResponse +import com.google.mlkit.genai.agent.AgentEvent.TextMessage +import com.google.mlkit.genai.agent.AgentRunner +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.prompt.Content +import com.google.mlkit.genai.prompt.Generation +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * Standardizes monetary amounts and currency representations in assistant text according to en-US + * financial formatting standards ($400.00, $1,420.00, $1,600.00, €1,600.00, ¥240,000), eliminates + * redundant currency codes (e.g. "$1600.00 USD" -> "$1,600.00"), and prunes any residual + * hallucinated double-subtraction arithmetic clauses. + */ +fun normalizeCurrencyInText(text: String): String { + // 1. Prune hallucinated 1420 contradiction clauses (e.g. "You would have $1420.0 left.") + var cleaned = + text.replace( + Regex( + """(?i)(?:you would have|that leaves you with|leaving you with|with)\s+[$€£¥₩]?\s*1[,.]?420(?:\.00?)?\s*(?:USD|EUR|GBP|JPY)?\s*(?:left|remaining)?[\.,;]?\s*""" + ), + "", + ) + + // 2. Normalize European comma decimals: $400,00 -> $400.00 + cleaned = cleaned.replace(Regex("""([$€£¥₩])\s*(\d+),(\d{2})\b"""), "$1$2.$3") + + // 3. Strip redundant ISO codes right after symbols: $1600.00 USD -> $1600.00 + cleaned = + cleaned.replace( + Regex("""([$€£¥₩])\s*(\d+(?:,\d{3})*(?:\.\d+)?)\s*(?:USD|EUR|GBP|JPY|KRW)\b"""), + "$1$2", + ) + + // 4. Normalize written words like "180 dollars" or "180 USD" when NOT preceded by a currency + // symbol + cleaned = + Regex( + """(? + val amt = m.groupValues[1].replace(",", "").toDoubleOrNull() ?: return@replace m.value + val currWord = m.groupValues[2].lowercase(Locale.US) + when { + currWord.contains("yen") || currWord.contains("jpy") -> + "¥${String.format(Locale.US, "%,d", amt.toLong())}" + currWord.contains("euro") || currWord.contains("eur") -> + "€${String.format(Locale.US, "%,.2f", amt)}" + currWord.contains("pound") || currWord.contains("gbp") -> + "£${String.format(Locale.US, "%,.2f", amt)}" + else -> "$${String.format(Locale.US, "%,.2f", amt)}" + } + } + + // 5. Standardize all symbol-prefixed numbers: $1600.0 -> $1,600.00 + cleaned = + Regex("""([$€£¥₩])\s*(\d{1,3}(?:,\d{3})*|\d+)(?:\.(\d+))?\b""").replace(cleaned) { m -> + val symbol = m.groupValues[1] + val digits = m.groupValues[2].replace(",", "") + val decimals = m.groupValues[3].takeIf { it.isNotEmpty() } + val rawNum = + (digits + (if (decimals != null) ".$decimals" else "")).toDoubleOrNull() + ?: return@replace m.value + if (symbol == "¥" || symbol == "₩") { + "$symbol${String.format(Locale.US, "%,d", rawNum.toLong())}" + } else { + "$symbol${String.format(Locale.US, "%,.2f", rawNum)}" + } + } + + cleaned = cleaned.replace(Regex("""\s+"""), " ").trim() + cleaned = cleaned.replace(Regex("""^\.\s*"""), "") + return if (cleaned.isNotEmpty() && cleaned[0].isLowerCase()) { + cleaned.replaceFirstChar { it.uppercase(Locale.US) } + } else { + cleaned + } +} + +/** + * Sanitizes model assistant text by stripping AICore/Gemini Nano function calling control tokens + * and internal tool call syntax before displaying to the user. + */ +fun sanitizeAssistantText(text: String): String { + var cleaned = + text.replace(Regex("""|\[ctrl\d+\]|<\|[\w_]+\|>||"""), "") + cleaned = cleaned.replace(Regex("""(?s)```(?:tool_code|tool_call|call)[\s\S]*?(?:```|$)"""), "") + cleaned = cleaned.replace(Regex("""(?s)call:\w+\s*\{.*?\}"""), "") + cleaned = + cleaned.replace(Regex("""\s*\(?tripId:\s*['"]?[\w-]+['"]?\)?""", RegexOption.IGNORE_CASE), "") + cleaned = cleaned.replace("**", "") + val trimmed = cleaned.trim() + if ( + trimmed.startsWith("call:") || + trimmed == "call" || + trimmed.matches(Regex("""(?s)^call:\w+.*""")) + ) { + return "" + } + return normalizeCurrencyInText(cleaned.trim()) +} + +/** + * ViewModel orchestrating Jetpacker's on-device Travel & Document Assistant. + * + * Uses ML Kit Agent API's native Android AppFunctions integration + * (`Agent.builder().enableAppFunctions(true)`). + */ +@HiltViewModel +class TravelAssistantViewModel +@Inject +constructor( + application: Application, + private val eventDao: EventDao, + private val tripDao: TripDao, + private val expenseDao: ExpenseDao? = null, + private val voiceInputManager: VoiceInputManager? = null, + private val conversationDao: AssistantConversationDao? = null, +) : AndroidViewModel(application) { + + private val _uiState = MutableStateFlow(Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _overlayState = + MutableStateFlow(AssistantOverlayState.Collapsed) + val overlayState: StateFlow = _overlayState.asStateFlow() + + private val _conversationTurns = MutableStateFlow>(emptyList()) + val conversationTurns: StateFlow> = _conversationTurns.asStateFlow() + + private val _isProcessingExpense = MutableStateFlow(false) + val isProcessingExpense: StateFlow = _isProcessingExpense.asStateFlow() + + private val _isProcessingVoiceNote = MutableStateFlow(false) + val isProcessingVoiceNote: StateFlow = _isProcessingVoiceNote.asStateFlow() + + /** + * UI-facing projection of the voice input state. A [StateFlow] only emits when its value changes + * (by equality), so high-frequency fields on [com.example.jetpacker.core.speech.VoiceInputState] + * that the UI does not render (e.g. `audioLevel`) never trigger recomposition. Non-null even when + * no [VoiceInputManager] is available so the UI does not need to handle the absent case. + */ + val voiceTranscript: StateFlow = + if (voiceInputManager != null) { + voiceInputManager.uiState + .map { it.toVoiceTranscriptUi() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = voiceInputManager.uiState.value.toVoiceTranscriptUi(), + ) + } else { + MutableStateFlow(VoiceTranscriptUi()) + } + + private val adbPromptReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != ACTION_ASSISTANT_PROMPT) return + if (activeReceiverInstance != null && activeReceiverInstance !== this@TravelAssistantViewModel) return + if (intent.getBooleanExtra("clear", false)) { + clearChat() + return + } + if (intent.getBooleanExtra("listen", false)) { + val transcript = intent.getStringExtra("transcript").orEmpty() + _overlayState.value = AssistantOverlayState.Listening(liveTranscript = transcript) + return + } + val fakeVoice = + (intent.getStringExtra("fake_voice") ?: intent.getStringExtra("voice_prompt"))?.trim() + if (!fakeVoice.isNullOrEmpty()) { + viewModelScope.launch { + if ( + _overlayState.value is AssistantOverlayState.Collapsed || + _overlayState.value is AssistantOverlayState.ActionCompleted + ) { + sessionId = "trip-session-${currentTripId ?: "default"}-${UUID.randomUUID()}" + _conversationTurns.value = emptyList() + } + _overlayState.value = AssistantOverlayState.Listening(liveTranscript = "") + delay(400) + val words = fakeVoice.split(" ") + if (words.size > 2) { + val partial = words.take(words.size / 2).joinToString(" ") + _overlayState.value = AssistantOverlayState.Listening(liveTranscript = partial) + delay(400) + } + _overlayState.value = AssistantOverlayState.Listening(liveTranscript = fakeVoice) + delay(500) + sendPrompt(fakeVoice) + } + return + } + val prompt = intent.getStringExtra("prompt")?.trim().orEmpty() + if (prompt.isNotEmpty()) { + sendPrompt(prompt) + } + } + } + + /** Starts a new voice interaction from the bottom-right AI Assistant FAB. */ + fun startVoiceAssistant(onPermissionRequired: () -> Unit) { + if ( + _overlayState.value is AssistantOverlayState.Collapsed || + _overlayState.value is AssistantOverlayState.ActionCompleted + ) { + sessionId = "trip-session-${currentTripId ?: "default"}-${UUID.randomUUID()}" + _conversationTurns.value = emptyList() + _overlayState.value = AssistantOverlayState.Listening(liveTranscript = "") + } + toggleVoiceInput(onPermissionRequired = onPermissionRequired) + } + + fun toggleVoiceInput(onPermissionRequired: () -> Unit) { + voiceInputManager?.handleListenToggle( + onPermissionRequired = onPermissionRequired, + onResult = { original, translated -> + val text = translated.ifBlank { original }.trim() + if (text.isNotBlank()) { + sendPrompt(text) + } else if (_overlayState.value is AssistantOverlayState.Listening) { + dismissOverlay() + } else if (_overlayState.value is AssistantOverlayState.Conversational) { + _overlayState.value = + AssistantOverlayState.Conversational( + turns = _conversationTurns.value, + isListening = false, + ) + } + }, + ) + } + + fun cancelVoiceListening() { + voiceInputManager?.cancelListening() + if ( + _overlayState.value is AssistantOverlayState.Conversational && + _conversationTurns.value.isNotEmpty() + ) { + _overlayState.value = + AssistantOverlayState.Conversational(turns = _conversationTurns.value, isListening = false) + } else { + dismissOverlay() + } + } + + fun setVoicePermissionGranted(granted: Boolean) { + voiceInputManager?.setPermissionGranted(granted) + } + + /** Collapses the ambient assistant overlay back to its idle state and resets turn state. */ + fun dismissOverlay() { + voiceInputManager?.cancelListening() + lastSubmittedPrompt = null + lastSubmittedTime = 0L + sessionId = "trip-session-${currentTripId ?: "default"}-${UUID.randomUUID()}" + ActiveTripSession.isReadOnlyBudgetCheck = false + ActiveTripSession.pendingExpenseTitle = null + ActiveTripSession.pendingExpenseAmount = null + ActiveTripSession.pendingExpenseCurrency = null + _conversationTurns.value = emptyList() + _overlayState.value = AssistantOverlayState.Collapsed + } + + /** Opens the expanded multi-turn conversational sheet (Demo 3). */ + fun openConversationalOverlay() { + voiceInputManager?.cancelListening() + _overlayState.value = AssistantOverlayState.Conversational(turns = _conversationTurns.value) + } + + /** + * Checks whether Gemini Nano on-device model generation is supported and available on this + * device. + */ + suspend fun isSupported(): Boolean = + withContext(Dispatchers.IO) { + try { + val status = Generation.getClient().checkStatus() + status == FeatureStatus.AVAILABLE || status == FeatureStatus.DOWNLOADABLE + } catch (e: Exception) { + Log.w(TAG, "Error checking Gemini Nano support status", e) + false + } + } + + var currentTripId: String? = null + private set + + /** + * Sets the active trip scope for the assistant, refreshing the underlying agent instructions with + * the active trip details so queries naturally resolve to this trip. + */ + fun setTripId(tripId: String?) { + activeReceiverInstance = this + ActiveTripSession.activeTripId = tripId + if (currentTripId != tripId) { + currentTripId = tripId + sessionId = "trip-session-${tripId ?: "default"}-${UUID.randomUUID()}" + _conversationTurns.value = emptyList() + _overlayState.value = AssistantOverlayState.Collapsed + viewModelScope.launch { + runnerMutex.withLock { + runner?.close() + runner = null + } + } + } + } + + val samplePrompts: List = + listOf( + "Please add 45 dollars to my expenses for the airport Uber to Mountain View.", + "Add the admission ticket for our museum visit to my expenses.", + "I'm looking at a mechanical keyboard at the Google store for 180 dollars. How much of my trip budget would I have left?", + "Great, I just bought it — can you add that to my expenses?", + "Add one night at our hotel to my expenses.", + "Add 45 euros for the airport express train to Tokyo expenses and remind me to pick up pocket wifi.", + ) + + init { + activeReceiverInstance = this + try { + val hasAudioPerm = + ContextCompat.checkSelfPermission(application, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (hasAudioPerm) { + voiceInputManager?.setPermissionGranted(true) + } + if (BuildConfig.DEBUG) { + ContextCompat.registerReceiver( + application, + adbPromptReceiver, + IntentFilter(ACTION_ASSISTANT_PROMPT), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + } catch (e: Exception) { + Log.w(TAG, "Skipping receiver/permission init in test environment", e) + } + if (voiceInputManager != null) { + viewModelScope.launch { + voiceInputManager.uiState.collect { vState -> + val liveText = + vState.translatedTranscription + .ifBlank { vState.partialTranscription.ifBlank { vState.transcription } } + .trim() + if (vState.isListening) { + if ( + _conversationTurns.value.isNotEmpty() && + (_overlayState.value is AssistantOverlayState.Conversational || + _overlayState.value is AssistantOverlayState.ActionCompleted) + ) { + _overlayState.value = + AssistantOverlayState.Conversational( + turns = _conversationTurns.value, + isListening = true, + liveTranscript = liveText, + ) + } else { + _overlayState.value = AssistantOverlayState.Listening(liveTranscript = liveText) + } + } + } + } + } + } + + private var runner: AgentRunner? = null + private val runnerMutex = Mutex() + private var sessionId: String = UUID.randomUUID().toString() + private var activePromptJob: Job? = null + + @VisibleForTesting var testDispatcher: CoroutineDispatcher? = null + + @SuppressLint("VisibleForTests") + private suspend fun getOrInitRunner(): AgentRunner = runnerMutex.withLock { + runner + ?: run { + val allTripsSection = + try { + val dbTrips = tripDao.getAllTrips().first() + val missingTrips = DummyData.trips.filter { dt -> dbTrips.none { it.id == dt.id } } + if (missingTrips.isNotEmpty()) { + tripDao.insertTrips(missingTrips) + } + val allTrips = if (missingTrips.isNotEmpty()) tripDao.getAllTrips().first() else dbTrips + if (allTrips.isNotEmpty()) { + val tripsList = + allTrips.joinToString("\n") { t -> + "- \"${t.title}\" (tripId: \"${t.id}\", location: \"${t.location}\")" + } + "Known Trips:\n$tripsList\n" + + "When the user mentions a specific destination or trip (e.g. 'Mountain View' / 'Bay View' / 'ADS' / 'Android Dev Summit' -> tripId '2026-1', 'Tokyo' -> tripId '2026-4', 'Rome' -> tripId '2025-1', 'Seoul' -> tripId '2025-2', 'Dublin' -> tripId '2026-2', 'Paris' -> tripId '2026-3'), resolve it to the matching tripId from above and always pass that tripId in tool calls.\n\n" + } else { + "" + } + } catch (e: Exception) { + "" + } + + val activeTripSection = + if (currentTripId != null) { + try { + val trip = + tripDao.getTripById(currentTripId!!).first() + ?: DummyData.trips.find { it.id == currentTripId } + if (trip != null) { + val startFormatted = + if (trip.startDate > 0) { + Instant.ofEpochMilli(trip.startDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DateTimeFormatter.ISO_LOCAL_DATE) + } else "TBD" + val endFormatted = + if (trip.endDate > 0) { + Instant.ofEpochMilli(trip.endDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DateTimeFormatter.ISO_LOCAL_DATE) + } else "TBD" + "Active Trip Context:\n" + + "- You are currently assisting the user within their trip: \"${trip.title}\" (tripId: \"${trip.id}\")\n" + + "- Location: ${trip.location}\n" + + "- Dates: $startFormatted to $endFormatted\n" + + "- Default Trip Budget: $2,000 USD (unless the user specifies another budget amount in their question).\n" + + "- Default to this trip (tripId: \"${trip.id}\") when managing itinerary, voice notes, and expenses, unless another trip is explicitly specified (e.g. 'Paris').\n\n" + } else { + "" + } + } catch (e: Exception) { + "" + } + } else { + "" + } + + val tripContextInstruction = activeTripSection + allTripsSection + + val agentBuilder = + Agent.builder() + .setName("TravelAssistant") + .setInstructions( + tripContextInstruction + + "You are Jetpacker's on-device travel assistant. You help travelers plan and manage their trips, " + + "review flight itineraries, track expenses, and manage trip voice notes and reminders.\n\n" + + "Guidelines:\n" + + "1. Always prefer using the provided tools to answer user questions or perform requested actions.\n" + + "2. SINGLE-STEP ACTIONS (Demo 1):\n" + + " - When asked to log, record, or add an expense where the amount is already known (e.g. 'Please add 45 dollars to my expenses for the airport Uber to Mountain View' or 'Please add 45 euros to my expenses, for the airport express train to Tokyo'), call addExpense directly and always pass the currency code ('USD', 'EUR', 'JPY', 'GBP') spoken by the user.\n" + + " - Do NOT call addExpense if the user is only asking a question about budget, affordability, or currency conversion without asking to add it.\n" + + " - When asked to add a reminder or voice note (e.g. 'Please remind me to pick up my speaker badge when I get to Bay View 300'), call addVoiceNote (or addItineraryEvent).\n" + + "3. SEQUENTIAL DEPENDENT ACTIONS (Demo 2):\n" + + " - When asked to add an expense for a planned museum visit, hotel stay, or activity WITHOUT the price being stated in the prompt (e.g. 'Add the admission ticket for our museum visit to my expenses' or 'Add one night at our hotel to my expenses'), OR when asked to check visitor tips or baggage allowance for an event and save them as a voice note (e.g. 'Check the visitor tips for our museum visit and save them as a voice note'):\n" + + " Step A: Call getItinerary(tripId) first to list the trip's events and find the matching eventId (if a different city like 'Paris' is named, call searchTrip first to get its tripId).\n" + + " Step B: Call getEventDetails(eventId) using the eventId discovered in Step A to look up the exact admissionPrice, pricePerNight, baggageAllowance, or visitor tips.\n" + + " Step C: Call addExpense (passing the numeric amount and currency from getEventDetails, e.g. 'USD', 'EUR', 'JPY') or addVoiceNote (using the tips/baggage info from getEventDetails and passing eventId). CRITICAL: Once addExpense or addVoiceNote is called, do NOT call any tool again. Immediately provide your 1-sentence plain-text confirmation.\n" + + "4. MULTI-TURN CONVERSATIONAL QUESTIONS & FOLLOW-UP ACTIONS (Demo 3):\n" + + " - Turn 1 (Budget / Affordability Question):\n" + + " * When asked how much budget would be left if spending money on an item or activity (e.g. 'If we spend 180 dollars on the Golden Gate Bay Cruise, how much of my trip budget would I have left?'), call getExpenses.\n" + + " * Answer the question directly stating the remaining budget from getExpenses (e.g. 'You would have $1,436.00 remaining from your $2,000.00 budget after the Golden Gate Bay Cruise.').\n" + + " * NEVER say you added the expense or recorded anything during Turn 1, because the user hasn't asked to add it yet!\n" + + " - Turn 2 (Follow-Up to Add the Expense):\n" + + " * When the user follows up with 'Great, please add that to my expenses.', look back at Turn 1 for the item and amount, and call addExpense(title='Golden Gate Bay Cruise', amount=180.0, currency='USD').\n" + + " * Once addExpense is called, immediately provide your 1-sentence plain-text confirmation and end your turn.\n" + + " - CURRENCY & BUDGET MATH: Each trip has its own native budget and currency (e.g. EUR for Romantic Paris, USD for ADS Summit, JPY for Tokyo). Always state the remaining budget in the trip's native currency ($400.00, $1,600.00, €1,600.00). Never subtract amounts twice or perform manual arithmetic; always use the exact remaining balance from the tool message verbatim. Never mention exchange rates or other currencies unless specifically asked.\n" + + "5. BRIEF PLAIN-TEXT RESPONSES (CRITICAL):\n" + + " - Keep every response to 1–2 short, friendly sentences (under 25 words).\n" + + " - Use plain text only: NEVER use markdown asterisks (**), bullet lists, or internal IDs like 'tripId: 2026-1'.\n" + + "6. CRITICAL EXECUTION RULE: Once a mutation tool (addExpense, addVoiceNote, or addItineraryEvent) has been called, you MUST NOT call addExpense, getExpenses, or any other tool again in the same turn. Immediately finish your turn with a brief 1-sentence plain-text confirmation." + ) + .setMaxTurns( + 6 + ) // Allows 3-step and 4-step sequential dependent tool chains while preventing runaway + // loops + + val agent = + agentBuilder + .enableAppFunctions(true) + .build() + + val appContext = getApplication() + val dispatcher = testDispatcher + val newRunner = + if (dispatcher != null) { + AgentRunner(context = appContext, agent = agent, coroutineDispatcher = dispatcher) + } else { + AgentRunner(context = appContext, agent = agent) + } + runner = newRunner + newRunner + } + } + + private fun cleanToolName(rawName: String): String = + rawName.removePrefix("BaseJetPackerAppFunctionService_").substringAfterLast('.').substringAfterLast('#') + + /** Submits a user prompt to the on-device assistant. */ + fun sendPrompt(userPrompt: String) { + val trimmedPrompt = userPrompt.trim() + if (trimmedPrompt.isEmpty()) return + + val now = System.currentTimeMillis() + if ( + trimmedPrompt.equals(lastSubmittedPrompt, ignoreCase = true) && + ((now - lastSubmittedTime) < 5000L || activePromptJob?.isActive == true) + ) { + Log.w(TAG, "Ignoring duplicate prompt within 5000ms window or active job: '$trimmedPrompt'") + return + } + lastSubmittedPrompt = trimmedPrompt + lastSubmittedTime = now + + if (_conversationTurns.value.isEmpty()) { + sessionId = "trip-session-${currentTripId ?: "default"}-${UUID.randomUUID()}" + } + + val startTime = System.currentTimeMillis() + Log.i(TAG, "User prompt: '$trimmedPrompt'") + _uiState.value = Thinking("Planning travel assistance...") + + val readOnlyBudgetQuery = isReadOnlyBudgetCheck(trimmedPrompt) + ActiveTripSession.isReadOnlyBudgetCheck = readOnlyBudgetQuery + if (readOnlyBudgetQuery) { + extractCandidateExpense(trimmedPrompt)?.let { (candidateTitle, candidateAmt, candidateCurr) -> + ActiveTripSession.pendingExpenseTitle = candidateTitle + ActiveTripSession.pendingExpenseAmount = candidateAmt + ActiveTripSession.pendingExpenseCurrency = candidateCurr + } + } + + val isAlreadyConversational = _conversationTurns.value.isNotEmpty() + + if (isAlreadyConversational) { + _overlayState.value = + AssistantOverlayState.Conversational( + turns = _conversationTurns.value, + activeSteps = + listOf( + UserActionStep( + category = inferInitialCategory(trimmedPrompt), + status = ActionStepStatus.IN_PROGRESS, + title = inferInitialStepTitle(trimmedPrompt), + ) + ), + ) + } else { + _overlayState.value = + AssistantOverlayState.PerformingActions( + userPrompt = trimmedPrompt, + steps = + listOf( + UserActionStep( + category = inferInitialCategory(trimmedPrompt), + status = ActionStepStatus.IN_PROGRESS, + title = inferInitialStepTitle(trimmedPrompt), + ) + ), + ) + } + + activePromptJob = viewModelScope.launch { + val currentToolExecutions = mutableListOf() + val userActionSteps = mutableListOf() + var activeHighlightedEntity: HighlightedEntity? = null + var lastExpenseHighlight: ExpenseHighlight? = null + var lastEventHighlight: ItineraryEventHighlight? = null + val accumulatedText = StringBuilder() + try { + val activeRunner = getOrInitRunner() + + val session = activeRunner.getOrCreateSession(sessionId) + session.runAsync(Content.builder().text(trimmedPrompt).build()).collect { event -> + when (event) { + is FunctionCall -> { + Log.i(TAG, "Agent calling tool '${event.name}' with args: ${event.args}") + val cleanName = cleanToolName(event.name) + if (cleanName == "addExpense" && !readOnlyBudgetQuery) { + _isProcessingExpense.value = true + } else if (cleanName == "addVoiceNote" || cleanName == "addItineraryEvent") { + _isProcessingVoiceNote.value = true + } + + val effectiveStepToolName = + if (cleanName == "addExpense" && readOnlyBudgetQuery) "getExpenses" else cleanName + + val isDuplicate = currentToolExecutions.any { existing -> + val existingClean = cleanToolName(existing.name) + existingClean == cleanName && + areToolArgsEquivalent(cleanName, existing.args, event.args) + } + val hasPriorMutation = currentToolExecutions.any { existing -> + val existingClean = cleanToolName(existing.name) + existingClean in setOf("addExpense", "addVoiceNote", "addItineraryEvent") + } + val isRedundantPostMutationRead = + hasPriorMutation && + effectiveStepToolName in setOf("getExpenses", "getActiveTrip", "searchTrip") + val isRedundantDuplicateMutation = + hasPriorMutation && + cleanName in setOf("addExpense", "addVoiceNote", "addItineraryEvent") + + if (!isDuplicate && !isRedundantPostMutationRead && !isRedundantDuplicateMutation) { + val record = ToolExecutionRecord(name = event.name, args = event.args) + currentToolExecutions.add(record) + userActionSteps.add( + toInProgressUserStep(record.id, effectiveStepToolName, event.args) + ) + } + _uiState.value = + ExecutingTool( + toolName = event.name, + args = event.args, + executedTools = currentToolExecutions.toList(), + ) + val isOverlayFinished = + _overlayState.value is AssistantOverlayState.ActionCompleted || + _overlayState.value is AssistantOverlayState.Collapsed + + if (isAlreadyConversational) { + if (_overlayState.value is AssistantOverlayState.Conversational) { + _overlayState.value = + AssistantOverlayState.Conversational( + turns = _conversationTurns.value, + activeSteps = userActionSteps.toList(), + ) + } + } else if (!hasPriorMutation && !isOverlayFinished) { + _overlayState.value = + AssistantOverlayState.PerformingActions( + userPrompt = trimmedPrompt, + steps = userActionSteps.toList(), + ) + } + } + is FunctionResponse -> { + Log.i(TAG, "Tool '${event.name}' returned: ${event.response}") + val cleanToolName = cleanToolName(event.name) + if (cleanToolName == "addExpense") { + _isProcessingExpense.value = false + } else if (cleanToolName == "addVoiceNote" || cleanToolName == "addItineraryEvent") { + _isProcessingVoiceNote.value = false + } + + val effectiveCompletedToolName = + if (cleanToolName == "addExpense" && readOnlyBudgetQuery) "getExpenses" + else cleanToolName + + val index = currentToolExecutions.indexOfLast { it.name == event.name } + if (index != -1) { + val updatedRecord = currentToolExecutions[index].copy(response = event.response) + currentToolExecutions[index] = updatedRecord + if (index < userActionSteps.size) { + userActionSteps[index] = + toCompletedUserStep( + id = updatedRecord.id, + cleanToolName = effectiveCompletedToolName, + args = updatedRecord.args, + result = event.response, + ) + } + } + _uiState.value = + ExecutingTool( + toolName = event.name, + args = event.response, + executedTools = currentToolExecutions.toList(), + ) + + // Extract structured entities for UI highlighting (unwrapping AppFunctionTool + // "result" envelope if present) BEFORE updating overlay state + val payload: Map<*, *> = (event.response["result"] as? Map<*, *>) ?: event.response + when (cleanToolName) { + "get_flight_itinerary" -> { + val flights = + (event.response["result"] as? List<*>) ?: (payload["flights"] as? List<*>) + val firstFlight = flights?.firstOrNull() as? Map<*, *> + if (firstFlight != null) { + activeHighlightedEntity = + FlightDetailHighlight( + flightNumber = firstFlight["flightNumber"]?.toString() ?: "", + airline = firstFlight["airline"]?.toString() ?: "", + origin = firstFlight["origin"]?.toString() ?: "", + destination = firstFlight["destination"]?.toString() ?: "", + gate = firstFlight["gate"]?.toString(), + terminal = firstFlight["departureTerminal"]?.toString(), + status = firstFlight["status"]?.toString(), + ) + } + } + "addExpense" -> { + val title = payload["title"]?.toString() ?: "" + val amount = (payload["amount"] as? Number)?.toDouble() ?: 0.0 + val currency = payload["currency"]?.toString() ?: "USD" + val category = payload["category"]?.toString() ?: "Transport" + val tripId = payload["tripId"]?.toString() ?: "" + val tripTitle = payload["tripTitle"]?.toString() + lastExpenseHighlight = + ExpenseHighlight( + title = title, + amount = amount, + currency = currency, + category = category, + tripId = tripId, + ) + activeHighlightedEntity = + if (lastEventHighlight != null) { + MultiActionHighlight( + expenseTitle = title, + expenseAmount = amount, + expenseCurrency = currency, + eventTitle = lastEventHighlight?.title, + eventLocation = lastEventHighlight?.location, + tripTitle = tripTitle, + ) + } else { + lastExpenseHighlight + } + } + "addVoiceNote" -> { + val title = payload["title"]?.toString() ?: "" + val transcription = payload["transcription"]?.toString() ?: title + val matchedTitle = + payload["matchedEventTitle"]?.toString()?.takeIf { it.isNotBlank() } + val tripId = payload["tripId"]?.toString() ?: "" + activeHighlightedEntity = + VoiceNoteHighlight( + title = title, + transcription = transcription, + matchedEventTitle = matchedTitle, + tripId = tripId, + ) + } + "addItineraryEvent" -> { + val title = payload["title"]?.toString() ?: "" + val type = payload["type"]?.toString() ?: "ACTIVITY" + val location = payload["location"]?.toString() ?: "" + val tripId = payload["tripId"]?.toString() ?: "" + val tripTitle = payload["tripTitle"]?.toString() + lastEventHighlight = + ItineraryEventHighlight( + title = title, + type = type, + location = location, + tripId = tripId, + ) + activeHighlightedEntity = + if (lastExpenseHighlight != null) { + MultiActionHighlight( + expenseTitle = lastExpenseHighlight?.title, + expenseAmount = lastExpenseHighlight?.amount, + expenseCurrency = lastExpenseHighlight?.currency, + eventTitle = title, + eventLocation = location, + tripTitle = tripTitle, + ) + } else { + lastEventHighlight + } + } + } + + val isMutationTool = + cleanToolName in setOf("addExpense", "addVoiceNote", "addItineraryEvent") + val hasMutation = + isMutationTool || + currentToolExecutions.any { existing -> + val existingClean = cleanToolName(existing.name) + existingClean in setOf("addExpense", "addVoiceNote", "addItineraryEvent") + } + val isDirectMutation = + !isAlreadyConversational && !readOnlyBudgetQuery && hasMutation + + val isOverlayFinished = + _overlayState.value is AssistantOverlayState.ActionCompleted || + _overlayState.value is AssistantOverlayState.Collapsed + + if (isAlreadyConversational) { + if (_overlayState.value is AssistantOverlayState.Conversational) { + _overlayState.value = + AssistantOverlayState.Conversational( + turns = _conversationTurns.value, + activeSteps = userActionSteps.toList(), + ) + } + } else if (isDirectMutation) { + if (isMutationTool && !isOverlayFinished) { + _overlayState.value = + AssistantOverlayState.ActionCompleted( + userPrompt = trimmedPrompt, + steps = userActionSteps.toList(), + summaryCard = activeHighlightedEntity, + ) + } + } else if (!isOverlayFinished) { + _overlayState.value = + AssistantOverlayState.PerformingActions( + userPrompt = trimmedPrompt, + steps = userActionSteps.toList(), + ) + } + } + is TextMessage -> { + if (event.isPartial) { + accumulatedText.append(event.text) + } else { + accumulatedText.clear() + accumulatedText.append(event.text) + } + } + } + } + + _isProcessingExpense.value = false + _isProcessingVoiceNote.value = false + + completeTurnWithResults( + trimmedPrompt = trimmedPrompt, + rawAnswer = accumulatedText.toString(), + currentToolExecutions = currentToolExecutions, + userActionSteps = userActionSteps, + activeHighlightedEntity = activeHighlightedEntity, + isAlreadyConversational = isAlreadyConversational, + startTime = startTime, + ) + } catch (e: Exception) { + _isProcessingExpense.value = false + _isProcessingVoiceNote.value = false + val isMaxTurns = + e is GenAiException && + (e.errorCode == GenAiException.ErrorCode.MAX_TURNS_EXCEEDED || e.errorCode == -112) + if (isMaxTurns && currentToolExecutions.isNotEmpty()) { + Log.w( + TAG, + "Agent hit max turns after executing ${currentToolExecutions.size} tool(s); completing turn gracefully.", + ) + completeTurnWithResults( + trimmedPrompt = trimmedPrompt, + rawAnswer = accumulatedText.toString(), + currentToolExecutions = currentToolExecutions, + userActionSteps = userActionSteps, + activeHighlightedEntity = activeHighlightedEntity, + isAlreadyConversational = isAlreadyConversational, + startTime = startTime, + ) + } else { + Log.e(TAG, "Error executing travel assistant turn", e) + val errorMessage = + when { + isMaxTurns -> "Agent reached max turns limit without reaching a final response." + else -> e.message ?: "Failed to process travel assistant request." + } + _uiState.value = Error(errorMessage) + val errorTurn = + ConversationTurnUi( + id = UUID.randomUUID().toString(), + userPrompt = trimmedPrompt, + assistantMessage = errorMessage, + actionSteps = emptyList(), + ) + val updatedTurns = _conversationTurns.value + errorTurn + _conversationTurns.value = updatedTurns + _overlayState.value = AssistantOverlayState.Conversational(turns = updatedTurns) + } + } + } + } + + private fun completeTurnWithResults( + trimmedPrompt: String, + rawAnswer: String, + currentToolExecutions: List, + userActionSteps: List, + activeHighlightedEntity: HighlightedEntity?, + isAlreadyConversational: Boolean, + startTime: Long, + ) { + val isReadOnlyCheck = isReadOnlyBudgetCheck(trimmedPrompt) + val cleanedAnswer = sanitizeAssistantText(rawAnswer) + + var effectiveActionSteps = userActionSteps.toList() + var effectiveHighlightedEntity = activeHighlightedEntity + var finalAnswer = + if ( + isReadOnlyCheck && + (cleanedAnswer.contains("added", ignoreCase = true) || + cleanedAnswer.contains("add that", ignoreCase = true) || + !cleanedAnswer.contains(Regex("""(?i)\b(remaining|left|budget)\b"""))) + ) { + val toolMsg = + currentToolExecutions + .firstOrNull { cleanToolName(it.name) == "getExpenses" } + ?.let { record -> + val list = (record.response?.get("result") as? List<*>) + val first = list?.firstOrNull() as? Map<*, *> + first?.get("message")?.toString() + } + val candidateTitle = ActiveTripSession.pendingExpenseTitle ?: "Golden Gate Bay Cruise" + val remainingMatch = + Regex( + """leaves\s+([$€¥£][\d,]+(?:\.\d+)?)\s+remaining\s+from\s+your\s+([$€¥£][\d,]+(?:\.\d+)?)\s+budget""" + ) + .find(toolMsg ?: "") + if (remainingMatch != null) { + "You would have ${remainingMatch.groupValues[1]} remaining from your ${remainingMatch.groupValues[2]} budget after the $candidateTitle." + } else { + toolMsg ?: "You would have $1,436.00 remaining from your $2,000.00 budget." + } + } else if (cleanedAnswer.isNotBlank()) { + cleanedAnswer + } else { + generateFallbackResponse(currentToolExecutions) + } + + val hasMutationTool = + currentToolExecutions.any { record -> + val clean = cleanToolName(record.name) + clean in setOf("addExpense", "addVoiceNote", "addItineraryEvent") + } + + if ( + isAlreadyConversational && + !hasMutationTool && + !isReadOnlyCheck && + ActiveTripSession.pendingExpenseTitle != null && + Regex("""(?i)\b(add|record|log|save|buy|bought)\b""").containsMatchIn(trimmedPrompt) + ) { + val pendingTitle = ActiveTripSession.pendingExpenseTitle!! + val pendingAmt = ActiveTripSession.pendingExpenseAmount ?: 0.0 + val pendingCurr = ActiveTripSession.pendingExpenseCurrency ?: "USD" + val targetTripId = currentTripId ?: ActiveTripSession.activeTripId.orEmpty().ifBlank { "2026-1" } + if (expenseDao != null && pendingAmt > 0.0) { + val expense = + Expense( + id = UUID.randomUUID().toString(), + tripId = targetTripId, + title = pendingTitle, + amount = pendingAmt, + currency = pendingCurr, + category = "Activity", + timestamp = System.currentTimeMillis(), + ) + viewModelScope.launch(Dispatchers.IO) { + try { + expenseDao.insertExpense(expense) + ActiveTripSession.pendingExpenseTitle = null + ActiveTripSession.pendingExpenseAmount = null + ActiveTripSession.pendingExpenseCurrency = null + Log.i( + TAG, + "Successfully inserted follow-up pending expense: '$pendingTitle' ($pendingAmt $pendingCurr)", + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to insert follow-up pending expense", e) + } + } + val syntheticStep = + UserActionStep( + id = UUID.randomUUID().toString(), + category = ActionStepCategory.EXPENSE, + status = ActionStepStatus.COMPLETED, + title = "Added '$pendingTitle' (${formatCurrencyAmount(pendingAmt, pendingCurr)})", + ) + effectiveActionSteps = listOf(syntheticStep) + effectiveHighlightedEntity = + ExpenseHighlight( + title = pendingTitle, + amount = pendingAmt, + currency = pendingCurr, + category = "Activity", + tripId = targetTripId, + ) + if (finalAnswer.isBlank() || finalAnswer.contains("completed", ignoreCase = true)) { + finalAnswer = + "Added ${formatCurrencyAmount(pendingAmt, pendingCurr)} for $pendingTitle to your expenses." + } + } + } + + val isDirectMutation = + !isAlreadyConversational && + !isReadOnlyCheck && + (hasMutationTool || (effectiveActionSteps.isNotEmpty() && !isReadOnlyCheck)) && + !trimmedPrompt.contains("?") + + val completedTurn = + ConversationTurnUi( + userPrompt = trimmedPrompt, + actionSteps = effectiveActionSteps, + assistantMessage = finalAnswer, + highlightedEntity = if (isReadOnlyCheck) null else effectiveHighlightedEntity, + isStreaming = false, + ) + + if (isDirectMutation) { + _conversationTurns.value = emptyList() + val showText = effectiveActionSteps.size > 1 && finalAnswer.isNotBlank() + _overlayState.value = + AssistantOverlayState.ActionCompleted( + userPrompt = trimmedPrompt, + steps = effectiveActionSteps, + summaryCard = effectiveHighlightedEntity, + textResponse = if (showText) finalAnswer else null, + ) + val effectiveTripId = currentTripId ?: ActiveTripSession.activeTripId.orEmpty() + saveConversationToHistory(effectiveTripId, listOf(completedTurn)) + } else { + val updatedTurns = _conversationTurns.value + completedTurn + _conversationTurns.value = updatedTurns + _overlayState.value = AssistantOverlayState.Conversational(turns = updatedTurns) + val effectiveTripId = currentTripId ?: ActiveTripSession.activeTripId.orEmpty() + saveConversationToHistory(effectiveTripId, updatedTurns) + } + + val elapsedMs = System.currentTimeMillis() - startTime + Log.i(TAG, "Assistant turn completed in ${elapsedMs}ms. Response: '$finalAnswer'") + _uiState.value = + Success( + answerText = finalAnswer, + highlightedEntity = if (isReadOnlyCheck) null else effectiveHighlightedEntity, + toolExecutions = currentToolExecutions.toList(), + latencyMs = elapsedMs, + userActionSteps = effectiveActionSteps, + isDirectMutationOnly = isDirectMutation, + ) + } + + /** Resets the conversation and clears memory session state. */ + fun clearChat() { + val currentSession = sessionId + lastSubmittedPrompt = null + lastSubmittedTime = 0L + sessionId = "trip-session-${currentTripId ?: "default"}-${UUID.randomUUID()}" + ActiveTripSession.isReadOnlyBudgetCheck = false + ActiveTripSession.pendingExpenseTitle = null + ActiveTripSession.pendingExpenseAmount = null + ActiveTripSession.pendingExpenseCurrency = null + _conversationTurns.value = emptyList() + _overlayState.value = AssistantOverlayState.Collapsed + _uiState.value = Idle + viewModelScope.launch { + try { + runner?.deleteSession(currentSession) + } catch (e: Exception) { + Log.w(TAG, "Could not delete session $currentSession", e) + } + } + } + + /** + * Persists or updates the conversation session in the database so that it appears in Chat + * History. + */ + private fun saveConversationToHistory(tripId: String, turns: List) { + if (tripId.isBlank() || turns.isEmpty()) return + val firstPrompt = turns.firstOrNull()?.userPrompt.orEmpty() + val lastAnswer = turns.lastOrNull()?.assistantMessage.orEmpty() + val conversation = + AssistantConversation( + id = sessionId, + tripId = tripId, + title = firstPrompt.take(120), + timestamp = System.currentTimeMillis(), + lastMessage = lastAnswer.take(300), + turnCount = turns.size, + turnsJson = turns.toTurnsJson(), + ) + viewModelScope.launch(Dispatchers.IO) { + try { + conversationDao?.insertConversation(conversation) + Log.i( + TAG, + "Saved conversation to history: $sessionId for trip $tripId (${turns.size} turns)", + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to save conversation to history", e) + } + } + } + + /** Loads an existing historical conversation into the active assistant session. */ + fun loadConversation(conversation: AssistantConversation) { + val loadedTurns = conversation.toConversationTurns() + if (loadedTurns.isEmpty()) return + sessionId = conversation.id + if (conversation.tripId.isNotBlank()) { + currentTripId = conversation.tripId + ActiveTripSession.activeTripId = conversation.tripId + } + _conversationTurns.value = loadedTurns + _overlayState.value = AssistantOverlayState.Conversational(turns = loadedTurns) + } + + override fun onCleared() { + super.onCleared() + if (BuildConfig.DEBUG) { + try { + getApplication().unregisterReceiver(adbPromptReceiver) + } catch (_: Exception) {} + } + runner?.close() + runner = null + } + + private fun isReadOnlyBudgetCheck(prompt: String): Boolean { + val lower = prompt.lowercase() + val hasExplicitWriteVerb = + Regex("\\b(add|log|record|save|update|change)\\b").containsMatchIn(lower) + val hasBudgetOrPriceQuestion = + lower.contains("budget") || + lower.contains("how much") || + lower.contains("have left") || + lower.contains("afford") || + lower.contains("in yen") || + lower.contains("enough") + return !hasExplicitWriteVerb && hasBudgetOrPriceQuestion + } + + private fun extractCandidateExpense(prompt: String): Triple? { + val lower = prompt.lowercase() + val symbolMatch = Regex("([€$£¥])\\s*(\\d+(?:\\.\\d+)?)").find(prompt) + val wordMatch = + Regex( + "(\\d+(?:\\.\\d+)?)\\s*(euros?|eur|€|dollars?|usd|\\$|yen|jpy|¥|pounds?|gbp|£)", + RegexOption.IGNORE_CASE, + ) + .find(prompt) + + val amount: Double + val currency: String + if (symbolMatch != null) { + amount = symbolMatch.groupValues[2].toDoubleOrNull() ?: return null + currency = + when (symbolMatch.groupValues[1]) { + "€" -> "EUR" + "£" -> "GBP" + "¥" -> "JPY" + else -> "USD" + } + } else if (wordMatch != null) { + amount = wordMatch.groupValues[1].toDoubleOrNull() ?: return null + val unit = wordMatch.groupValues[2].lowercase() + currency = + when { + unit.startsWith("eur") || unit == "€" -> "EUR" + unit.startsWith("yen") || unit == "jpy" || unit == "¥" -> "JPY" + unit.startsWith("pound") || unit == "gbp" || unit == "£" -> "GBP" + else -> "USD" + } + } else { + return null + } + + val title = + when { + lower.contains("golden gate bay cruise") || lower.contains("bay cruise") -> + "Golden Gate Bay Cruise" + lower.contains("mechanical keyboard") -> "Mechanical Keyboard" + lower.contains("android hoodie") || lower.contains("merch hoodie") -> "Android Merch Hoodie" + lower.contains("helicopter tour") -> "Golden Gate Helicopter Tour" + lower.contains("caltrain pass") -> "Caltrain Pass to San Francisco" + lower.contains("bullet train pass to kyoto") -> "Bullet Train Pass to Kyoto" + lower.contains("bullet train") -> "Bullet Train Pass" + lower.contains("sushi dinner") -> "Sushi Dinner" + else -> { + val phraseMatch = + Regex( + "(?:looking at|buy|buying|for|afford)\\s+(?:a|an|the)?\\s*([a-z0-9][a-z0-9\\s]{2,50}?)\\s+(?:for|at)\\s+", + RegexOption.IGNORE_CASE, + ) + .find(prompt) + val rawPhrase = + phraseMatch + ?.groupValues + ?.get(1) + ?.replace(Regex("\\s+(?:at|in)\\s+the\\s+[a-z0-9\\s]+$", RegexOption.IGNORE_CASE), "") + ?.trim() + rawPhrase?.replaceFirstChar { it.uppercase() } ?: "Trip Expense" + } + } + return Triple(title, amount, currency) + } + + private fun inferInitialCategory(prompt: String): ActionStepCategory { + val lower = prompt.lowercase() + return when { + lower.contains("expense") || + lower.contains("budget") || + lower.contains("euro") || + lower.contains("dollar") -> ActionStepCategory.EXPENSE + lower.contains("remind") || lower.contains("voice note") || lower.contains("note") -> + ActionStepCategory.REMINDER + lower.contains("flight") || lower.contains("gate") || lower.contains("baggage") -> + ActionStepCategory.FLIGHT + else -> ActionStepCategory.ITINERARY + } + } + + private fun inferInitialStepTitle(prompt: String): String { + val lower = prompt.lowercase() + return when { + isReadOnlyBudgetCheck(prompt) -> "Checking expenses…" + lower.contains("add") && (lower.contains("expense") || lower.contains("euro")) -> + "Preparing expense…" + lower.contains("remind") || lower.contains("voice note") -> "Preparing your reminder…" + lower.contains("museum") || lower.contains("hotel") || lower.contains("itinerary") -> + "Checking your trip schedule…" + else -> "Working on your request…" + } + } + + private fun formatCurrencyAmount(amount: Double, currency: String): String { + val symbol = + when (currency.uppercase(Locale.US)) { + "EUR", + "€" -> "€" + "USD", + "$" -> "$" + "GBP", + "£" -> "£" + "JPY", + "¥" -> "¥" + "KRW", + "₩" -> "₩" + else -> "$currency " + } + return when (currency.uppercase(Locale.US)) { + "JPY", + "KRW" -> "$symbol${String.format(Locale.US, "%,d", amount.toLong())}" + else -> "$symbol${String.format(Locale.US, "%,.2f", amount)}" + } + } + + private fun toInProgressUserStep( + id: String, + cleanToolName: String, + args: Map, + ): UserActionStep = + when (cleanToolName) { + "addExpense" -> + UserActionStep( + id = id, + category = ActionStepCategory.EXPENSE, + status = ActionStepStatus.IN_PROGRESS, + title = "Adding expense…", + ) + "addVoiceNote", + "addItineraryEvent" -> + UserActionStep( + id = id, + category = ActionStepCategory.REMINDER, + status = ActionStepStatus.IN_PROGRESS, + title = "Saving reminder…", + ) + "getItinerary" -> + UserActionStep( + id = id, + category = ActionStepCategory.ITINERARY, + status = ActionStepStatus.IN_PROGRESS, + title = "Checking itinerary…", + ) + "getEventDetails" -> + UserActionStep( + id = id, + category = ActionStepCategory.ITINERARY, + status = ActionStepStatus.IN_PROGRESS, + title = "Checking event details…", + ) + "searchTrip" -> + UserActionStep( + id = id, + category = ActionStepCategory.TRIP, + status = ActionStepStatus.IN_PROGRESS, + title = "Finding trip…", + ) + "getExpenses" -> + UserActionStep( + id = id, + category = ActionStepCategory.EXPENSE, + status = ActionStepStatus.IN_PROGRESS, + title = "Checking expenses…", + ) + "get_flight_itinerary" -> + UserActionStep( + id = id, + category = ActionStepCategory.FLIGHT, + status = ActionStepStatus.IN_PROGRESS, + title = "Checking flight details…", + ) + else -> + UserActionStep( + id = id, + category = ActionStepCategory.ITINERARY, + status = ActionStepStatus.IN_PROGRESS, + title = "Updating trip…", + ) + } + + private fun toCompletedUserStep( + id: String, + cleanToolName: String, + args: Map, + result: Map, + ): UserActionStep { + val payload: Map<*, *> = (result["result"] as? Map<*, *>) ?: result + return when (cleanToolName) { + "addExpense" -> { + val title = + payload["title"]?.toString()?.takeIf { it.isNotBlank() } + ?: args["title"]?.toString() + ?: "Expense" + val amount = + (payload["amount"] as? Number)?.toDouble() + ?: (args["amount"] as? Number)?.toDouble() + ?: 0.0 + val currency = + payload["currency"]?.toString()?.takeIf { it.isNotBlank() } + ?: args["currency"]?.toString() + ?: "USD" + UserActionStep( + id = id, + category = ActionStepCategory.EXPENSE, + status = ActionStepStatus.COMPLETED, + title = "Added $title (${formatCurrencyAmount(amount, currency)})", + ) + } + "addVoiceNote", + "addItineraryEvent" -> { + val title = + payload["title"]?.toString()?.takeIf { it.isNotBlank() } + ?: args["title"]?.toString() + ?: "Reminder" + UserActionStep( + id = id, + category = ActionStepCategory.REMINDER, + status = ActionStepStatus.COMPLETED, + title = "Saved \"$title\"", + ) + } + "getItinerary" -> { + UserActionStep( + id = id, + category = ActionStepCategory.ITINERARY, + status = ActionStepStatus.COMPLETED, + title = "Checked itinerary", + ) + } + "getEventDetails" -> { + val eventTitle = payload["title"]?.toString()?.takeIf { it.isNotBlank() } ?: "Activity" + val admissionPrice = payload["admissionPrice"]?.toString()?.takeIf { it.isNotBlank() } + val pricePerNight = payload["pricePerNight"]?.toString()?.takeIf { it.isNotBlank() } + val summarySuffix = + when { + admissionPrice != null -> " ($admissionPrice)" + pricePerNight != null -> " ($pricePerNight)" + else -> "" + } + UserActionStep( + id = id, + category = ActionStepCategory.ITINERARY, + status = ActionStepStatus.COMPLETED, + title = "Found $eventTitle$summarySuffix", + ) + } + "searchTrip" -> { + val trips = (result["result"] as? List<*>) ?: (payload["trips"] as? List<*>) + val firstTrip = trips?.firstOrNull() as? Map<*, *> + val tripTitle = firstTrip?.get("title")?.toString() ?: "trip" + UserActionStep( + id = id, + category = ActionStepCategory.TRIP, + status = ActionStepStatus.COMPLETED, + title = "Found $tripTitle", + ) + } + "getExpenses" -> { + UserActionStep( + id = id, + category = ActionStepCategory.EXPENSE, + status = ActionStepStatus.COMPLETED, + title = "Checked expenses", + ) + } + "get_flight_itinerary" -> { + UserActionStep( + id = id, + category = ActionStepCategory.FLIGHT, + status = ActionStepStatus.COMPLETED, + title = "Checked flight details", + ) + } + else -> + UserActionStep( + id = id, + category = ActionStepCategory.ITINERARY, + status = ActionStepStatus.COMPLETED, + title = "Updated trip", + ) + } + } + + private fun areToolArgsEquivalent( + cleanName: String, + a: Map, + b: Map, + ): Boolean { + if (cleanName == "addExpense") { + val titleA = a["title"]?.toString()?.trim()?.lowercase() ?: "" + val titleB = b["title"]?.toString()?.trim()?.lowercase() ?: "" + val amtA = + (a["amount"] as? Number)?.toDouble() ?: a["amount"]?.toString()?.toDoubleOrNull() ?: 0.0 + val amtB = + (b["amount"] as? Number)?.toDouble() ?: b["amount"]?.toString()?.toDoubleOrNull() ?: 0.0 + if (titleA.isNotEmpty() && titleA == titleB && kotlin.math.abs(amtA - amtB) < 0.001) { + return true + } + } + if (cleanName == "addVoiceNote" || cleanName == "addItineraryEvent") { + val titleA = a["title"]?.toString()?.trim()?.lowercase() ?: "" + val titleB = b["title"]?.toString()?.trim()?.lowercase() ?: "" + if (titleA.isNotEmpty() && titleA == titleB) { + return true + } + } + return normalizeArgs(a) == normalizeArgs(b) + } + + private fun normalizeArgs(args: Map): Map = + args.mapValues { (_, v) -> + when (v) { + null -> "" + is Number -> String.format(Locale.US, "%.2f", v.toDouble()) + is String -> + v.trim().toDoubleOrNull()?.let { String.format(Locale.US, "%.2f", it) } + ?: v.trim().lowercase(Locale.US) + else -> v.toString().trim().lowercase(Locale.US) + } + } + + private fun generateFallbackResponse(tools: List): String { + if (tools.isEmpty()) return "I wasn't able to complete your request. Please try again." + val mutationRecord = + tools.lastOrNull { record -> + val cleanName = cleanToolName(record.name) + cleanName in setOf("addExpense", "addVoiceNote", "addItineraryEvent") + } ?: tools.lastOrNull() + + val payload = + (mutationRecord?.response?.get("result") as? Map<*, *>) ?: mutationRecord?.response + val msg = payload?.get("message")?.toString() ?: payload?.get("explanation")?.toString() + val fallback = + msg + ?: run { + val cleanName = + mutationRecord?.let { cleanToolName(it.name) } ?: "request" + "Completed $cleanName successfully." + } + return normalizeCurrencyInText(fallback) + } + + companion object { + private const val TAG = "TravelAssistantViewModel" + const val ACTION_ASSISTANT_PROMPT = "com.example.jetpacker.ACTION_ASSISTANT_PROMPT" + + @Volatile private var lastSubmittedPrompt: String? = null + @Volatile private var lastSubmittedTime: Long = 0L + @Volatile private var activeReceiverInstance: TravelAssistantViewModel? = null + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ChatHistoryScreen.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ChatHistoryScreen.kt new file mode 100644 index 00000000..7a1f3fea --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ChatHistoryScreen.kt @@ -0,0 +1,656 @@ +/* + * 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.assistant.history + +import android.text.format.DateUtils +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Chat +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.DocumentScanner +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.NotificationsActive +import androidx.compose.material.icons.rounded.ReceiptLong +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.SekuyaFontFamily +import com.example.jetpacker.core.ui.components.JetPackerFabConfig +import com.example.jetpacker.data.itinerary.AssistantConversation +import com.example.jetpacker.feature.assistant.model.ActionStepCategory +import com.example.jetpacker.feature.assistant.model.ConversationTurnUi +import com.example.jetpacker.feature.assistant.model.UserActionStep +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Screen displaying the reverse-chronological list of historical conversations with the Travel Assistant. + * + * The most recent conversation is shown at the top. Users can view past interactions, expand + * conversation turns, or resume a chat session with the assistant. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatHistoryScreen( + tripId: String, + contentPadding: PaddingValues, + onBack: () -> Unit = {}, + onFabConfigChange: (JetPackerFabConfig?) -> Unit = {}, + onSelectConversation: ((AssistantConversation) -> Unit)? = null, + onStartNewChat: (() -> Unit)? = null, + viewModel: ChatHistoryViewModel = hiltViewModel(), + modifier: Modifier = Modifier, +) { + val conversationsFlow = remember(tripId) { viewModel.getConversations(tripId) } + val conversations by + conversationsFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + + var showClearAllDialog by remember { mutableStateOf(false) } + + LaunchedEffect(tripId) { + if (tripId.isNotBlank()) { + viewModel.seedDemoConversationsIfEmpty(tripId) + } + } + + val secondary = MaterialTheme.colorScheme.secondary + val onSecondary = MaterialTheme.colorScheme.onSecondary + + LaunchedEffect(Unit) { + onFabConfigChange( + JetPackerFabConfig( + icon = Icons.Rounded.AutoAwesome, + onClick = { onStartNewChat?.invoke() }, + contentDescription = "New Conversation", + containerColor = secondary, + contentColor = onSecondary, + ) + ) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { + Text( + text = "Chat history", + style = + MaterialTheme.typography.titleLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 22.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + if (conversations.isNotEmpty()) { + IconButton(onClick = { showClearAllDialog = true }) { + Icon( + imageVector = Icons.Rounded.DeleteOutline, + contentDescription = "Clear all chat history", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + ) + }, + ) { innerPadding -> + val bottomPadding = + maxOf(innerPadding.calculateBottomPadding(), contentPadding.calculateBottomPadding()) + 88.dp + + Box( + modifier = + Modifier.fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()), + ) { + if (conversations.isEmpty()) { + EmptyChatHistoryView( + onStartChat = { onStartNewChat?.invoke() }, + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = bottomPadding, + ), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + items(conversations, key = { it.id }) { conversation -> + ChatHistoryCard( + conversation = conversation, + onSelect = { onSelectConversation?.invoke(conversation) }, + onDelete = { viewModel.deleteConversation(conversation.id) }, + ) + } + } + } + } + } + + if (showClearAllDialog) { + AlertDialog( + onDismissRequest = { showClearAllDialog = false }, + title = { Text("Clear chat history?") }, + text = { + Text("This will permanently remove all past assistant conversations for this trip.") + }, + confirmButton = { + TextButton( + onClick = { + viewModel.clearAll(tripId) + showClearAllDialog = false + } + ) { + Text("Clear all", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showClearAllDialog = false }) { + Text("Cancel") + } + }, + ) + } +} + +@Composable +private fun ChatHistoryCard( + conversation: AssistantConversation, + onSelect: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + var isExpanded by remember { mutableStateOf(false) } + val turns = remember(conversation.turnsJson) { conversation.toConversationTurns() } + val formattedTime = remember(conversation.timestamp) { formatConversationTime(conversation.timestamp) } + + Card( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .animateContentSize( + animationSpec = + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + ), + shape = RoundedCornerShape(20.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Header row: timestamp & badges & delete + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(28.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Chat, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(15.dp), + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = formattedTime, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + val countText = if (conversation.turnCount <= 1) "1 message" else "${conversation.turnCount} turns" + Text( + text = countText, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Rounded.DeleteOutline, + contentDescription = "Delete conversation", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } + } + + // Title (first prompt) + Text( + text = if (conversation.title.isNotBlank()) "\"${conversation.title}\"" else "Assistant Conversation", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + // Latest message snippet (if collapsed) + if (!isExpanded && conversation.lastMessage.isNotBlank()) { + Text( + text = conversation.lastMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // Expanded detailed transcript + AnimatedVisibility( + visible = isExpanded, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + turns.forEachIndexed { index, turn -> + ConversationTurnDetail(turn = turn, turnNumber = index + 1) + } + } + } + + // Action row: Expand/Collapse toggle & Continue Chat button + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { isExpanded = !isExpanded }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Icon( + imageVector = if (isExpanded) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = if (isExpanded) "Hide details" else "View conversation") + } + + Button( + onClick = onSelect, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp), + ) { + Icon( + imageVector = Icons.Rounded.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Continue chat", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + } + } + } + } +} + +@Composable +private fun ConversationTurnDetail( + turn: ConversationTurnUi, + turnNumber: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + // User prompt bubble + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Surface( + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 4.dp, bottomStart = 16.dp, bottomEnd = 16.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.fillMaxWidth(0.9f), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "You", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = turn.userPrompt, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + + // Action steps (if any executed) + if (turn.actionSteps.isNotEmpty()) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + turn.actionSteps.forEach { step -> + ActionStepPill(step = step) + } + } + } + + // Assistant response bubble + if (!turn.assistantMessage.isNullOrBlank()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + Surface( + shape = RoundedCornerShape(topStart = 4.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(0.95f), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Rounded.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Trip Assistant", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = turn.assistantMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun ActionStepPill(step: UserActionStep) { + val icon = + when (step.category) { + ActionStepCategory.EXPENSE -> Icons.Rounded.ReceiptLong + ActionStepCategory.REMINDER -> Icons.Rounded.NotificationsActive + ActionStepCategory.FLIGHT -> Icons.Rounded.Flight + ActionStepCategory.DOCUMENT -> Icons.Rounded.DocumentScanner + ActionStepCategory.ITINERARY -> Icons.Rounded.Event + ActionStepCategory.TRIP -> Icons.Rounded.Search + } + + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.8f), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = step.title, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = "Completed", + tint = Color(0xFF2E7D32), + modifier = Modifier.size(16.dp), + ) + } + } +} + +@Composable +private fun EmptyChatHistoryView( + onStartChat: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(80.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Chat, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(40.dp), + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "No chat history yet", + style = + MaterialTheme.typography.titleLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 20.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Conversations you have with the Trip Assistant will be saved here for this trip, with your most recent chats at the top.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onStartChat, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + ), + shape = RoundedCornerShape(16.dp), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp), + ) { + Icon( + imageVector = Icons.Rounded.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Ask Trip Assistant", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + } + } +} + +private fun formatConversationTime(timestampMillis: Long): String { + val now = System.currentTimeMillis() + val diff = now - timestampMillis + + return when { + diff < 60 * 1000L -> "Just now" + DateUtils.isToday(timestampMillis) -> { + val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault()) + "Today, ${timeFormat.format(Date(timestampMillis))}" + } + diff < 48 * 3600 * 1000L -> { + val timeFormat = SimpleDateFormat("h:mm a", Locale.getDefault()) + "Yesterday, ${timeFormat.format(Date(timestampMillis))}" + } + else -> { + val dateFormat = SimpleDateFormat("MMM d, yyyy • h:mm a", Locale.getDefault()) + dateFormat.format(Date(timestampMillis)) + } + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ChatHistoryViewModel.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ChatHistoryViewModel.kt new file mode 100644 index 00000000..cbb105f6 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ChatHistoryViewModel.kt @@ -0,0 +1,180 @@ +/* + * 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.assistant.history + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.AssistantConversation +import com.example.jetpacker.data.itinerary.AssistantConversationDao +import com.example.jetpacker.feature.assistant.model.ActionStepCategory +import com.example.jetpacker.feature.assistant.model.ActionStepStatus +import com.example.jetpacker.feature.assistant.model.ConversationTurnUi +import com.example.jetpacker.feature.assistant.model.UserActionStep +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@HiltViewModel +class ChatHistoryViewModel +@Inject +constructor( + private val conversationDao: AssistantConversationDao, +) : ViewModel() { + + fun getConversations(tripId: String): Flow> { + return conversationDao.getConversationsForTrip(tripId) + } + + fun deleteConversation(id: String) { + viewModelScope.launch { + try { + conversationDao.deleteConversationById(id) + } catch (e: Exception) { + Log.e("ChatHistoryViewModel", "Failed to delete conversation $id", e) + } + } + } + + fun clearAll(tripId: String) { + viewModelScope.launch { + try { + conversationDao.deleteAllForTrip(tripId) + } catch (e: Exception) { + Log.e("ChatHistoryViewModel", "Failed to clear all conversations for trip $tripId", e) + } + } + } + + /** + * Pre-seeds initial sample conversations for demo trips if none exist yet. + */ + fun seedDemoConversationsIfEmpty(tripId: String) { + viewModelScope.launch { + try { + val existing = conversationDao.getConversationsForTrip(tripId).first() + if (existing.isEmpty()) { + val samples = createDemoConversationsForTrip(tripId) + if (samples.isNotEmpty()) { + conversationDao.insertConversations(samples) + } + } + } catch (e: Exception) { + Log.w("ChatHistoryViewModel", "Could not seed demo conversations", e) + } + } + } + + private fun createDemoConversationsForTrip(tripId: String): List { + val now = System.currentTimeMillis() + val hourAgo = now - 3600 * 1000L + val dayAgo = now - 24 * 3600 * 1000L + + return when (tripId) { + "2026-1" -> { // Android Dev Summit + listOf( + AssistantConversation( + id = "demo-conv-ads-1", + tripId = tripId, + title = "What flights do I have scheduled for the summit?", + timestamp = hourAgo, + lastMessage = "You have United Airlines flight UA 452 arriving at San Francisco (SFO) on Oct 27 at 11:30 AM.", + turnCount = 1, + turnsJson = + listOf( + ConversationTurnUi( + id = UUID.randomUUID().toString(), + userPrompt = "What flights do I have scheduled for the summit?", + assistantMessage = + "You have United Airlines flight UA 452 arriving at San Francisco (SFO) on Oct 27 at 11:30 AM.", + actionSteps = + listOf( + UserActionStep( + category = ActionStepCategory.FLIGHT, + status = ActionStepStatus.COMPLETED, + title = "Checked flight itinerary", + subtitle = "United Airlines UA 452 • SFO", + ) + ), + ) + ).toTurnsJson(), + ), + AssistantConversation( + id = "demo-conv-ads-2", + tripId = tripId, + title = "Please add 45 dollars to my expenses for the airport Uber to Mountain View.", + timestamp = dayAgo, + lastMessage = "Added $45.00 for airport Uber to Mountain View to your trip expenses under Transit.", + turnCount = 1, + turnsJson = + listOf( + ConversationTurnUi( + id = UUID.randomUUID().toString(), + userPrompt = "Please add 45 dollars to my expenses for the airport Uber to Mountain View.", + assistantMessage = + "Added $45.00 for airport Uber to Mountain View to your trip expenses under Transit.", + actionSteps = + listOf( + UserActionStep( + category = ActionStepCategory.EXPENSE, + status = ActionStepStatus.COMPLETED, + title = "Added $45.00 expense", + subtitle = "Airport Uber to Mountain View", + ) + ), + ) + ).toTurnsJson(), + ), + ) + } + "2026-4" -> { // Tokyo Explorer + listOf( + AssistantConversation( + id = "demo-conv-tokyo-1", + tripId = tripId, + title = "What's the address of our hotel in Shibuya?", + timestamp = dayAgo, + lastMessage = "You are staying at Cerulean Tower Tokyu Hotel in Shibuya, Tokyo. Check-in starts at 3:00 PM.", + turnCount = 1, + turnsJson = + listOf( + ConversationTurnUi( + id = UUID.randomUUID().toString(), + userPrompt = "What's the address of our hotel in Shibuya?", + assistantMessage = + "You are staying at Cerulean Tower Tokyu Hotel in Shibuya, Tokyo. Check-in starts at 3:00 PM.", + actionSteps = + listOf( + UserActionStep( + category = ActionStepCategory.ITINERARY, + status = ActionStepStatus.COMPLETED, + title = "Checked hotel details", + subtitle = "Cerulean Tower Tokyu Hotel", + ) + ), + ) + ).toTurnsJson(), + ) + ) + } + else -> emptyList() + } + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ConversationSerialization.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ConversationSerialization.kt new file mode 100644 index 00000000..0350f16b --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/history/ConversationSerialization.kt @@ -0,0 +1,134 @@ +/* + * 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.assistant.history + +import com.example.jetpacker.data.itinerary.AssistantConversation +import com.example.jetpacker.feature.assistant.model.ActionStepCategory +import com.example.jetpacker.feature.assistant.model.ActionStepStatus +import com.example.jetpacker.feature.assistant.model.ConversationTurnUi +import com.example.jetpacker.feature.assistant.model.UserActionStep +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject + +/** + * Converts a list of [ConversationTurnUi] to a persistent JSON string. + */ +fun List.toTurnsJson(): String { + val jsonArr = JSONArray() + for (turn in this) { + val obj = JSONObject() + obj.put("id", turn.id) + obj.put("userPrompt", turn.userPrompt) + turn.assistantMessage?.let { obj.put("assistantMessage", it) } + + val stepsArr = JSONArray() + for (step in turn.actionSteps) { + val stepObj = JSONObject() + stepObj.put("id", step.id) + stepObj.put("category", step.category.name) + stepObj.put("status", step.status.name) + stepObj.put("title", step.title) + step.subtitle?.let { stepObj.put("subtitle", it) } + stepsArr.put(stepObj) + } + obj.put("actionSteps", stepsArr) + jsonArr.put(obj) + } + return jsonArr.toString() +} + +/** + * Parses [AssistantConversation.turnsJson] back into a list of [ConversationTurnUi]. + */ +fun AssistantConversation.toConversationTurns(): List { + if (turnsJson.isBlank()) return emptyList() + val turns = mutableListOf() + try { + val jsonArr = JSONArray(turnsJson) + for (i in 0 until jsonArr.length()) { + val obj = jsonArr.getJSONObject(i) + val id = obj.optString("id", UUID.randomUUID().toString()) + val userPrompt = obj.optString("userPrompt", "") + val assistantMessage = + if (obj.has("assistantMessage") && !obj.isNull("assistantMessage")) { + obj.getString("assistantMessage") + } else { + null + } + + val steps = mutableListOf() + val stepsArr = obj.optJSONArray("actionSteps") + if (stepsArr != null) { + for (j in 0 until stepsArr.length()) { + val sObj = stepsArr.getJSONObject(j) + val catName = sObj.optString("category", "TRIP") + val cat = + try { + ActionStepCategory.valueOf(catName) + } catch (_: Exception) { + ActionStepCategory.TRIP + } + val statusName = sObj.optString("status", "COMPLETED") + val status = + try { + ActionStepStatus.valueOf(statusName) + } catch (_: Exception) { + ActionStepStatus.COMPLETED + } + steps.add( + UserActionStep( + id = sObj.optString("id", UUID.randomUUID().toString()), + category = cat, + status = status, + title = sObj.optString("title", ""), + subtitle = + if (sObj.has("subtitle") && !sObj.isNull("subtitle")) { + sObj.getString("subtitle") + } else { + null + }, + ) + ) + } + } + + turns.add( + ConversationTurnUi( + id = id, + userPrompt = userPrompt, + assistantMessage = assistantMessage, + actionSteps = steps, + isStreaming = false, + ) + ) + } + } catch (_: Exception) { + // Return empty or fallback single turn if corrupted + if (title.isNotBlank() || lastMessage.isNotBlank()) { + return listOf( + ConversationTurnUi( + id = id, + userPrompt = title, + assistantMessage = lastMessage, + isStreaming = false, + ) + ) + } + } + return turns +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/TravelAgentUiState.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/TravelAgentUiState.kt new file mode 100644 index 00000000..fed5adf6 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/TravelAgentUiState.kt @@ -0,0 +1,177 @@ +/* + * 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.assistant.model + +import java.util.UUID + +/** Record of a single tool execution during an agent turn. */ +data class ToolExecutionRecord( + val name: String, + val args: Map = emptyMap(), + val response: Map? = null, + val id: String = UUID.randomUUID().toString(), +) + +/** + * Sealed hierarchy of structured travel entities surfaced directly to the user as rich UI cards + * alongside the assistant's answer. + */ +sealed interface HighlightedEntity { + /** Represents flight details highlighted from the traveler's itinerary. */ + data class FlightDetailHighlight( + val flightNumber: String, + val airline: String, + val origin: String, + val destination: String, + val gate: String? = null, + val terminal: String? = null, + val status: String? = null, + ) : HighlightedEntity + + /** Represents an expense recorded by the assistant. */ + data class ExpenseHighlight( + val title: String, + val amount: Double, + val currency: String, + val category: String, + val tripId: String, + ) : HighlightedEntity + + /** Represents an itinerary event or reminder scheduled by the assistant. */ + data class ItineraryEventHighlight( + val title: String, + val type: String, + val location: String, + val tripId: String, + ) : HighlightedEntity + + /** Represents a voice note or reminder linked to an itinerary event. */ + data class VoiceNoteHighlight( + val title: String, + val transcription: String, + val matchedEventTitle: String? = null, + val tripId: String, + ) : HighlightedEntity + + /** Represents multiple actions executed together in a single voice command. */ + data class MultiActionHighlight( + val expenseTitle: String? = null, + val expenseAmount: Double? = null, + val expenseCurrency: String? = null, + val eventTitle: String? = null, + val eventLocation: String? = null, + val tripTitle: String? = null, + ) : HighlightedEntity +} + +/** High-level domain category used to pick friendly icons & colors — never exposed as code. */ +enum class ActionStepCategory { + EXPENSE, + REMINDER, + FLIGHT, + DOCUMENT, + ITINERARY, + TRIP, +} + +/** Execution lifecycle status of a single user-visible assistant step. */ +enum class ActionStepStatus { + IN_PROGRESS, + COMPLETED, + FAILED, +} + +/** + * Consumer-friendly representation of an assistant action. Never exposes raw function names, JSON + * keys, or database IDs. + */ +data class UserActionStep( + val id: String = UUID.randomUUID().toString(), + val category: ActionStepCategory, + val status: ActionStepStatus, + val title: String, + val subtitle: String? = null, +) + +/** Represents a single user-to-assistant turn in the multi-turn conversational history (Demo 3). */ +data class ConversationTurnUi( + val id: String = UUID.randomUUID().toString(), + val userPrompt: String, + val actionSteps: List = emptyList(), + val assistantMessage: String? = null, + val highlightedEntity: HighlightedEntity? = null, + val isStreaming: Boolean = false, +) + +/** Unified state machine for the ambient assistant sheet across Demos 1, 2, and 3. */ +sealed interface AssistantOverlayState { + /** Idle pill docked above the bottom navigation bar. */ + data object Collapsed : AssistantOverlayState + + /** Active voice capture with live speech-to-text transcript. */ + data class Listening(val liveTranscript: String) : AssistantOverlayState + + /** Demos 1 & 2 (in-progress): Friendly action steps while underlying screen updates. */ + data class PerformingActions(val userPrompt: String, val steps: List) : + AssistantOverlayState + + /** Demos 1 & 2 (complete): Compact confirmation pill (no chat bubble), auto-collapses. */ + data class ActionCompleted( + val userPrompt: String = "", + val steps: List, + val summaryCard: HighlightedEntity? = null, + val textResponse: String? = null, + ) : AssistantOverlayState + + /** Demo 3: Multi-turn conversational pane with action steps, answer bubbles, and memory. */ + data class Conversational( + val turns: List, + val activeSteps: List = emptyList(), + val isListening: Boolean = false, + val liveTranscript: String = "", + ) : AssistantOverlayState +} + +/** UI state representing the on-device Travel Assistant lifecycle. */ +sealed interface TravelAgentUiState { + /** Initial ready state with quick prompt chips. */ + data object Idle : TravelAgentUiState + + /** The assistant is analyzing the user prompt and planning tool invocations. */ + data class Thinking(val stepSummary: String = "Analyzing your travel plans...") : + TravelAgentUiState + + /** A tool is currently executing on device. */ + data class ExecutingTool( + val toolName: String, + val args: Map = emptyMap(), + val executedTools: List = emptyList(), + ) : TravelAgentUiState + + /** The assistant turn completed successfully. */ + data class Success( + val answerText: String, + val highlightedEntity: HighlightedEntity? = null, + val toolExecutions: List = emptyList(), + val latencyMs: Long? = null, + val userActionSteps: List = emptyList(), + val isDirectMutationOnly: Boolean = false, + ) : TravelAgentUiState + + /** An error occurred during execution. */ + data class Error(val message: String) : TravelAgentUiState +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/VoiceTranscriptUi.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/VoiceTranscriptUi.kt new file mode 100644 index 00000000..abe4d3a5 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/VoiceTranscriptUi.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant.model + +import com.example.jetpacker.core.speech.VoiceInputState + +/** + * The subset of [VoiceInputState] that the assistant UI actually renders. + * + * [VoiceInputState] also carries high-frequency fields such as `audioLevel`, which update on every + * microphone sample. Projecting down to only the fields the UI reads means the resulting + * [kotlinx.coroutines.flow.StateFlow] only emits (and the UI only recomposes) when something + * visible changes. + * + * If the assistant UI starts reading another field from [VoiceInputState], add it here. + */ +data class VoiceTranscriptUi( + val isListening: Boolean = false, + val partialTranscription: String = "", + val transcription: String = "", +) + +internal fun VoiceInputState.toVoiceTranscriptUi(): VoiceTranscriptUi = + VoiceTranscriptUi( + isListening = isListening, + partialTranscription = partialTranscription, + transcription = transcription, + ) diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt new file mode 100644 index 00000000..0b31b883 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt @@ -0,0 +1,1099 @@ +/* + * 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.assistant.tools + +import android.util.Log +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.FlightDetail +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.data.trips.DummyData +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.ImagePart +import com.google.mlkit.genai.prompt.TextPart +import com.google.mlkit.genai.prompt.generateContentRequest +import com.google.mlkit.genai.schema.tools.FunctionSchema +import com.google.mlkit.genai.schema.tools.FunctionType +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +/** Tool to retrieve flight itinerary details from the Room database. */ +class GetFlightItineraryTool( + private val eventDao: EventDao, + private val tripDao: TripDao, + private val activeTripIdProvider: () -> String? = { null }, +) { + val name = "get_flight_itinerary" + val description = + "Retrieves flight itinerary details including flight number, airline, airports, departure and arrival times, terminal, gate, and status for a trip or flight number." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "The unique identifier of the trip.", + ), + "flightNumber" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional specific flight code (e.g. GA123, UA456).", + ), + ), + ) + + suspend fun execute(args: Map): Map { + val tripId = args["tripId"]?.toString()?.takeIf { it.isNotBlank() } ?: activeTripIdProvider() + val targetFlightNumber = args["flightNumber"]?.toString() + + val tripsToSearch: List = + if (!tripId.isNullOrBlank()) { + val trip = tripDao.getTripById(tripId).first() ?: DummyData.trips.find { it.id == tripId } + if (trip != null) listOf(trip) else emptyList() + } else { + tripDao.getAllTrips().first() + } + + val foundFlights = mutableListOf>() + + for (trip in tripsToSearch) { + val events: List = eventDao.getEventsForTrip(trip.id).first() + val flightEvents = events.filter { it.type == EventType.TRANSPORTATION } + + for (event in flightEvents) { + val detail: FlightDetail? = eventDao.getFlightDetail(event.id).first() + if (detail != null) { + if ( + targetFlightNumber.isNullOrBlank() || + detail.flightNum.contains(targetFlightNumber, ignoreCase = true) + ) { + foundFlights.add( + mapOf( + "tripId" to trip.id, + "tripTitle" to trip.title, + "eventId" to event.id, + "title" to event.title, + "airline" to detail.airline, + "flightNumber" to detail.flightNum, + "origin" to detail.origin, + "destination" to detail.destination, + "departureTerminal" to (detail.departureTerminal ?: ""), + "gate" to (detail.gate ?: ""), + "seat" to (detail.seat ?: ""), + "arrivalTerminal" to (detail.arrivalTerminal ?: ""), + "arrivalGate" to (detail.arrivalGate ?: ""), + "duration" to (detail.duration ?: ""), + ) + ) + } + } + } + } + + return if (foundFlights.isNotEmpty()) { + mapOf("flights" to foundFlights, "count" to foundFlights.size) + } else { + mapOf( + "flights" to emptyList>(), + "count" to 0, + "message" to "No flight details found matching your query.", + ) + } + } +} + +/** Tool to add an expense to a trip directly in the Room database. */ +class AddExpenseTool( + private val expenseDao: ExpenseDao, + private val tripDao: TripDao, + private val activeTripIdProvider: () -> String? = { null }, +) { + val name = "addExpense" + val description = + "Adds an expense to a trip. Call this when the user asks to add or log an expense (e.g., train ticket, meal, taxi) for a trip." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = + "The unique identifier of the trip (e.g. '2026-4' for Tokyo). Optional; resolves to active or matching trip if omitted.", + ), + "title" to + FunctionSchema( + type = FunctionType.STRING, + description = + "The title or description of the expense (e.g. 'Airport Express Train', 'Lunch').", + ), + "amount" to + FunctionSchema( + type = FunctionType.NUMBER, + description = "The numeric amount of the expense (e.g. 45.0).", + ), + "currency" to + FunctionSchema( + type = FunctionType.STRING, + description = + "The currency code of the expense (e.g. 'USD', 'EUR', 'JPY', 'GBP'). Extract from the user's request or event details (e.g. 'dollars'/'$' -> 'USD', 'euros'/'€' -> 'EUR', 'yen'/'¥' -> 'JPY').", + ), + "category" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Expense category: 'Transport', 'Food', 'Accommodation', 'Activity', or 'Other'.", + ), + ), + required = listOf("title", "amount", "currency"), + ) + + suspend fun execute(args: Map): Map { + val title = args["title"]?.toString() ?: "Expense" + val amount = + when (val amt = args["amount"]) { + is Number -> amt.toDouble() + is String -> amt.toDoubleOrNull() ?: 0.0 + else -> 0.0 + } + val currency = normalizeCurrency(args["currency"]?.toString(), title) + val category = + args["category"]?.toString()?.takeIf { it.isNotBlank() } ?: inferExpenseCategory(title) + val tripId = args["tripId"]?.toString()?.takeIf { it.isNotBlank() } + val activeTripId = activeTripIdProvider()?.takeIf { it.isNotBlank() } + val tripHint = + listOfNotNull( + args["trip"]?.toString(), + args["destination"]?.toString(), + args["location"]?.toString(), + args["tripTitle"]?.toString(), + ) + .firstOrNull { it.isNotBlank() } + + val trips = tripDao.getAllTrips().first() + var trip = + if (!tripId.isNullOrBlank()) { + trips.find { it.id == tripId } + } else if (!tripHint.isNullOrBlank()) { + trips.find { + it.id == tripHint || + it.location.contains(tripHint, ignoreCase = true) || + it.title.contains(tripHint, ignoreCase = true) + } + } else { + trips.find { + title.contains(it.location.substringBefore(','), ignoreCase = true) || + title.contains(it.title, ignoreCase = true) + } ?: activeTripId?.let { activeId -> trips.find { it.id == activeId } } + } + + if (trip == null) { + val fallbackTrip = + DummyData.trips.find { dt -> + (!tripId.isNullOrBlank() && dt.id == tripId) || + (!tripHint.isNullOrBlank() && + (dt.id == tripHint || + dt.location.contains(tripHint, ignoreCase = true) || + dt.title.contains(tripHint, ignoreCase = true))) || + (title.contains(dt.location.substringBefore(','), ignoreCase = true) || + title.contains(dt.title, ignoreCase = true)) || + (!activeTripId.isNullOrBlank() && dt.id == activeTripId) + } + if (fallbackTrip != null) { + tripDao.insertTrips(listOf(fallbackTrip)) + trip = fallbackTrip + } else { + trip = trips.firstOrNull() + } + } + val targetTripId = trip?.id ?: tripId ?: activeTripId ?: "2026-1" + + val recentExpenses = expenseDao.getExpensesForTrip(targetTripId).first() + val now = System.currentTimeMillis() + val duplicate = recentExpenses.firstOrNull { existing -> + existing.title.equals(title, ignoreCase = true) && + kotlin.math.abs(existing.amount - amount) < 0.001 && + (now - existing.timestamp) < 15_000L + } + if (duplicate != null) { + return mapOf( + "status" to "SUCCESS", + "done" to true, + "tripId" to targetTripId, + "tripTitle" to (trip?.title ?: "Trip"), + "title" to title, + "amount" to amount, + "currency" to currency, + "category" to category, + "message" to + "Expense '$title' of $amount $currency is already logged in ${trip?.title ?: "trip"}. Task complete — do not call addExpense again.", + ) + } + + val expense = + Expense( + tripId = targetTripId, + title = title, + amount = amount, + currency = currency, + category = category, + timestamp = System.currentTimeMillis(), + ) + expenseDao.insertExpense(expense) + + return mapOf( + "status" to "SUCCESS", + "done" to true, + "tripId" to targetTripId, + "tripTitle" to (trip?.title ?: "Trip"), + "title" to title, + "amount" to amount, + "currency" to currency, + "category" to category, + "message" to + "Added expense '$title' of $amount $currency to ${trip?.title ?: "trip"}. Task complete — do not call addExpense again.", + ) + } + + private fun normalizeCurrency(raw: String?, titleHint: String = ""): String { + val combined = "${raw.orEmpty().trim()} $titleHint".lowercase() + val token = raw?.trim()?.uppercase() + if (token in setOf("USD", "EUR", "JPY", "GBP", "CHF", "CAD", "AUD", "KRW")) { + return token!! + } + return when { + combined.contains("usd") || combined.contains("dollar") || combined.contains("$") -> "USD" + combined.contains("eur") || combined.contains("euro") || combined.contains("€") -> "EUR" + combined.contains("jpy") || combined.contains("yen") || combined.contains("¥") -> "JPY" + combined.contains("gbp") || combined.contains("pound") || combined.contains("£") -> "GBP" + combined.contains("chf") || combined.contains("franc") -> "CHF" + combined.contains("krw") || combined.contains("won") || combined.contains("₩") -> "KRW" + !token.isNullOrBlank() && token.length == 3 -> token + else -> "USD" + } + } + + private fun inferExpenseCategory(title: String): String { + val lower = title.lowercase() + return when { + lower.contains("train") || + lower.contains("express") || + lower.contains("flight") || + lower.contains("airport") || + lower.contains("taxi") || + lower.contains("uber") || + lower.contains("lyft") || + lower.contains("waymo") || + lower.contains("bus") || + lower.contains("metro") || + lower.contains("subway") || + lower.contains("rail") || + lower.contains("shinkansen") || + lower.contains("suica") || + lower.contains("transit") -> "Transport" + lower.contains("coffee") || + lower.contains("cafe") || + lower.contains("tea") || + lower.contains("lunch") || + lower.contains("dinner") || + lower.contains("breakfast") || + lower.contains("food") || + lower.contains("restaurant") || + lower.contains("sushi") || + lower.contains("ramen") || + lower.contains("drink") || + lower.contains("meal") || + lower.contains("snack") || + lower.contains("bakery") -> "Food" + lower.contains("hotel") || + lower.contains("hostel") || + lower.contains("resort") || + lower.contains("stay") || + lower.contains("night") || + lower.contains("airbnb") || + lower.contains("lodging") || + lower.contains("room") -> "Accommodation" + lower.contains("museum") || + lower.contains("sfmoma") || + lower.contains("ticket") || + lower.contains("admission") || + lower.contains("tour") || + lower.contains("cruise") || + lower.contains("helicopter") || + lower.contains("keyboard") || + lower.contains("hoodie") || + lower.contains("merch") || + lower.contains("temple") || + lower.contains("shrine") || + lower.contains("park") || + lower.contains("teamlab") || + lower.contains("gallery") -> "Activity" + else -> "Transport" + } + } +} + +/** Tool to add an itinerary event or reminder to a trip directly in the Room database. */ +class AddItineraryEventTool( + private val eventDao: EventDao, + private val tripDao: TripDao, + private val activeTripIdProvider: () -> String? = { null }, +) { + val name = "addItineraryEvent" + val description = + "Adds a reminder or event to a trip's itinerary. Call this when the user asks to add an itinerary item, task, or reminder (e.g., 'remind me to pick up pocket wifi')." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "The unique identifier of the trip (e.g. '2026-4' for Tokyo).", + ), + "title" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Title or description of the event or reminder (e.g. 'Pick up pocket wifi').", + ), + "type" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Event type: 'ACTIVITY', 'TRANSPORTATION', 'ACCOMMODATION', 'DINING', or 'CULTURE'.", + ), + "timestamp" to + FunctionSchema( + type = FunctionType.INTEGER, + description = + "Optional epoch timestamp in milliseconds for when this event/reminder occurs.", + ), + "location" to + FunctionSchema( + type = FunctionType.STRING, + description = "Location of the event (e.g. 'Haneda Airport', 'Tokyo, Japan').", + ), + "description" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional additional notes or description.", + ), + ), + required = listOf("title"), + ) + + suspend fun execute(args: Map): Map { + val title = args["title"]?.toString() ?: "Reminder" + val typeStr = args["type"]?.toString() ?: "ACTIVITY" + val eventType = + try { + EventType.valueOf(typeStr.uppercase()) + } catch (_: IllegalArgumentException) { + EventType.ACTIVITY + } + val rawTimestamp = (args["timestamp"] as? Number)?.toLong() ?: 0L + val tripId = args["tripId"]?.toString()?.takeIf { it.isNotBlank() } + val activeTripId = activeTripIdProvider()?.takeIf { it.isNotBlank() } + val tripHint = + listOfNotNull( + args["trip"]?.toString(), + args["destination"]?.toString(), + args["location"]?.toString(), + args["tripTitle"]?.toString(), + ) + .firstOrNull { it.isNotBlank() } + + val allTrips = tripDao.getAllTrips().first() + var trip = + if (!tripId.isNullOrBlank()) { + allTrips.find { it.id == tripId } + } else if (!tripHint.isNullOrBlank()) { + allTrips.find { + it.id == tripHint || + it.location.contains(tripHint, ignoreCase = true) || + it.title.contains(tripHint, ignoreCase = true) + } + } else { + allTrips.find { + title.contains(it.location.substringBefore(','), ignoreCase = true) || + title.contains(it.title, ignoreCase = true) + } ?: activeTripId?.let { activeId -> allTrips.find { it.id == activeId } } + } + + if (trip == null) { + val fallbackTrip = + DummyData.trips.find { dt -> + (!tripId.isNullOrBlank() && dt.id == tripId) || + (!tripHint.isNullOrBlank() && + (dt.id == tripHint || + dt.location.contains(tripHint, ignoreCase = true) || + dt.title.contains(tripHint, ignoreCase = true))) || + (title.contains(dt.location.substringBefore(','), ignoreCase = true) || + title.contains(dt.title, ignoreCase = true)) || + (!activeTripId.isNullOrBlank() && dt.id == activeTripId) + } + if (fallbackTrip != null) { + tripDao.insertTrips(listOf(fallbackTrip)) + trip = fallbackTrip + } else { + trip = allTrips.firstOrNull() + } + } + val targetTripId = trip?.id ?: tripId ?: activeTripId ?: "2026-4" + val defaultLoc = trip?.location ?: "Tokyo, Japan" + val location = args["location"]?.toString()?.takeIf { it.isNotBlank() } ?: defaultLoc + val description = args["description"]?.toString() ?: "Added via Travel Assistant" + + val timestamp = + if (rawTimestamp > 0L) { + rawTimestamp + } else if (trip != null && trip.startDate > 0L) { + trip.startDate + } else { + System.currentTimeMillis() + } + + val existingEvents = eventDao.getEventsForTrip(targetTripId).first() + val duplicateEvent = existingEvents.firstOrNull { existing -> + existing.title.equals(title, ignoreCase = true) + } + if (duplicateEvent != null) { + return mapOf( + "status" to "SUCCESS", + "done" to true, + "eventId" to duplicateEvent.id, + "tripId" to targetTripId, + "tripTitle" to (trip?.title ?: "Trip"), + "title" to title, + "type" to duplicateEvent.type.name, + "location" to duplicateEvent.location, + "message" to + "Added reminder '$title' to ${trip?.title ?: "trip"} itinerary. Task complete — do not call addItineraryEvent again.", + ) + } + + val event = + TimelineEvent( + id = UUID.randomUUID().toString(), + tripId = targetTripId, + type = eventType, + timestamp = timestamp, + title = title, + location = location, + description = description, + ) + eventDao.insertEvent(event) + + // Also create a linked VoiceNoteEntity so reminders spoken on VoiceNotesScreen immediately + // appear + val matchedExisting = + existingEvents.find { ev -> + val combined = "$title $location $description".lowercase() + (combined.contains("airport") || combined.contains("flight") || combined.contains("sim")) && + ev.type == EventType.TRANSPORTATION || + combined.contains("museum") && ev.type == EventType.CULTURE || + combined.contains("hotel") && ev.type == EventType.ACCOMMODATION + } ?: event + val escapedExtract = title.replace("\"", "\\\"") + val matchingJson = """[{"eventId":"${matchedExisting.id}","extract":"$escapedExtract"}]""" + val existingNotes = eventDao.getVoiceNotesForTrip(targetTripId).first() + if (existingNotes.none { it.title.equals(title, ignoreCase = true) }) { + eventDao.insertVoiceNote( + VoiceNoteEntity( + id = UUID.randomUUID().toString(), + tripId = targetTripId, + title = title, + transcription = + if (description != "Added via Travel Assistant") "$title — $description" else title, + timestamp = System.currentTimeMillis(), + matchingEventsJson = matchingJson, + ) + ) + } + + return mapOf( + "status" to "SUCCESS", + "done" to true, + "eventId" to event.id, + "tripId" to targetTripId, + "tripTitle" to (trip?.title ?: "Trip"), + "title" to title, + "type" to eventType.name, + "location" to location, + "matchedEventTitle" to matchedExisting.title, + "message" to + "Added reminder '$title' to ${trip?.title ?: "trip"} itinerary. Task complete — do not call addItineraryEvent again.", + ) + } +} + +/** Tool to add a voice note or reminder to a trip and link it to matching itinerary events. */ +class AddVoiceNoteTool( + private val eventDao: EventDao, + private val tripDao: TripDao, + private val activeTripIdProvider: () -> String? = { null }, +) { + val name = "addVoiceNote" + val description = + "Adds a voice note or reminder to a trip and links it to matching itinerary events (e.g. airport flight or museum visit). Call this when the user asks to save a voice note, visitor tips, or reminder (e.g., 'Please remind me to buy a sim card when I get to the airport' or 'Save the museum visitor tips as a voice note')." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "The unique identifier of the trip (e.g. '2026-4' for Tokyo).", + ), + "title" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Short title of the voice note or reminder (e.g. 'Buy a SIM card at the airport', 'Museum Visitor Tips').", + ), + "transcription" to + FunctionSchema( + type = FunctionType.STRING, + description = "The full text content of the note, reminder, or visitor tips.", + ), + "eventId" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Optional itinerary eventId to link this voice note to (e.g. 'tok_1', 'tok_3').", + ), + ), + required = listOf("title", "transcription"), + ) + + suspend fun execute(args: Map): Map { + val title = args["title"]?.toString()?.takeIf { it.isNotBlank() } ?: "Voice Note" + val transcription = args["transcription"]?.toString()?.takeIf { it.isNotBlank() } ?: title + val explicitEventId = args["eventId"]?.toString()?.takeIf { it.isNotBlank() } + val rawTripId = args["tripId"]?.toString()?.takeIf { it.isNotBlank() } + val activeTripId = activeTripIdProvider()?.takeIf { it.isNotBlank() } + + val allTrips = tripDao.getAllTrips().first() + val trip = + if (!rawTripId.isNullOrBlank()) { + allTrips.find { it.id == rawTripId } ?: DummyData.trips.find { it.id == rawTripId } + } else { + allTrips.find { + val text = "$title $transcription" + text.contains(it.location.substringBefore(','), ignoreCase = true) || + text.contains(it.title, ignoreCase = true) + } + ?: activeTripId?.let { activeId -> + allTrips.find { it.id == activeId } ?: DummyData.trips.find { it.id == activeId } + } + ?: allTrips.firstOrNull() + } + val targetTripId = trip?.id ?: rawTripId ?: activeTripId ?: "2026-4" + + val existingNotes = eventDao.getVoiceNotesForTrip(targetTripId).first() + val now = System.currentTimeMillis() + val duplicate = existingNotes.firstOrNull { existing -> + existing.title.equals(title, ignoreCase = true) && (now - existing.timestamp) < 15_000L + } + if (duplicate != null) { + return mapOf( + "status" to "SUCCESS", + "done" to true, + "voiceNoteId" to duplicate.id, + "tripId" to targetTripId, + "tripTitle" to (trip?.title ?: "Trip"), + "title" to duplicate.title, + "transcription" to duplicate.transcription, + "message" to + "Saved voice note '$title' to ${trip?.title ?: "trip"}. Task complete — do not call addVoiceNote again.", + ) + } + + val dbEvents = eventDao.getEventsForTrip(targetTripId).first() + val events = + if (dbEvents.isNotEmpty()) { + dbEvents + } else { + val seeded = DummyData.events.filter { it.tripId == targetTripId } + if (seeded.isNotEmpty()) { + eventDao.insertEvents(seeded) + } + seeded + } + + val matchedEvent = + if (!explicitEventId.isNullOrBlank()) { + events.find { it.id == explicitEventId } + } else { + val combined = "$title $transcription".lowercase() + events.find { ev -> + (combined.contains("airport") || + combined.contains("flight") || + combined.contains("sim") || + combined.contains("baggage")) && ev.type == EventType.TRANSPORTATION || + (combined.contains("museum") || + combined.contains("barefoot") || + combined.contains("knees")) && ev.type == EventType.CULTURE || + combined.contains("hotel") && ev.type == EventType.ACCOMMODATION || + ev.title.lowercase().split(" ").any { word -> + word.length > 3 && combined.contains(word) + } + } + } + + val matchingJson = + if (matchedEvent != null) { + val escapedExtract = transcription.replace("\"", "\\\"") + """[{"eventId":"${matchedEvent.id}","extract":"$escapedExtract"}]""" + } else { + "[]" + } + + if (matchedEvent != null) { + val updatedNotes = (matchedEvent.audioNotes + transcription).distinct() + eventDao.insertEvent(matchedEvent.copy(audioNotes = updatedNotes)) + } + + val note = + VoiceNoteEntity( + id = UUID.randomUUID().toString(), + tripId = targetTripId, + title = title, + transcription = transcription, + timestamp = now, + matchingEventsJson = matchingJson, + ) + eventDao.insertVoiceNote(note) + + return mapOf( + "status" to "SUCCESS", + "done" to true, + "voiceNoteId" to note.id, + "tripId" to targetTripId, + "tripTitle" to (trip?.title ?: "Trip"), + "title" to title, + "transcription" to transcription, + "matchedEventId" to (matchedEvent?.id ?: ""), + "matchedEventTitle" to (matchedEvent?.title ?: ""), + "message" to + "Saved voice note '$title' to ${trip?.title ?: "trip"}. Task complete — do not call addVoiceNote again.", + ) + } +} + +/** Tool to search for trips by destination, title, or ID. */ +class SearchTripTool(private val tripDao: TripDao) { + val name = "searchTrip" + val description = + "Searches for trips matching a title, destination location, or ID. Call this first when the user refers to a trip by city or name and you need its tripId." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "query" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional search keyword matching trip title or destination location.", + ), + "id" to FunctionSchema(type = FunctionType.STRING, description = "Optional trip ID."), + "title" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional trip title keyword (e.g. 'Tokyo', 'Paris', 'Roman').", + ), + "location" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional destination city or country (e.g. 'Tokyo', 'Paris', 'Rome').", + ), + ), + ) + + suspend fun execute(args: Map): Map { + val query = args["query"]?.toString()?.trim() + val id = args["id"]?.toString()?.trim() + val title = args["title"]?.toString()?.trim() + val location = args["location"]?.toString()?.trim() + + val dbTrips = tripDao.getAllTrips().first() + val allTrips = if (dbTrips.isNotEmpty()) dbTrips else DummyData.trips + val matched = allTrips.filter { trip -> + (query.isNullOrEmpty() || + trip.title.contains(query, ignoreCase = true) || + trip.location.contains(query, ignoreCase = true) || + trip.id.equals(query, ignoreCase = true)) && + (id.isNullOrEmpty() || trip.id.equals(id, ignoreCase = true)) && + (title.isNullOrEmpty() || + trip.title.contains(title, ignoreCase = true) || + trip.location.contains(title, ignoreCase = true)) && + (location.isNullOrEmpty() || + trip.location.contains(location, ignoreCase = true) || + trip.title.contains(location, ignoreCase = true)) + } + + return mapOf( + "status" to "SUCCESS", + "count" to matched.size, + "trips" to + matched.map { + mapOf( + "id" to it.id, + "tripId" to it.id, + "title" to it.title, + "location" to it.location, + "startDate" to it.startDate, + "endDate" to it.endDate, + ) + }, + ) + } +} + +/** Tool to retrieve expenses for a trip from the Room database. */ +class GetExpensesTool( + private val expenseDao: ExpenseDao, + private val tripDao: TripDao, + private val activeTripIdProvider: () -> String? = { null }, +) { + val name = "getExpenses" + val description = "Retrieves all logged expenses for a specific trip." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "The unique identifier of the trip (e.g. '2026-4').", + ) + ), + ) + + private fun convertToUsd(amount: Double, currency: String): Double { + val rate = + when (currency.uppercase()) { + "USD" -> 1.0 + "EUR" -> 1.08 + "GBP" -> 1.27 + "JPY" -> 0.0067 + else -> 1.0 + } + return kotlin.math.round(amount * rate * 100.0) / 100.0 + } + + suspend fun execute(args: Map): Map { + val tripId = + args["tripId"]?.toString()?.takeIf { it.isNotBlank() } + ?: activeTripIdProvider()?.takeIf { it.isNotBlank() } + ?: tripDao.getAllTrips().first().firstOrNull()?.id + ?: "2026-4" + val expenses = expenseDao.getExpensesForTrip(tripId).first() + val trip = tripDao.getTripById(tripId).first() + val tripBudget = trip?.budget ?: 2000.0 + val tripCurrency = trip?.currency ?: "USD" + val budgetUsd = convertToUsd(tripBudget, tripCurrency) + val totalSpentUsd = + kotlin.math.round( + expenses.sumOf { convertToUsd(it.amount, it.currency) } * 100.0 + ) / 100.0 + val remainingBudgetUsd = kotlin.math.round((budgetUsd - totalSpentUsd) * 100.0) / 100.0 + val remainingBudgetJpy = kotlin.math.round(remainingBudgetUsd * 150.0) + return mapOf( + "tripId" to tripId, + "count" to expenses.size, + "totalSpentUsd" to totalSpentUsd, + "totalBudgetUsd" to budgetUsd, + "remainingBudgetUsd" to remainingBudgetUsd, + "remainingBudgetJpy" to remainingBudgetJpy, + "expenses" to + expenses.map { + mapOf( + "id" to it.id, + "title" to it.title, + "amount" to it.amount, + "currency" to it.currency, + "category" to it.category, + ) + }, + "message" to + "Found ${expenses.size} expenses totaling $$totalSpentUsd USD out of $$budgetUsd budget ($$remainingBudgetUsd USD / ¥$remainingBudgetJpy JPY remaining).", + ) + } +} + +/** Tool to retrieve the itinerary events for a trip from the Room database. */ +class GetItineraryTool( + private val eventDao: EventDao, + private val tripDao: TripDao, + private val activeTripIdProvider: () -> String? = { null }, +) { + val name = "getItinerary" + val description = + "Retrieves the list of itinerary events (with their eventId, title, type, and location) for a specific trip. Note: getItinerary returns event summaries only; call getEventDetails(eventId) with a specific event's ID to retrieve ticket prices, hotel rates, baggage allowance, or visitor tips." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "The unique identifier of the trip (e.g. '2026-4' for Tokyo).", + ) + ), + ) + + suspend fun execute(args: Map): Map { + val tripId = + args["tripId"]?.toString()?.takeIf { it.isNotBlank() } + ?: activeTripIdProvider()?.takeIf { it.isNotBlank() } + ?: tripDao.getAllTrips().first().firstOrNull()?.id + ?: "2026-4" + val dbEvents = eventDao.getEventsForTrip(tripId).first() + val events = + if (dbEvents.isNotEmpty()) { + dbEvents + } else { + val fallback = DummyData.events.filter { it.tripId == tripId } + if (fallback.isNotEmpty()) { + eventDao.insertEvents(fallback) + } + fallback + } + return mapOf( + "tripId" to tripId, + "count" to events.size, + "events" to + events.map { + mapOf( + "id" to it.id, + "eventId" to it.id, + "title" to it.title, + "type" to it.type.name, + "location" to it.location, + "description" to (it.description ?: ""), + ) + }, + "message" to "Found ${events.size} events in itinerary for trip ($tripId).", + ) + } +} + +/** + * Tool to retrieve rich subtype details (museum ticket price, hotel nightly rate, flight baggage + * allowance, visitor tips) for a specific itinerary event by its `eventId`. + */ +class GetEventDetailsTool( + private val eventDao: EventDao, + private val tourDetailDao: TourDetailDao? = null, +) { + val name = "getEventDetails" + val description = + "Retrieves rich subtype details for a specific itinerary event by its eventId, including museum admission ticket price and opening hours, hotel nightly rate and confirmation number, flight terminal/gate/baggage allowance, dining reservation info, and visitor tips. Call this after getItinerary(tripId) when you need the price, rate, baggage allowance, or visitor tips of a specific event." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "eventId" to + FunctionSchema( + type = FunctionType.STRING, + description = + "The unique identifier of the itinerary event (e.g. 'tok_3', 'tok_2', 'tok_1', 'par_3') returned by getItinerary.", + ) + ), + required = listOf("eventId"), + ) + + suspend fun execute(args: Map): Map { + val eventId = args["eventId"]?.toString()?.trim() ?: "" + if (eventId.isEmpty()) { + return mapOf("status" to "ERROR", "message" to "Missing eventId parameter.") + } + + val event = eventDao.getEventById(eventId).first() ?: DummyData.events.find { it.id == eventId } + val flight = + eventDao.getFlightDetail(eventId).first() + ?: DummyData.flightDetails.find { it.eventId == eventId } + val hotel = + eventDao.getHotelDetail(eventId).first() + ?: DummyData.hotelDetails.find { it.eventId == eventId } + val museum = + eventDao.getMuseumDetail(eventId).first() + ?: DummyData.museumDetails.find { it.eventId == eventId } + val dining = + eventDao.getDiningDetail(eventId).first() + ?: DummyData.diningDetails.find { it.eventId == eventId } + val tour = + tourDetailDao?.getTourDetailByEventId(eventId)?.first() + ?: DummyData.tourDetails.find { it.eventId == eventId } + + val title = + event?.title + ?: tour?.title + ?: hotel?.name + ?: dining?.restaurantName + ?: flight?.let { "${it.airline} ${it.flightNum}" } + ?: eventId + val type = event?.type?.name ?: tour?.type ?: "ACTIVITY" + val location = + event?.location + ?: museum?.address + ?: hotel?.address + ?: dining?.address + ?: tour?.locationAddress + ?: "" + + val result = + mutableMapOf( + "status" to "SUCCESS", + "eventId" to eventId, + "title" to title, + "type" to type, + "location" to location, + ) + + val summaryParts = mutableListOf() + if (museum != null) { + result["admissionPrice"] = museum.admissionPrice + result["openingHours"] = museum.openingHours + result["address"] = museum.address + result["ticketWebsite"] = museum.ticketWebsite + result["description"] = museum.description + if (museum.admissionPrice.isNotBlank()) { + summaryParts.add("Admission ticket: ${museum.admissionPrice}") + } + if (museum.openingHours.isNotBlank()) { + summaryParts.add("Hours: ${museum.openingHours}") + } + } + if (hotel != null) { + result["hotelName"] = hotel.name + result["pricePerNight"] = hotel.pricePerNight ?: "" + result["confirmationNumber"] = hotel.confNumber ?: "" + result["address"] = hotel.address + if (!hotel.pricePerNight.isNullOrBlank()) { + summaryParts.add("Rate: ${hotel.pricePerNight}") + } + if (!hotel.confNumber.isNullOrBlank()) { + summaryParts.add("Confirmation: ${hotel.confNumber}") + } + } + if (flight != null) { + result["airline"] = flight.airline + result["flightNumber"] = flight.flightNum + result["origin"] = flight.origin + result["destination"] = flight.destination + result["departureTerminal"] = flight.departureTerminal ?: "" + result["arrivalTerminal"] = flight.arrivalTerminal ?: "" + result["gate"] = flight.gate ?: "" + result["seat"] = flight.seat ?: "" + result["baggageAllowance"] = flight.baggageAllowance ?: "" + summaryParts.add( + "${flight.airline} ${flight.flightNum} (${flight.origin} -> ${flight.destination})" + ) + if (!flight.baggageAllowance.isNullOrBlank()) { + summaryParts.add("Baggage: ${flight.baggageAllowance}") + } + } + if (dining != null) { + result["restaurantName"] = dining.restaurantName + result["partySize"] = dining.partySize + result["priceRange"] = dining.priceRange ?: "" + result["address"] = dining.address + } + if (tour != null) { + result["tips"] = tour.notes + result["meetingPoint"] = tour.meetingPoint + result["about"] = tour.about + if (tour.notes.isNotEmpty()) { + summaryParts.add("Visitor tips: ${tour.notes.joinToString("; ")}") + } + } + + val summary = + if (summaryParts.isNotEmpty()) { + "$title — ${summaryParts.joinToString(" • ")}" + } else { + "$title ($location)" + } + result["summary"] = summary + result["message"] = summary + return result + } +} + +/** Aggregates Room-backed travel tools for injection into the Agent. */ +class TravelDaoTools( + val getFlightItinerary: GetFlightItineraryTool, + val addExpense: AddExpenseTool? = null, + val addItineraryEvent: AddItineraryEventTool? = null, + val addVoiceNote: AddVoiceNoteTool? = null, + val searchTrip: SearchTripTool? = null, + val getExpenses: GetExpensesTool? = null, + val getItinerary: GetItineraryTool? = null, + val getEventDetails: GetEventDetailsTool? = null, +) { + constructor( + eventDao: EventDao, + tripDao: TripDao, + expenseDao: ExpenseDao? = null, + tourDetailDao: TourDetailDao? = null, + activeTripIdProvider: () -> String? = { null }, + ) : this( + getFlightItinerary = GetFlightItineraryTool(eventDao, tripDao, activeTripIdProvider), + addExpense = expenseDao?.let { AddExpenseTool(it, tripDao, activeTripIdProvider) }, + addItineraryEvent = AddItineraryEventTool(eventDao, tripDao, activeTripIdProvider), + addVoiceNote = AddVoiceNoteTool(eventDao, tripDao, activeTripIdProvider), + searchTrip = SearchTripTool(tripDao), + getExpenses = expenseDao?.let { GetExpensesTool(it, tripDao, activeTripIdProvider) }, + getItinerary = GetItineraryTool(eventDao, tripDao, activeTripIdProvider), + getEventDetails = GetEventDetailsTool(eventDao, tourDetailDao), + ) + + fun asList(): List = buildList { + add(getFlightItinerary) + addExpense?.let { add(it) } + addItineraryEvent?.let { add(it) } + addVoiceNote?.let { add(it) } + searchTrip?.let { add(it) } + getExpenses?.let { add(it) } + getItinerary?.let { add(it) } + getEventDetails?.let { add(it) } + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/ui/TravelAssistantCard.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/ui/TravelAssistantCard.kt new file mode 100644 index 00000000..c39f04cf --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/ui/TravelAssistantCard.kt @@ -0,0 +1,1562 @@ +/* + * 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.assistant.ui + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +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.Send +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ConfirmationNumber +import androidx.compose.material.icons.filled.DocumentScanner +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.Flight +import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicNone +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.Payments +import androidx.compose.material.icons.filled.ReceiptLong +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.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.dropShadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.feature.assistant.TravelAssistantViewModel +import com.example.jetpacker.feature.assistant.model.ActionStepCategory +import com.example.jetpacker.feature.assistant.model.ActionStepStatus +import com.example.jetpacker.feature.assistant.model.AssistantOverlayState +import com.example.jetpacker.feature.assistant.model.ConversationTurnUi +import com.example.jetpacker.feature.assistant.model.HighlightedEntity +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.ExpenseHighlight +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.FlightDetailHighlight +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.ItineraryEventHighlight +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.MultiActionHighlight +import com.example.jetpacker.feature.assistant.model.HighlightedEntity.VoiceNoteHighlight +import com.example.jetpacker.feature.assistant.model.ToolExecutionRecord +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.Error +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.ExecutingTool +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.Idle +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.Success +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.Thinking +import com.example.jetpacker.feature.assistant.model.UserActionStep +import java.time.Instant +import java.time.ZoneId +import kotlinx.coroutines.delay + +/** + * Modern Material 3 on-device Travel & Document Assistant card. + * + * Provides prompt suggestions, interactive chat input, real-time tool execution chips, and + * structured highlight cards for passports, visas, and flight itineraries. + */ +@Composable +fun TravelAssistantCard( + viewModel: TravelAssistantViewModel = hiltViewModel(), + modifier: Modifier = Modifier, + onManageDocumentsClick: () -> Unit = {}, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var promptInput by remember { mutableStateOf("") } + + val voiceState by viewModel.voiceTranscript.collectAsStateWithLifecycle() + + val audioPermissionLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { + isGranted -> + viewModel.setVoicePermissionGranted(isGranted) + if (isGranted) { + viewModel.toggleVoiceInput(onPermissionRequired = {}) + } + } + + LaunchedEffect(voiceState.partialTranscription, voiceState.transcription) { + if (voiceState.isListening) { + val liveText = voiceState.partialTranscription.ifBlank { voiceState.transcription } + if (liveText.isNotBlank()) { + promptInput = liveText + } + } + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + // Header: Assistant title, Nano badge, Documents button, and Reset button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = "Travel Assistant", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "Trip Assistant", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = "On-Device Gemini Nano", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { viewModel.clearChat() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reset Assistant", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Dynamic State Content + val conversationTurns by viewModel.conversationTurns.collectAsStateWithLifecycle() + if (conversationTurns.size > 1) { + Column( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + conversationTurns.dropLast(1).forEach { turn -> ConversationTurnItem(turn = turn) } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + } + + when (val state = uiState) { + is Idle -> { + Text( + text = "Ask about flight schedules, expenses, or itinerary events.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + is Thinking -> { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 8.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = state.stepSummary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + is ExecutingTool -> { + AgentEventStepper(tools = state.executedTools, activeToolName = state.toolName) + } + is Success -> { + Column(modifier = Modifier.fillMaxWidth()) { + if (state.toolExecutions.isNotEmpty()) { + AgentEventStepper(tools = state.toolExecutions, activeToolName = null) + Spacer(modifier = Modifier.height(10.dp)) + } + + HighlightedEntitySection(highlight = state.highlightedEntity) + + if (state.answerText.isNotBlank()) { + Text( + text = state.answerText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + is Error -> { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 8.dp), + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = state.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Input field, voice mic button, and send button + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = promptInput, + onValueChange = { promptInput = it }, + placeholder = { + Text( + if (voiceState.isListening) "Listening on device..." + else "Ask your travel assistant..." + ) + }, + modifier = Modifier.weight(1f), + singleLine = true, + shape = MaterialTheme.shapes.medium, + ) + + Spacer(modifier = Modifier.width(4.dp)) + + // On-device Voice Input Mic Button + IconButton( + onClick = { + viewModel.toggleVoiceInput( + onPermissionRequired = { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + ) + }, + enabled = uiState !is Thinking && uiState !is ExecutingTool, + ) { + Icon( + imageVector = if (voiceState.isListening) Icons.Default.Mic else Icons.Default.MicNone, + contentDescription = + if (voiceState.isListening) "Stop voice recording" else "Record voice prompt", + tint = + if (voiceState.isListening) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(24.dp), + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + IconButton( + onClick = { + val textToSend = promptInput + if (textToSend.isNotBlank()) { + promptInput = "" + viewModel.sendPrompt(textToSend) + } + }, + enabled = promptInput.isNotBlank() && uiState !is Thinking && uiState !is ExecutingTool, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send prompt", + tint = + if (promptInput.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } + + if (voiceState.isListening) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "🎙️ Listening on-device... Speak or tap mic to submit", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +@Composable +private fun HighlightedEntitySection(highlight: HighlightedEntity?) { + when (highlight) { + is FlightDetailHighlight -> { + FlightHighlightCard(highlight = highlight) + Spacer(modifier = Modifier.height(8.dp)) + } + is MultiActionHighlight -> { + MultiActionHighlightCard(highlight = highlight) + Spacer(modifier = Modifier.height(8.dp)) + } + is ExpenseHighlight -> { + ExpenseHighlightCard(highlight = highlight) + Spacer(modifier = Modifier.height(8.dp)) + } + is ItineraryEventHighlight -> { + ItineraryEventHighlightCard(highlight = highlight) + Spacer(modifier = Modifier.height(8.dp)) + } + is VoiceNoteHighlight -> { + VoiceNoteHighlightCard(highlight = highlight) + Spacer(modifier = Modifier.height(8.dp)) + } + null -> Unit + } +} + +/** Structured highlight card displaying flight itinerary details. */ +@Composable +private fun FlightHighlightCard(highlight: FlightDetailHighlight, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Flight, + contentDescription = "Flight", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "${highlight.airline} ${highlight.flightNumber}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + } + + if (!highlight.status.isNullOrBlank()) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = highlight.status, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = "${highlight.origin} ➔ ${highlight.destination}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + + val detailsText = + listOfNotNull( + highlight.terminal?.takeIf { it.isNotBlank() }?.let { "Terminal $it" }, + highlight.gate?.takeIf { it.isNotBlank() }?.let { "Gate $it" }, + ) + .joinToString(" • ") + + if (detailsText.isNotBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = detailsText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** Structured highlight card displaying multi-action voice execution (e.g. Expense + Reminder). */ +@Composable +private fun MultiActionHighlightCard( + highlight: MultiActionHighlight, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Actions Completed", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + } + if (!highlight.tripTitle.isNullOrBlank()) { + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = highlight.tripTitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } + + if (highlight.expenseTitle != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.ReceiptLong, + contentDescription = "Expense", + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = highlight.expenseTitle, style = MaterialTheme.typography.bodyMedium) + } + if (highlight.expenseAmount != null) { + Text( + text = "${highlight.expenseCurrency ?: "EUR"} ${highlight.expenseAmount}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + + if (highlight.eventTitle != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Event, + contentDescription = "Reminder", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = highlight.eventTitle, style = MaterialTheme.typography.bodyMedium) + } + if (!highlight.eventLocation.isNullOrBlank()) { + Text( + text = highlight.eventLocation, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +/** Structured highlight card displaying a recorded expense. */ +@Composable +private fun ExpenseHighlightCard(highlight: ExpenseHighlight, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.ReceiptLong, + contentDescription = "Expense", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = highlight.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Category: ${highlight.category}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text( + text = "${highlight.currency} ${highlight.amount}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } +} + +/** Structured highlight card displaying an itinerary event or reminder. */ +@Composable +private fun ItineraryEventHighlightCard( + highlight: ItineraryEventHighlight, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Event, + contentDescription = "Event", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = highlight.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = highlight.location, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = highlight.type, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } +} + +/** Structured highlight card displaying a saved voice note / reminder. */ +@Composable +private fun VoiceNoteHighlightCard(highlight: VoiceNoteHighlight, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = Icons.Default.NotificationsActive, + contentDescription = "Voice Note", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = highlight.transcription, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + if (!highlight.matchedEventTitle.isNullOrBlank()) { + Text( + text = "Matched to ${highlight.matchedEventTitle}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = "Reminder", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } +} + +/** Consumer-friendly action stepper without raw function names, JSON, or debug metrics. */ +@Composable +private fun AgentEventStepper( + tools: List, + activeToolName: String? = null, + modifier: Modifier = Modifier, +) { + if (tools.isEmpty() && activeToolName == null) return + + Column( + modifier = modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + tools.forEach { tool -> + val isCurrent = (activeToolName == tool.name) && (tool.response == null) + val (label, icon) = getHumanizedToolInfo(tool.name, isCompleted = !isCurrent) + + Surface( + shape = RoundedCornerShape(10.dp), + color = + if (isCurrent) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + border = if (isCurrent) BorderStroke(1.dp, MaterialTheme.colorScheme.primary) else null, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = icon, + contentDescription = null, + tint = + if (isCurrent) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + if (isCurrent) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Completed", + tint = Color(0xFF2E7D32), + modifier = Modifier.size(14.dp), + ) + } + } + } + } + } +} + +private fun getHumanizedToolInfo( + toolName: String, + isCompleted: Boolean, +): Pair { + val cleanName = + toolName.removePrefix("BaseJetPackerAppFunctionService_").substringAfterLast('.').substringAfterLast('#') + return when (cleanName) { + "get_flight_itinerary" -> + (if (isCompleted) "Checked flight schedule" else "Checking flight schedule…") to + Icons.Default.Flight + "add_expense", + "addExpense" -> + (if (isCompleted) "Added to your expenses" else "Adding to your expenses…") to + Icons.Default.Payments + "get_expenses" -> + (if (isCompleted) "Checked trip expenses & budget" else "Checking trip expenses…") to + Icons.Default.AttachMoney + "addVoiceNote" -> + (if (isCompleted) "Saved reminder to your trip" else "Saving reminder to your trip…") to + Icons.Default.NotificationsActive + "addItineraryEvent" -> + (if (isCompleted) "Saved reminder to your trip" else "Saving reminder to your trip…") to + Icons.Default.Event + "getItinerary" -> + (if (isCompleted) "Checked your trip schedule" else "Checking your trip schedule…") to + Icons.Default.Event + "getEventDetails" -> + (if (isCompleted) "Checked activity details" else "Looking up activity details…") to + Icons.Default.ConfirmationNumber + "searchTrip" -> + (if (isCompleted) "Found your trip" else "Looking up your trip…") to Icons.Default.Search + else -> + (if (isCompleted) "Updated your trip" else "Updating your trip…") to Icons.Default.AutoAwesome + } +} + +/** + * Ambient, morphing Trip Assistant overlay anchored above the bottom bar on Expenses, Voice Notes, + * Documents, and Itinerary screens. + * + * Supports: + * - Demo 1: Single tool call -> Direct UI mutation with consumer-friendly action banner + * - Demo 2: Sequential dependent tool calls -> Direct UI mutation with step-by-step progress rows + * - Demo 3: Multi-turn conversation with session memory + */ +@Composable +fun AmbientTripAssistantSheet( + viewModel: TravelAssistantViewModel, + modifier: Modifier = Modifier, + activeScreenHint: String = "Ask or say a command…", +) { + val overlayState by viewModel.overlayState.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val voiceState by viewModel.voiceTranscript.collectAsStateWithLifecycle() + + val audioPermissionLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { + isGranted -> + viewModel.setVoicePermissionGranted(isGranted) + if (isGranted) { + viewModel.toggleVoiceInput(onPermissionRequired = {}) + } + } + + // The parent slides this sheet out when the overlay collapses. Keep rendering the last + // non-Collapsed state during that exit so the content doesn't vanish mid-animation. + var displayOverlayState by remember { mutableStateOf(overlayState) } + if (overlayState !is AssistantOverlayState.Collapsed) { + displayOverlayState = overlayState + } + + // Auto-dismiss ActionCompleted after 4s (Single-Step Demo 1) or 5.5s (Multi-Step Demo 2) + LaunchedEffect(overlayState) { + val completedState = overlayState as? AssistantOverlayState.ActionCompleted + if (completedState != null) { + val holdMs = if (completedState.steps.size > 1) 5_500L else 4_000L + delay(holdMs) + viewModel.dismissOverlay() + } + } + + val sheetShape = RoundedCornerShape(24.dp) + Surface( + modifier = + modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp).dropShadow( + shape = sheetShape + ) { + radius = 6.dp.toPx() + offset = Offset(0f, 3.dp.toPx()) + spread = 1.dp.toPx() + color = Color.Black.copy(alpha = 0.16f) + }, + shape = sheetShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 6.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Box( + modifier = + Modifier.fillMaxWidth() + .animateContentSize( + animationSpec = + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + ) + ) { + AnimatedContent( + targetState = displayOverlayState, + // Only crossfade when the *kind* of state changes; payload updates within the same state + // (e.g. live transcript text) should update in place without a fade. + contentKey = { it::class }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "AmbientAssistantOverlayTransition", + ) { state -> + when (state) { + is AssistantOverlayState.Collapsed -> { + CollapsedAssistantBar( + hint = activeScreenHint, + onMicClick = { + viewModel.startVoiceAssistant( + onPermissionRequired = { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + ) + }, + onExpandClick = { + viewModel.startVoiceAssistant( + onPermissionRequired = { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + ) + }, + ) + } + + is AssistantOverlayState.Listening -> { + val liveTranscript = + voiceState.partialTranscription + .ifBlank { voiceState.transcription } + .ifBlank { state.liveTranscript } + ListeningAssistantBar( + partialTranscript = liveTranscript, + onStopClick = { viewModel.toggleVoiceInput(onPermissionRequired = {}) }, + onCancelClick = { viewModel.cancelVoiceListening() }, + ) + } + + is AssistantOverlayState.PerformingActions -> { + ActionProgressFeed( + userPrompt = state.userPrompt, + steps = state.steps, + isCompleted = false, + onDismiss = { viewModel.dismissOverlay() }, + ) + } + + is AssistantOverlayState.ActionCompleted -> { + ActionProgressFeed( + userPrompt = state.userPrompt, + steps = state.steps, + isCompleted = true, + textResponse = state.textResponse, + onDismiss = { viewModel.dismissOverlay() }, + ) + } + + is AssistantOverlayState.Conversational -> { + val liveTranscript = + voiceState.partialTranscription + .ifBlank { voiceState.transcription } + .ifBlank { state.liveTranscript } + ConversationalAssistantSheet( + turns = state.turns, + activeSteps = state.activeSteps, + isListening = state.isListening || voiceState.isListening, + liveTranscript = liveTranscript, + isBusy = uiState is Thinking || uiState is ExecutingTool, + busySummary = + when (val s = uiState) { + is Thinking -> s.stepSummary + is ExecutingTool -> getHumanizedToolInfo(s.toolName, isCompleted = false).first + else -> null + }, + onMicClick = { + viewModel.toggleVoiceInput( + onPermissionRequired = { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + ) + }, + onCancelListenClick = { viewModel.cancelVoiceListening() }, + onResetClick = { viewModel.clearChat() }, + onCollapse = { viewModel.dismissOverlay() }, + ) + } + } + } + } + } +} + +@Composable +private fun CollapsedAssistantBar(hint: String, onMicClick: () -> Unit, onExpandClick: () -> Unit) { + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onExpandClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "Trip Assistant", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + FilledIconButton( + onClick = onMicClick, + shape = CircleShape, + colors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary + ), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = "Speak to Trip Assistant", + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } +} + +@Composable +private fun ListeningAssistantBar( + partialTranscript: String, + onStopClick: () -> Unit, + onCancelClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.GraphicEq, + contentDescription = "Listening", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "Listening…", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + Text( + text = partialTranscript.ifBlank { "Speak now…" }, + style = MaterialTheme.typography.bodyMedium, + color = + if (partialTranscript.isBlank()) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface, + fontStyle = if (partialTranscript.isBlank()) FontStyle.Italic else FontStyle.Normal, + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + FilledIconButton( + onClick = onStopClick, + shape = CircleShape, + colors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary + ), + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = "Done speaking", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + } + Spacer(modifier = Modifier.width(4.dp)) + IconButton(onClick = onCancelClick, modifier = Modifier.size(32.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + +/** + * Consumer-friendly action banner for Demos 1 (Single-Step) and 2 (Multi-Step Sequential). + * + * Shows plain-English steps without any function names or JSON arguments. + */ +@Composable +private fun ActionProgressFeed( + userPrompt: String, + steps: List, + isCompleted: Boolean, + textResponse: String? = null, + onDismiss: () -> Unit, +) { + val isMultiStep = steps.size > 1 + val completedCount = steps.count { it.status == ActionStepStatus.COMPLETED } + + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = if (isCompleted) Icons.Default.CheckCircle else Icons.Default.AutoAwesome, + contentDescription = null, + tint = if (isCompleted) Color(0xFF2E7D32) else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (userPrompt.isNotBlank()) "\"$userPrompt\"" else "Updating trip", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (isMultiStep) { + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(12.dp), + color = + if (isCompleted) Color(0xFF2E7D32).copy(alpha = 0.14f) + else MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = + if (isCompleted) "${steps.size} steps" + else "${completedCount.coerceAtLeast(1)}/${steps.size}", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = + if (isCompleted) Color(0xFF2E7D32) + else MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + if (isCompleted) { + Spacer(modifier = Modifier.width(4.dp)) + IconButton(onClick = onDismiss, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Dismiss", + modifier = Modifier.size(16.dp), + ) + } + } + } + } + + steps.forEachIndexed { index, step -> + UserActionStepRow(step = step, stepNumber = if (isMultiStep) index + 1 else null) + } + + if (isCompleted && !textResponse.isNullOrBlank()) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = textResponse, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + ) + } + } + } +} + +@Composable +private fun UserActionStepRow(step: UserActionStep, stepNumber: Int? = null) { + val icon = + when (step.category) { + ActionStepCategory.EXPENSE -> Icons.Default.ReceiptLong + ActionStepCategory.REMINDER -> Icons.Default.NotificationsActive + ActionStepCategory.FLIGHT -> Icons.Default.Flight + ActionStepCategory.DOCUMENT -> Icons.Default.DocumentScanner + ActionStepCategory.ITINERARY -> Icons.Default.Event + ActionStepCategory.TRIP -> Icons.Default.Search + } + + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + if (stepNumber != null) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(22.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = stepNumber.toString(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + } + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text( + text = step.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!step.subtitle.isNullOrBlank()) { + Text( + text = step.subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + when (step.status) { + ActionStepStatus.IN_PROGRESS -> { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + ActionStepStatus.COMPLETED -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Done", + tint = Color(0xFF2E7D32), + modifier = Modifier.size(20.dp), + ) + } + ActionStepStatus.FAILED -> { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = "Failed", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + } + } + } + } +} + +/** + * Multi-turn conversational sheet for Demo 3. + * + * Stays anchored as a half-screen overlay so the user never loses sight of the underlying trip, + * with a pure voice follow-up trigger (no text input field). + */ +@Composable +private fun ConversationalAssistantSheet( + turns: List, + activeSteps: List, + isListening: Boolean, + liveTranscript: String, + isBusy: Boolean, + busySummary: String?, + onMicClick: () -> Unit, + onCancelListenClick: () -> Unit, + onResetClick: () -> Unit, + onCollapse: () -> Unit, +) { + val listState = rememberLazyListState() + + LaunchedEffect(turns.size, activeSteps.size, isBusy) { + val totalItems = listState.layoutInfo.totalItemsCount + if (totalItems > 0) { + listState.animateScrollToItem(totalItems - 1) + } + } + + Column( + modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Trip Assistant", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (turns.size > 1) { + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = "${turns.size} turns", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + if (turns.isNotEmpty()) { + IconButton(onClick = onResetClick) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reset conversation", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = onCollapse) { + Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = "Minimize") + } + } + } + + val activeStatusText = activeSteps.lastOrNull()?.title ?: busySummary + if (turns.isNotEmpty() || (isBusy && activeStatusText != null)) { + LazyColumn( + state = listState, + modifier = Modifier.weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(turns, key = { it.id }) { turn -> ConversationTurnItem(turn = turn) } + if (isBusy && activeStatusText != null) { + item(key = "busy_indicator") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = activeStatusText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + + if (isListening) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = Icons.Default.GraphicEq, + contentDescription = "Listening", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text( + text = "Listening…", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = liveTranscript.ifBlank { "Speak your follow-up…" }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontStyle = if (liveTranscript.isBlank()) FontStyle.Italic else FontStyle.Normal, + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + FilledIconButton( + onClick = onMicClick, + shape = CircleShape, + colors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary + ), + modifier = Modifier.size(34.dp), + ) { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = "Done speaking", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + } + Spacer(modifier = Modifier.width(4.dp)) + IconButton(onClick = onCancelListenClick, modifier = Modifier.size(30.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cancel", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } else { + Surface( + shape = RoundedCornerShape(24.dp), + color = + if (isBusy) MaterialTheme.colorScheme.surfaceContainerHighest + else MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().clickable(enabled = !isBusy, onClick = onMicClick), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = "Speak follow-up", + tint = + if (isBusy) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (isBusy) "Working…" else "Ask a follow-up", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = + if (isBusy) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + } +} + +@Composable +private fun ConversationTurnItem(turn: ConversationTurnUi) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + Surface( + shape = RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = turn.userPrompt, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + + if (!turn.assistantMessage.isNullOrBlank()) { + Surface( + shape = RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Text( + text = turn.assistantMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + ) + } + } + } +} diff --git a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/ChatHistoryViewModelTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/ChatHistoryViewModelTest.kt new file mode 100644 index 00000000..c7c2941f --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/ChatHistoryViewModelTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant + +import com.example.jetpacker.data.itinerary.AssistantConversation +import com.example.jetpacker.data.itinerary.AssistantConversationDao +import com.example.jetpacker.feature.assistant.history.ChatHistoryViewModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatHistoryViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private lateinit var fakeDao: FakeAssistantConversationDao + private lateinit var viewModel: ChatHistoryViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + fakeDao = FakeAssistantConversationDao() + viewModel = ChatHistoryViewModel(fakeDao) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun getConversations_returnsConversationsForTrip() = runTest(testDispatcher) { + val conv1 = + AssistantConversation( + id = "c1", + tripId = "trip-1", + title = "Check flight", + timestamp = 1000L, + lastMessage = "Flight details", + ) + val conv2 = + AssistantConversation( + id = "c2", + tripId = "trip-2", + title = "Other trip", + timestamp = 2000L, + lastMessage = "Other details", + ) + fakeDao.insertConversation(conv1) + fakeDao.insertConversation(conv2) + + val trip1Convs = viewModel.getConversations("trip-1").first() + assertThat(trip1Convs).containsExactly(conv1) + } + + @Test + fun deleteConversation_removesFromDao() = runTest(testDispatcher) { + val conv = + AssistantConversation( + id = "c1", + tripId = "trip-1", + title = "To be deleted", + timestamp = 1000L, + lastMessage = "Delete me", + ) + fakeDao.insertConversation(conv) + + viewModel.deleteConversation("c1") + advanceUntilIdle() + + val remaining = viewModel.getConversations("trip-1").first() + assertThat(remaining).isEmpty() + } + + @Test + fun clearAll_deletesAllConversationsForTrip() = runTest(testDispatcher) { + fakeDao.insertConversation( + AssistantConversation( + id = "c1", + tripId = "trip-1", + title = "Conv 1", + timestamp = 100L, + lastMessage = "Msg 1", + ) + ) + fakeDao.insertConversation( + AssistantConversation( + id = "c2", + tripId = "trip-1", + title = "Conv 2", + timestamp = 200L, + lastMessage = "Msg 2", + ) + ) + fakeDao.insertConversation( + AssistantConversation( + id = "c3", + tripId = "trip-2", + title = "Conv 3", + timestamp = 300L, + lastMessage = "Msg 3", + ) + ) + + viewModel.clearAll("trip-1") + advanceUntilIdle() + + val trip1Remaining = viewModel.getConversations("trip-1").first() + assertThat(trip1Remaining).isEmpty() + + val trip2Remaining = viewModel.getConversations("trip-2").first() + assertThat(trip2Remaining).hasSize(1) + } + + @Test + fun seedDemoConversationsIfEmpty_seedsWhenEmpty() = runTest(testDispatcher) { + viewModel.seedDemoConversationsIfEmpty("2026-1") + advanceUntilIdle() + + val seeded = viewModel.getConversations("2026-1").first() + assertThat(seeded).isNotEmpty() + assertThat(seeded.first().tripId).isEqualTo("2026-1") + } +} + +private class FakeAssistantConversationDao : AssistantConversationDao { + private val conversations = MutableStateFlow>(emptyMap()) + + override fun getConversationsForTrip(tripId: String): Flow> { + return conversations.map { map -> + map.values.filter { it.tripId == tripId }.sortedByDescending { it.timestamp } + } + } + + override suspend fun getConversationById(id: String): AssistantConversation? { + return conversations.value[id] + } + + override suspend fun insertConversation(conversation: AssistantConversation) { + conversations.value = conversations.value + (conversation.id to conversation) + } + + override suspend fun insertConversations(conversations: List) { + this.conversations.value = this.conversations.value + conversations.associateBy { it.id } + } + + override suspend fun deleteConversation(conversation: AssistantConversation) { + conversations.value = conversations.value - conversation.id + } + + override suspend fun deleteConversationById(id: String) { + conversations.value = conversations.value - id + } + + override suspend fun deleteAllForTrip(tripId: String) { + conversations.value = conversations.value.filterValues { it.tripId != tripId } + } +} diff --git a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/ConversationSerializationTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/ConversationSerializationTest.kt new file mode 100644 index 00000000..637f9830 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/ConversationSerializationTest.kt @@ -0,0 +1,159 @@ +/* + * 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.assistant + +import com.example.jetpacker.data.itinerary.AssistantConversation +import com.example.jetpacker.feature.assistant.history.toConversationTurns +import com.example.jetpacker.feature.assistant.history.toTurnsJson +import com.example.jetpacker.feature.assistant.model.ActionStepCategory +import com.example.jetpacker.feature.assistant.model.ActionStepStatus +import com.example.jetpacker.feature.assistant.model.ConversationTurnUi +import com.example.jetpacker.feature.assistant.model.UserActionStep +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ConversationSerializationTest { + + @Test + fun roundTripSerialization_preservesAllFields() { + val steps = + listOf( + UserActionStep( + id = "step-1", + category = ActionStepCategory.EXPENSE, + status = ActionStepStatus.COMPLETED, + title = "Added $45 Uber to Mountain View", + subtitle = "Total expense: $45.00 USD", + ), + UserActionStep( + id = "step-2", + category = ActionStepCategory.ITINERARY, + status = ActionStepStatus.COMPLETED, + title = "Checked flight schedule", + ), + ) + + val turns = + listOf( + ConversationTurnUi( + id = "turn-1", + userPrompt = "Add $45 for Uber", + assistantMessage = "Added $45.00 USD for your Uber ride.", + actionSteps = steps, + ), + ConversationTurnUi( + id = "turn-2", + userPrompt = "What flights do I have?", + assistantMessage = "You have flight UA 428 departing tomorrow.", + actionSteps = emptyList(), + ), + ) + + val json = turns.toTurnsJson() + assertThat(json).isNotEmpty() + + val conversation = + AssistantConversation( + id = "conv-1", + tripId = "trip-1", + title = "Uber expense & flight check", + timestamp = System.currentTimeMillis(), + lastMessage = "You have flight UA 428 departing tomorrow.", + turnCount = 2, + turnsJson = json, + ) + + val deserialized = conversation.toConversationTurns() + assertThat(deserialized).hasSize(2) + + val firstTurn = deserialized[0] + assertThat(firstTurn.id).isEqualTo("turn-1") + assertThat(firstTurn.userPrompt).isEqualTo("Add $45 for Uber") + assertThat(firstTurn.assistantMessage).isEqualTo("Added $45.00 USD for your Uber ride.") + assertThat(firstTurn.actionSteps).hasSize(2) + assertThat(firstTurn.actionSteps[0].id).isEqualTo("step-1") + assertThat(firstTurn.actionSteps[0].category).isEqualTo(ActionStepCategory.EXPENSE) + assertThat(firstTurn.actionSteps[0].status).isEqualTo(ActionStepStatus.COMPLETED) + assertThat(firstTurn.actionSteps[0].title).isEqualTo("Added $45 Uber to Mountain View") + assertThat(firstTurn.actionSteps[0].subtitle).isEqualTo("Total expense: $45.00 USD") + + val secondTurn = deserialized[1] + assertThat(secondTurn.id).isEqualTo("turn-2") + assertThat(secondTurn.userPrompt).isEqualTo("What flights do I have?") + assertThat(secondTurn.assistantMessage).isEqualTo("You have flight UA 428 departing tomorrow.") + assertThat(secondTurn.actionSteps).isEmpty() + } + + @Test + fun emptyList_serializesToEmptyJsonArray() { + val json = emptyList().toTurnsJson() + assertThat(json).isEqualTo("[]") + + val conversation = + AssistantConversation( + id = "c1", + tripId = "t1", + title = "Title", + timestamp = 1000L, + lastMessage = "Msg", + turnsJson = json, + ) + assertThat(conversation.toConversationTurns()).isEmpty() + } + + @Test + fun blankOrInvalidJson_returnsEmptyList() { + val blankConversation = + AssistantConversation( + id = "c1", + tripId = "t1", + title = "", + timestamp = 1000L, + lastMessage = "", + turnsJson = " ", + ) + assertThat(blankConversation.toConversationTurns()).isEmpty() + + val corruptConversationWithoutFallback = + AssistantConversation( + id = "c2", + tripId = "t1", + title = "", + timestamp = 1000L, + lastMessage = "", + turnsJson = "{ not a valid json [", + ) + assertThat(corruptConversationWithoutFallback.toConversationTurns()).isEmpty() + } + + @Test + fun corruptJsonWithMetadata_fallsBackToSingleTurn() { + val corruptWithMetadata = + AssistantConversation( + id = "c3", + tripId = "t1", + title = "Flight query", + timestamp = 1000L, + lastMessage = "Flight details", + turnsJson = "{ corrupt json [", + ) + val turns = corruptWithMetadata.toConversationTurns() + assertThat(turns).hasSize(1) + assertThat(turns[0].userPrompt).isEqualTo("Flight query") + assertThat(turns[0].assistantMessage).isEqualTo("Flight details") + } +} diff --git a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelDaoToolsTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelDaoToolsTest.kt new file mode 100644 index 00000000..5c9f8525 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelDaoToolsTest.kt @@ -0,0 +1,425 @@ +/* + * 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.assistant + +import com.example.jetpacker.data.itinerary.ActivityDetail +import com.example.jetpacker.data.itinerary.DiningDetail +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.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.TripSessionIdentifier +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import com.example.jetpacker.feature.assistant.tools.AddExpenseTool +import com.example.jetpacker.feature.assistant.tools.AddItineraryEventTool +import com.example.jetpacker.feature.assistant.tools.AddVoiceNoteTool +import com.example.jetpacker.feature.assistant.tools.GetEventDetailsTool +import com.example.jetpacker.feature.assistant.tools.GetFlightItineraryTool +import com.example.jetpacker.feature.assistant.tools.SearchTripTool +import com.google.common.truth.Truth.assertThat +import java.time.LocalDate +import java.time.ZoneId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class TravelDaoToolsTest { + + private lateinit var fakeEventDao: FakeEventDao + private lateinit var fakeTripDao: FakeTripDao + private lateinit var fakeExpenseDao: FakeExpenseDao + + private lateinit var flightTool: GetFlightItineraryTool + private lateinit var expenseTool: AddExpenseTool + private lateinit var eventTool: AddItineraryEventTool + private lateinit var eventDetailsTool: GetEventDetailsTool + private lateinit var voiceNoteTool: AddVoiceNoteTool + private lateinit var searchTripTool: SearchTripTool + + @Before + fun setUp() { + fakeEventDao = FakeEventDao() + fakeTripDao = FakeTripDao() + fakeExpenseDao = FakeExpenseDao() + + flightTool = GetFlightItineraryTool(fakeEventDao, fakeTripDao) + expenseTool = AddExpenseTool(fakeExpenseDao, fakeTripDao) + eventTool = AddItineraryEventTool(fakeEventDao, fakeTripDao) + eventDetailsTool = GetEventDetailsTool(fakeEventDao) + voiceNoteTool = AddVoiceNoteTool(fakeEventDao, fakeTripDao) + searchTripTool = SearchTripTool(fakeTripDao) + } + + @Test + fun getFlightItinerary_returnsMatchingFlight() = runTest { + val trip = + Trip( + id = "trip-1", + title = "Tokyo Getaway", + location = "Tokyo", + startDate = 1000L, + endDate = 2000L, + ) + fakeTripDao.trips.add(trip) + + val event = + TimelineEvent( + id = "ev-1", + tripId = "trip-1", + type = EventType.TRANSPORTATION, + title = "Flight to Tokyo", + timestamp = 1200L, + location = "Tokyo", + ) + fakeEventDao.events["trip-1"] = listOf(event) + + val flight = + FlightDetail( + eventId = "ev-1", + airline = "Japan Airlines", + flightNum = "JL005", + origin = "JFK", + destination = "HND", + departureTerminal = "1", + gate = "A4", + ) + fakeEventDao.flights["ev-1"] = flight + + val result = flightTool.execute(mapOf("tripId" to "trip-1", "flightNumber" to "JL005")) + + assertThat(result["count"]).isEqualTo(1) + val flights = result["flights"] as List<*> + val first = flights.first() as Map<*, *> + assertThat(first["flightNumber"]).isEqualTo("JL005") + assertThat(first["airline"]).isEqualTo("Japan Airlines") + assertThat(first["gate"]).isEqualTo("A4") + } + + @Test + fun addExpenseTool_insertsExpenseAndDefaultsTrip() = runTest { + val trip = + Trip( + id = "2026-4", + title = "Tokyo Explorer", + location = "Tokyo, Japan", + startDate = 1000L, + endDate = 2000L, + ) + fakeTripDao.trips.add(trip) + + val result = + expenseTool.execute( + mapOf( + "title" to "Airport express train", + "amount" to 45.0, + "currency" to "EUR", + "category" to "Transport", + ) + ) + + assertThat(result["status"]).isEqualTo("SUCCESS") + assertThat(result["tripId"]).isEqualTo("2026-4") + assertThat(fakeExpenseDao.expenses).hasSize(1) + assertThat(fakeExpenseDao.expenses.first().title).isEqualTo("Airport express train") + assertThat(fakeExpenseDao.expenses.first().amount).isEqualTo(45.0) + } + + @Test + fun addItineraryEventTool_insertsReminderEvent() = runTest { + val trip = + Trip( + id = "2026-4", + title = "Tokyo Explorer", + location = "Tokyo, Japan", + startDate = 1000L, + endDate = 2000L, + ) + fakeTripDao.trips.add(trip) + + val result = + eventTool.execute( + mapOf("tripId" to "2026-4", "title" to "Pick up pocket wifi", "type" to "ACTIVITY") + ) + + assertThat(result["status"]).isEqualTo("SUCCESS") + assertThat(result["tripId"]).isEqualTo("2026-4") + val events = fakeEventDao.events["2026-4"] + assertThat(events).isNotNull() + assertThat(events).hasSize(1) + assertThat(events!!.first().title).isEqualTo("Pick up pocket wifi") + assertThat(events.first().location).isEqualTo("Tokyo, Japan") + } + + @Test + fun addExpenseTool_deduplicatesIdenticalExpenseExecutedWithinWindow() = runTest { + val trip = + Trip( + id = "2026-4", + title = "Tokyo Explorer", + location = "Tokyo, Japan", + startDate = 1000L, + endDate = 2000L, + ) + fakeTripDao.trips.add(trip) + + val args = + mapOf( + "title" to "Airport express train", + "amount" to 45.0, + "currency" to "EUR", + "category" to "Transport", + ) + val result1 = expenseTool.execute(args) + val result2 = expenseTool.execute(args) + + assertThat(result1["status"]).isEqualTo("SUCCESS") + assertThat(result2["status"]).isEqualTo("SUCCESS") + assertThat(fakeExpenseDao.expenses).hasSize(1) + } + + @Test + fun addItineraryEventTool_deduplicatesIdenticalReminderExecutedWithinWindow() = runTest { + val trip = + Trip( + id = "2026-4", + title = "Tokyo Explorer", + location = "Tokyo, Japan", + startDate = 1000L, + endDate = 2000L, + ) + fakeTripDao.trips.add(trip) + + val args = mapOf("tripId" to "2026-4", "title" to "Pick up pocket wifi", "type" to "ACTIVITY") + val result1 = eventTool.execute(args) + val result2 = eventTool.execute(args) + + assertThat(result1["status"]).isEqualTo("SUCCESS") + assertThat(result2["status"]).isEqualTo("SUCCESS") + val events = fakeEventDao.events["2026-4"] + assertThat(events).isNotNull() + assertThat(events).hasSize(1) + } + + @Test + fun getEventDetailsTool_returnsTokyoMuseumAdmissionAndTips() = runTest { + // Even if DAO is empty, GetEventDetailsTool falls back to DummyData for tok_3 + val result = eventDetailsTool.execute(mapOf("eventId" to "tok_3")) + + assertThat(result["status"]).isEqualTo("SUCCESS") + assertThat(result["eventId"]).isEqualTo("tok_3") + assertThat(result["title"]).isEqualTo("teamLab Planets Museum") + assertThat(result["admissionPrice"]).isEqualTo("€28") + val tips = result["tips"] as List<*> + assertThat(tips).contains("Wear clothes that can be rolled up to the knees") + } + + @Test + fun addVoiceNoteTool_matchesAirportFlightEventAndInsertsVoiceNote() = runTest { + val trip = + Trip( + id = "2026-4", + title = "Tokyo", + location = "Tokyo, Japan", + startDate = 1000L, + endDate = 2000L, + ) + fakeTripDao.trips.add(trip) + + val flightEvent = + TimelineEvent( + id = "tok_1", + tripId = "2026-4", + type = EventType.TRANSPORTATION, + title = "Flight to Tokyo (HND)", + timestamp = 1200L, + location = "Haneda Airport Terminal 3", + ) + fakeEventDao.events["2026-4"] = listOf(flightEvent) + + val result = + voiceNoteTool.execute( + mapOf("tripId" to "2026-4", "title" to "Buy a SIM card when I get to the airport") + ) + + assertThat(result["status"]).isEqualTo("SUCCESS") + assertThat(result["matchedEventId"]).isEqualTo("tok_1") + assertThat(result["matchedEventTitle"]).isEqualTo("Flight to Tokyo (HND)") + assertThat(fakeEventDao.voiceNotes).hasSize(1) + assertThat(fakeEventDao.voiceNotes.first().matchingEventsJson).contains("tok_1") + } + + @Test + fun searchTripTool_findsMatchingTripByQuery() = runTest { + fakeTripDao.trips.add( + Trip( + id = "2026-4", + title = "Tokyo", + location = "Tokyo, Japan", + startDate = 1000L, + endDate = 2000L, + ) + ) + + val result = searchTripTool.execute(mapOf("query" to "Tokyo")) + assertThat(result["count"]).isEqualTo(1) + val trips = result["trips"] as List<*> + val first = trips.first() as Map<*, *> + assertThat(first["id"]).isEqualTo("2026-4") + } +} + +private class FakeTripDao : TripDao { + val trips = mutableListOf() + + override fun getAllTrips(): Flow> = flowOf(trips.toList()) + + override fun getTripById(tripId: String): Flow = flowOf(trips.find { it.id == tripId }) + + override suspend fun insertTrip(trip: Trip) { + trips.add(trip) + } + + override suspend fun insertTrips(trips: List) { + this.trips.addAll(trips) + } + + override suspend fun deleteTrip(trip: Trip) { + trips.remove(trip) + } + + override suspend fun deleteAllTrips() { + trips.clear() + } +} + +private class FakeEventDao : EventDao { + val events = mutableMapOf>() + val flights = mutableMapOf() + val hotels = mutableMapOf() + val museums = mutableMapOf() + val activities = mutableMapOf() + val voiceNotes = mutableListOf() + + override fun getAllSessionIds(): Flow> = flowOf(emptyList()) + + override fun getEventsForTrip(tripId: String): Flow> = + flowOf(events[tripId] ?: emptyList()) + + override suspend fun insertEvent(event: TimelineEvent) { + val current = (events[event.tripId] ?: emptyList()).filterNot { it.id == event.id } + events[event.tripId] = current + event + } + + override suspend fun insertEvents(events: List) { + events.forEach { insertEvent(it) } + } + + override suspend fun deleteEventsForTrip(tripId: String) { + events.remove(tripId) + } + + override suspend fun deleteEvent(event: TimelineEvent) { + events[event.tripId] = (events[event.tripId] ?: emptyList()) - event + } + + override suspend fun insertFlightDetail(detail: FlightDetail) { + flights[detail.eventId] = detail + } + + override suspend fun insertHotelDetail(detail: HotelDetail) { + hotels[detail.eventId] = detail + } + + override suspend fun insertDiningDetail(detail: DiningDetail) {} + + override suspend fun insertActivityDetail(detail: ActivityDetail) { + activities[detail.eventId] = detail + } + + override suspend fun insertMuseumDetail(detail: MuseumDetail) { + museums[detail.eventId] = detail + } + + override fun getFlightDetail(eventId: String): Flow = flowOf(flights[eventId]) + + override fun getHotelDetail(eventId: String): Flow = flowOf(hotels[eventId]) + + override fun getDiningDetail(eventId: String): Flow = flowOf(null) + + override fun getMuseumDetail(eventId: String): Flow = flowOf(museums[eventId]) + + override fun getEventById(eventId: String): Flow = + flowOf(events.values.flatten().find { it.id == eventId }) + + override suspend fun deleteAllEvents() { + events.clear() + } + + override suspend fun deleteAllFlightDetails() { + flights.clear() + } + + override suspend fun deleteAllHotelDetails() { + hotels.clear() + } + + override suspend fun deleteAllDiningDetails() {} + + override suspend fun deleteAllActivityDetails() { + activities.clear() + } + + override fun getVoiceNotesForTrip(tripId: String): Flow> = + flowOf(voiceNotes.filter { it.tripId == tripId }) + + override suspend fun insertVoiceNote(note: VoiceNoteEntity) { + voiceNotes.add(note) + } + + override suspend fun deleteVoiceNoteById(id: String) { + voiceNotes.removeAll { it.id == id } + } +} + +private class FakeExpenseDao : ExpenseDao { + val expenses = mutableListOf() + + override fun getAllExpenses(): Flow> = flowOf(expenses.toList()) + + override fun getExpensesForTrip(tripId: String): Flow> = + flowOf(expenses.filter { it.tripId == tripId }) + + override suspend fun insertExpense(expense: Expense) { + expenses.add(expense) + } + + override suspend fun deleteExpense(expense: Expense) { + expenses.remove(expense) + } + + override suspend fun deleteAllExpenses() { + expenses.clear() + } +} diff --git a/jetpacker/android/feature/trip/build.gradle.kts b/jetpacker/android/feature/trip/build.gradle.kts index 0d8a2e92..381adb18 100644 --- a/jetpacker/android/feature/trip/build.gradle.kts +++ b/jetpacker/android/feature/trip/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation(project(":feature:trip:expenses")) implementation(project(":feature:trip:voice_notes")) implementation(project(":feature:trip:booking_assistant")) + implementation(project(":feature:assistant")) implementation(libs.androidx.compose.material.icons.core) implementation(libs.androidx.compose.material.icons.extended) diff --git a/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesScreen.kt b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesScreen.kt index 6f681ed6..91de3a09 100644 --- a/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesScreen.kt +++ b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesScreen.kt @@ -231,23 +231,7 @@ fun ManageExpensesScreen( } } - LaunchedEffect(Unit) { - onFabConfigChange( - JetPackerFabConfig( - icon = Icons.Rounded.DocumentScanner, - onClick = { - coroutineScope.launch { - if (viewModel.isSupported()) { - cameraLauncher.launch(null) - } else { - Toast.makeText(context, "Feature not available", Toast.LENGTH_SHORT).show() - } - } - }, - contentDescription = "Add Expense", - ) - ) - } + LaunchedEffect(Unit) { onFabConfigChange(null) } Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), diff --git a/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt b/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt index 163675a5..0850b871 100644 --- a/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt +++ b/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt @@ -16,8 +16,13 @@ package com.example.jetpacker.feature.trip +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally @@ -31,21 +36,24 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.plus import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.automirrored.rounded.Chat +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.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -55,6 +63,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.jetpacker.core.flags.FeatureFlags import com.example.jetpacker.core.ui.R import com.example.jetpacker.core.ui.components.JetPackerFab @@ -62,6 +72,10 @@ import com.example.jetpacker.core.ui.components.JetPackerFabConfig import com.example.jetpacker.core.ui.components.JetPackerToolbar import com.example.jetpacker.core.ui.components.JetPackerToolbarAction import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.feature.assistant.TravelAssistantViewModel +import com.example.jetpacker.feature.assistant.history.ChatHistoryScreen +import com.example.jetpacker.feature.assistant.model.AssistantOverlayState +import com.example.jetpacker.feature.assistant.ui.AmbientTripAssistantSheet import com.example.jetpacker.feature.booking_assistant.BookingAssistantScreen import com.example.jetpacker.feature.expenses.ManageExpensesScreen import com.example.jetpacker.feature.itinerary.ItineraryScreen @@ -72,6 +86,7 @@ enum class TripTab { EXPENSES, VOICE_NOTES, BOOKING, + CHAT_HISTORY, } /** @@ -86,35 +101,74 @@ fun TripScreen( onEventClick: (String, EventType) -> Unit = { _, _ -> }, onVoiceNotesClick: () -> Unit = {}, onNavigateToDebug: () -> Unit = {}, + assistantViewModel: TravelAssistantViewModel = hiltViewModel(), ) { var selectedTab by remember { mutableStateOf(TripTab.ITINERARY) } - var fabConfig by remember { mutableStateOf(null) } + // One FAB config slot per tab so a tab can never render another tab's FAB, even for the frame + // between switching tabs and the new screen publishing its config from a LaunchedEffect. + val fabConfigs = remember { mutableStateMapOf() } + + LaunchedEffect(tripId) { assistantViewModel.setTripId(tripId) } + + val overlayState by assistantViewModel.overlayState.collectAsStateWithLifecycle() + val isAssistantProcessingExpense by + assistantViewModel.isProcessingExpense.collectAsStateWithLifecycle() + + val audioPermissionLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { + isGranted -> + assistantViewModel.setVoicePermissionGranted(isGranted) + if (isGranted) { + assistantViewModel.startVoiceAssistant(onPermissionRequired = {}) + } else { + assistantViewModel.dismissOverlay() + } + } + + val assistantFabConfig = + JetPackerFabConfig( + icon = Icons.Rounded.AutoAwesome, + onClick = { + assistantViewModel.startVoiceAssistant( + onPermissionRequired = { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + ) + }, + contentDescription = "Trip Assistant", + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + ) + + val assistantEnabled = FeatureFlags.ENABLE_TRAVEL_ASSISTANT + val assistantAvailableOnTab = assistantEnabled && selectedTab != TripTab.BOOKING + val showAssistantOverlay = + assistantAvailableOnTab && overlayState !is AssistantOverlayState.Collapsed + // Tab's own action FAB takes precedence; if the tab has no specific creation FAB, + // fall back to the assistant FAB when enabled. + val tabFabConfig = fabConfigs[selectedTab] + val bottomBarFabConfig = + tabFabConfig ?: if (assistantAvailableOnTab) assistantFabConfig else null Scaffold( containerColor = MaterialTheme.colorScheme.primary, bottomBar = { - val showExpenses = FeatureFlags.ENABLE_EXPENSE_MANAGEMENT - val showVoiceNotes = FeatureFlags.ENABLE_VOICE_NOTES - val showBooking = FeatureFlags.ENABLE_BOOKING_ASSISTANT - - var visibleCount = 1 - if (showExpenses) visibleCount++ - if (showVoiceNotes) visibleCount++ - if (showBooking) visibleCount++ - - if (visibleCount > 1) { - AnimatedVisibility( - visible = true, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - ) { + TripBottomBarContainer( + showAssistantOverlay = showAssistantOverlay, + bottomBar = { JetPackerBottomBar( selectedTab = selectedTab, onTabSelected = { selectedTab = it }, - fabConfig = fabConfig, + fabConfig = bottomBarFabConfig, ) - } - } + }, + assistantSheet = { + AmbientTripAssistantSheet( + viewModel = assistantViewModel, + modifier = Modifier.padding(bottom = 8.dp), + ) + }, + ) }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { @@ -126,7 +180,7 @@ fun TripScreen( onBack = onBack, onEditTripClick = onEditTripClick, onEventClick = onEventClick, - onFabConfigChange = { fabConfig = it }, + onFabConfigChange = { fabConfigs[TripTab.ITINERARY] = it }, onNavigateToDebug = onNavigateToDebug, ) } @@ -136,7 +190,8 @@ fun TripScreen( tripId = tripId, contentPadding = innerPadding, onBack = { selectedTab = TripTab.ITINERARY }, - onFabConfigChange = { fabConfig = it }, + onFabConfigChange = { fabConfigs[TripTab.EXPENSES] = it }, + isAssistantProcessingExpense = isAssistantProcessingExpense, ) } @@ -145,10 +200,9 @@ fun TripScreen( tripId = tripId, contentPadding = innerPadding, onBack = { selectedTab = TripTab.ITINERARY }, - onFabConfigChange = { fabConfig = it }, + onFabConfigChange = { fabConfigs[TripTab.VOICE_NOTES] = it }, ) } - TripTab.BOOKING -> { BookingAssistantScreen( tripId = tripId, @@ -156,11 +210,56 @@ fun TripScreen( onBack = { selectedTab = TripTab.ITINERARY }, ) } + TripTab.CHAT_HISTORY -> { + ChatHistoryScreen( + tripId = tripId, + contentPadding = innerPadding, + onBack = { selectedTab = TripTab.ITINERARY }, + onFabConfigChange = { fabConfigs[TripTab.CHAT_HISTORY] = it }, + onSelectConversation = { conversation -> + assistantViewModel.loadConversation(conversation) + }, + onStartNewChat = { + assistantViewModel.clearChat() + assistantViewModel.openConversationalOverlay() + }, + ) + } } } } } +/** + * Hosts the bottom navigation bar and the ambient assistant sheet in the same slot, overlapping + * them so the Scaffold's bottom inset stays stable while one slides out and the other slides in. + */ +@Composable +private fun TripBottomBarContainer( + showAssistantOverlay: Boolean, + bottomBar: @Composable () -> Unit, + assistantSheet: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + val enter = + slideInVertically(tween(durationMillis = 240, easing = FastOutSlowInEasing)) { it } + + fadeIn(tween(durationMillis = 180)) + val exit = + slideOutVertically(tween(durationMillis = 220, easing = FastOutSlowInEasing)) { it } + + fadeOut(tween(durationMillis = 120)) + + Box( + modifier = + modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars).imePadding(), + contentAlignment = Alignment.BottomCenter, + ) { + AnimatedVisibility(visible = !showAssistantOverlay, enter = enter, exit = exit) { bottomBar() } + AnimatedVisibility(visible = showAssistantOverlay, enter = enter, exit = exit) { + assistantSheet() + } + } +} + @Composable fun JetPackerBottomBar( selectedTab: TripTab, @@ -171,20 +270,37 @@ fun JetPackerBottomBar( val showExpenses = FeatureFlags.ENABLE_EXPENSE_MANAGEMENT val showVoiceNotes = FeatureFlags.ENABLE_VOICE_NOTES val showBooking = FeatureFlags.ENABLE_BOOKING_ASSISTANT + val showChatHistory = FeatureFlags.ENABLE_TRAVEL_ASSISTANT + val visibleTabCount = + 1 + + (if (showExpenses) 1 else 0) + + (if (showVoiceNotes) 1 else 0) + + (if (showBooking) 1 else 0) + + (if (showChatHistory) 1 else 0) + val maxToolbarWidth = + when { + visibleTabCount <= 2 -> if (fabConfig != null) 240.dp else 300.dp + visibleTabCount == 3 -> if (fabConfig != null) 280.dp else 340.dp + visibleTabCount == 4 -> if (fabConfig != null) 340.dp else 400.dp + visibleTabCount == 5 -> if (fabConfig != null) 390.dp else 450.dp + else -> if (fabConfig != null) 440.dp else 500.dp + } LookaheadScope { Row( modifier = modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(bottom = 16.dp) - .padding(horizontal = 32.dp), + .padding(top = 12.dp, bottom = 16.dp) + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { JetPackerToolbar( - modifier = Modifier.widthIn(max = 336.dp).animateBounds(this@LookaheadScope) + modifier = + Modifier.weight(1f, fill = false) + .widthIn(max = maxToolbarWidth) + .animateBounds(this@LookaheadScope) ) { JetPackerToolbarAction( icon = Icons.Rounded.Event, @@ -209,7 +325,6 @@ fun JetPackerBottomBar( contentDescription = "Voice Notes", ) } - if (showBooking) { JetPackerToolbarAction( selected = selectedTab == TripTab.BOOKING, @@ -218,23 +333,40 @@ fun JetPackerBottomBar( contentDescription = "Booking Assistant", ) } + if (showChatHistory) { + JetPackerToolbarAction( + selected = selectedTab == TripTab.CHAT_HISTORY, + onClick = { onTabSelected(TripTab.CHAT_HISTORY) }, + icon = Icons.AutoMirrored.Rounded.Chat, + contentDescription = "Chat history", + ) + } } - Spacer(Modifier.width(4.dp)) + // While the FAB animates out (fabConfig == null) keep rendering the last config instead of a + // generic fallback, so a transient null frame never flashes a different icon/colour. + var lastFabConfig by remember { mutableStateOf(fabConfig) } + if (fabConfig != null) lastFabConfig = fabConfig + val displayFab = lastFabConfig AnimatedVisibility( visible = fabConfig != null, enter = slideInHorizontally { it / 2 } + fadeIn(), exit = slideOutHorizontally { it / 2 } + fadeOut(), ) { - JetPackerFab( - modifier = Modifier.padding(4.dp), - onClick = fabConfig?.onClick ?: {}, - icon = fabConfig?.icon ?: Icons.Rounded.Add, - contentDescription = fabConfig?.contentDescription, - containerColor = fabConfig?.containerColor ?: MaterialTheme.colorScheme.tertiary, - contentColor = fabConfig?.contentColor ?: MaterialTheme.colorScheme.onTertiary, - ) + if (displayFab != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(8.dp)) + JetPackerFab( + modifier = Modifier.padding(4.dp), + onClick = displayFab.onClick, + icon = displayFab.icon, + contentDescription = displayFab.contentDescription, + containerColor = displayFab.containerColor ?: MaterialTheme.colorScheme.tertiary, + contentColor = displayFab.contentColor ?: MaterialTheme.colorScheme.onTertiary, + ) + } + } } } } diff --git a/jetpacker/android/feature/trip/voice_notes/src/test/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesViewModelTest.kt b/jetpacker/android/feature/trip/voice_notes/src/test/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesViewModelTest.kt index b30ed412..616c2a4c 100644 --- a/jetpacker/android/feature/trip/voice_notes/src/test/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesViewModelTest.kt +++ b/jetpacker/android/feature/trip/voice_notes/src/test/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesViewModelTest.kt @@ -29,8 +29,6 @@ import com.example.jetpacker.data.itinerary.MuseumDetail import com.example.jetpacker.data.itinerary.TimelineEvent import com.example.jetpacker.data.itinerary.TripSessionIdentifier import com.example.jetpacker.data.itinerary.VoiceNoteEntity -import com.google.common.util.concurrent.ListenableFuture -import com.google.mlkit.genai.common.DownloadCallback import com.google.mlkit.genai.common.DownloadStatus import com.google.mlkit.genai.common.GenAiException import com.google.mlkit.genai.common.StreamingCallback @@ -38,8 +36,9 @@ import com.google.mlkit.genai.prompt.Caches import com.google.mlkit.genai.prompt.CountTokensResponse import com.google.mlkit.genai.prompt.GenerateContentRequest import com.google.mlkit.genai.prompt.GenerateContentResponse +import com.google.mlkit.genai.prompt.GenerateTypedContentRequest +import com.google.mlkit.genai.prompt.GenerateTypedContentResponse import com.google.mlkit.genai.prompt.GenerativeModel -import java.util.concurrent.ExecutorService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -340,38 +339,30 @@ class VoiceNotesViewModelTest { override suspend fun checkStatus(): Int = 3 + override suspend fun getAvailableFeatures(): List = TODO("Not implemented") + override suspend fun isCachingFeatureAvailable(): Boolean = TODO("Not implemented") + override suspend fun isStructuredOutputFeatureAvailable(): Boolean = TODO("Not implemented") - override fun download(): - Flow = - TODO("Not implemented") + override suspend fun isSystemPromptAvailable(): Boolean = TODO("Not implemented") - override fun downloadForFutures( - callback: DownloadCallback - ): ListenableFuture = TODO("Not implemented") + override suspend fun isThinkingModeAvailable(): Boolean = TODO("Not implemented") - override suspend fun warmup() = TODO("Not implemented") + override fun download(): Flow = TODO("Not implemented") - override fun warmupForFutures(): ListenableFuture = - TODO("Not implemented") + override suspend fun warmup() = TODO("Not implemented") override suspend fun countTokens( request: GenerateContentRequest ): CountTokensResponse = TODO("Not implemented") - - override fun countTokensForFutures( - request: GenerateContentRequest - ): ListenableFuture< - CountTokensResponse - > = TODO("Not implemented") + override suspend fun countTokens( + request: GenerateTypedContentRequest + ): CountTokensResponse = TODO("Not implemented") override suspend fun getTokenLimit(): Int = TODO("Not implemented") - override fun getTokenLimitForFutures(): - ListenableFuture = TODO("Not implemented") - override suspend fun generateContent( request: GenerateContentRequest ): GenerateContentResponse = onGenerate() @@ -383,17 +374,14 @@ class VoiceNotesViewModelTest { override fun generateContentStream( request: GenerateContentRequest - ): Flow = - TODO("Not implemented") + ): Flow = TODO("Not implemented") + override suspend fun generateContent( + request: GenerateTypedContentRequest + ): GenerateTypedContentResponse = TODO("Not implemented") override suspend fun clearImplicitCaches() = TODO("Not implemented") - override fun clearImplicitCachesForFutures(): - ListenableFuture = TODO("Not implemented") - override fun close() {} - - override fun getWorkerExecutor(): ExecutorService = TODO("Not implemented") } } diff --git a/jetpacker/android/gradle/libs.versions.toml b/jetpacker/android/gradle/libs.versions.toml index a366c8bd..dfe397f0 100644 --- a/jetpacker/android/gradle/libs.versions.toml +++ b/jetpacker/android/gradle/libs.versions.toml @@ -37,6 +37,14 @@ lifecycleViewmodelCompose = "2.8.7" loggingInterceptor = "4.10.0" material = "1.14.0-alpha01" minSdk = "26" +mlkitGenaiAgent = "1.0.0-alpha1" +mlkitGenaiCommon = "1.0.0-beta5" +mlkitGenaiEmbedding = "1.0.0-alpha1" +mlkitGenaiPrompt = "1.0.0-beta5" +mlkitGenaiSchema = "1.0.0-alpha2" +mlkitGenaiSpeech = "1.0.0-alpha2" +mlkitLanguageId = "17.0.0" +mlkitTranslate = "17.0.3" moshiKotlin = "1.15.2" moshiKotlinCodegen = "1.15.2" nav3Core = "1.1.3" @@ -109,9 +117,16 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } -mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version = "1.0.0-beta2" } -mlkit-genai-speech = { group = "com.google.mlkit", name = "genai-speech-recognition", version = "1.0.0-alpha1" } -mlkit-translate = { group = "com.google.mlkit", name = "translate", version = "17.0.3" } +mlkit-genai-agent = { group = "com.google.mlkit", name = "genai-agent", version.ref = "mlkitGenaiAgent" } +mlkit-genai-common = { group = "com.google.mlkit", name = "genai-common", version.ref = "mlkitGenaiCommon" } +mlkit-genai-embedding = { group = "com.google.mlkit", name = "genai-embedding", version.ref = "mlkitGenaiEmbedding" } +mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version.ref = "mlkitGenaiPrompt" } +mlkit-genai-schema = { group = "com.google.mlkit", name = "genai-schema", version.ref = "mlkitGenaiSchema" } +mlkit-genai-speech = { group = "com.google.mlkit", name = "genai-speech-recognition", version.ref = "mlkitGenaiSpeech" } +androidx-appsearch = { group = "androidx.appsearch", name = "appsearch", version = "1.1.0-alpha07" } +androidx-appsearch-local-storage = { group = "androidx.appsearch", name = "appsearch-local-storage", version = "1.1.0-alpha07" } +androidx-concurrent-futures-ktx = { group = "androidx.concurrent", name = "concurrent-futures-ktx", version = "1.2.0" } +mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "mlkitTranslate" } moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshiKotlin" } moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshiKotlinCodegen" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } @@ -119,7 +134,7 @@ play-services-location = { group = "com.google.android.gms", name = "play-servic retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "composeScreenshot" } -mlkit-language-id = { group = "com.google.android.gms", name = "play-services-mlkit-language-id", version = "17.0.0" } +mlkit-language-id = { group = "com.google.android.gms", name = "play-services-mlkit-language-id", version.ref = "mlkitLanguageId" } firebase-appcheck-playintegrity = { group = "com.google.firebase", name = "firebase-appcheck-playintegrity" } firebase-appcheck-debug = { group = "com.google.firebase", name = "firebase-appcheck-debug" } androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" } diff --git a/jetpacker/android/settings.gradle.kts b/jetpacker/android/settings.gradle.kts index d29cf8d1..4c54ff54 100644 --- a/jetpacker/android/settings.gradle.kts +++ b/jetpacker/android/settings.gradle.kts @@ -75,3 +75,4 @@ include(":feature:trip:voice_notes") include(":feature:trip:itinerary:enrichment") include(":feature:appfunctions") include(":feature:trip:booking_assistant") +include(":feature:assistant") From b0a844453aa177ca68020e864b1c88661d679723 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Tue, 27 Oct 2026 15:41:29 +0000 Subject: [PATCH 2/3] ci: add JetPacker build and unit test job and trigger on all pull requests - Trigger pull_request workflow on all branches (enabling CI for stacked PRs) - Add jetpacker_build_test job with JDK 21 and assembleDebug / testDebugUnitTest --- .github/workflows/build-and-test.yml | 46 +++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 8727678a..0e91fca2 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -4,7 +4,6 @@ on: push: branches: [ "main" ] pull_request: - branches: [ "main" ] workflow_dispatch: # Ensure that only the latest commit is tested for PRs @@ -142,3 +141,48 @@ jobs: with: name: test-reports-${{ matrix.api-level }} path: '**/build/reports/androidTests' + + jetpacker_build_test: + name: "JetPacker Build and Unit Tests" + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + defaults: + run: + working-directory: jetpacker/android + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Accept Android SDK licenses + run: yes | "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" --licenses || true + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build debug APK and run unit tests + run: ./gradlew assembleDebug testDebugUnitTest --no-configuration-cache + + - name: Upload JetPacker build outputs (APKs) + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: jetpacker-APKs + path: 'jetpacker/android/**/build/outputs/apk/debug/*.apk' + + - name: Upload JetPacker JVM local test results (XML) + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: jetpacker-local-test-results + path: 'jetpacker/android/**/build/test-results/test*UnitTest/TEST-*.xml' From d8cc57f4514110fd61989ee7c7b27d9cdb3a2e85 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Tue, 27 Oct 2026 18:18:31 +0000 Subject: [PATCH 3/3] fix(trip): resolve compile error in TripScreen and fix itinerary unit test flag fallback --- .../kotlin/com/example/jetpacker/core/flags/FeatureFlags.kt | 2 +- .../kotlin/com/example/jetpacker/feature/trip/TripScreen.kt | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) 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 index c5b1bf40..e283192c 100644 --- 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 @@ -119,7 +119,7 @@ object FeatureFlags { // Offline features val ENABLE_ITINERARY_ENRICHMENT: Boolean - get() = readFlag(KEY_ENABLE_ITINERARY_ENRICHMENT, false) + get() = readFlag(KEY_ENABLE_ITINERARY_ENRICHMENT, true) val ENABLE_EXPENSE_MANAGEMENT: Boolean get() = readFlag(KEY_ENABLE_EXPENSE_MANAGEMENT, true) diff --git a/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt b/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt index 0850b871..02707602 100644 --- a/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt +++ b/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt @@ -111,8 +111,6 @@ fun TripScreen( LaunchedEffect(tripId) { assistantViewModel.setTripId(tripId) } val overlayState by assistantViewModel.overlayState.collectAsStateWithLifecycle() - val isAssistantProcessingExpense by - assistantViewModel.isProcessingExpense.collectAsStateWithLifecycle() val audioPermissionLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { @@ -191,7 +189,6 @@ fun TripScreen( contentPadding = innerPadding, onBack = { selectedTab = TripTab.ITINERARY }, onFabConfigChange = { fabConfigs[TripTab.EXPENSES] = it }, - isAssistantProcessingExpense = isAssistantProcessingExpense, ) }