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/app/src/main/AndroidManifest.xml b/jetpacker/android/app/src/main/AndroidManifest.xml index bf3fa4ad..bcf7715e 100644 --- a/jetpacker/android/app/src/main/AndroidManifest.xml +++ b/jetpacker/android/app/src/main/AndroidManifest.xml @@ -64,6 +64,16 @@ + + + + diff --git a/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/MainActivity.kt b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/MainActivity.kt index 8577136a..8e9ca881 100644 --- a/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/MainActivity.kt +++ b/jetpacker/android/app/src/main/kotlin/com/example/jetpacker/MainActivity.kt @@ -23,6 +23,7 @@ import android.hardware.SensorManager import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize @@ -45,7 +46,10 @@ import dagger.hilt.android.AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + ) handleFeatureFlags() setContent { diff --git a/jetpacker/android/app/src/main/res/values-night/colors.xml b/jetpacker/android/app/src/main/res/values-night/colors.xml index bf225f5a..882bb001 100644 --- a/jetpacker/android/app/src/main/res/values-night/colors.xml +++ b/jetpacker/android/app/src/main/res/values-night/colors.xml @@ -16,5 +16,27 @@ --> - #141412 + #131314 + + + #FF01AF57 + #FF061B0E + #FF0A3D22 + #FFB9F6CA + #FF26C6DA + #FF052329 + #FF183A9C + #FFD0E4FF + #FF3DDC84 + #FF061B0E + #FFFF6E6E + #FF3B080A + #FF4A151B + #FF131314 + #FFFFFFFF + #FF22262B + #FFC4C7C5 + #FF01AF57 + #FF2A3038 + #FF1E2226 diff --git a/jetpacker/android/app/src/main/res/values/colors.xml b/jetpacker/android/app/src/main/res/values/colors.xml index 19351f15..effca8bd 100644 --- a/jetpacker/android/app/src/main/res/values/colors.xml +++ b/jetpacker/android/app/src/main/res/values/colors.xml @@ -23,5 +23,27 @@ #FF018786 #FF000000 #FFFFFFFF - #FCF8E5 - \ No newline at end of file + #131314 + + + #FF01AF57 + #FF061B0E + #FF0A3D22 + #FFB9F6CA + #FF26C6DA + #FF052329 + #FF183A9C + #FFD0E4FF + #FF3DDC84 + #FF061B0E + #FFFF6E6E + #FF3B080A + #FF4A151B + #FF131314 + #FFFFFFFF + #FF22262B + #FFC4C7C5 + #FF01AF57 + #FF2A3038 + #FF1E2226 + diff --git a/jetpacker/android/app/src/main/res/values/themes.xml b/jetpacker/android/app/src/main/res/values/themes.xml index b572d416..2f525058 100644 --- a/jetpacker/android/app/src/main/res/values/themes.xml +++ b/jetpacker/android/app/src/main/res/values/themes.xml @@ -15,7 +15,7 @@ --> - diff --git a/jetpacker/android/app/src/main/res/xml/file_paths.xml b/jetpacker/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..3b8f328f --- /dev/null +++ b/jetpacker/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,20 @@ + + + + + + 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..5f928138 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,66 @@ 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" + + const val KEY_BOOKING_BACKEND_MODE = "booking_backend_mode" + const val BOOKING_BACKEND_MODE_MOCK = "mock" + const val BOOKING_BACKEND_MODE_LIVE = "live" + + const val KEY_BACKEND_URL = "backend_url" + const val CLOUD_RUN_BACKEND_URL = + "https://jetpacker-server-348872485807.us-central1.run.app" + const val LOCAL_BACKEND_URL = "http://localhost:8000" + const val DEFAULT_BACKEND_URL = CLOUD_RUN_BACKEND_URL 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_DEMO_BOOTH) + } + } + + 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) + .putString(KEY_BOOKING_BACKEND_MODE, BOOKING_BACKEND_MODE_LIVE) + .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) + .putString(KEY_BOOKING_BACKEND_MODE, BOOKING_BACKEND_MODE_LIVE) + .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,8 +113,11 @@ object FeatureFlags { .getString(keyName, defaultValue) ?: defaultValue } + val DEMO_PRESET: String + get() = readStringFlag(KEY_DEMO_PRESET, PRESET_DEMO_BOOTH) + // 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) @@ -76,6 +139,9 @@ object FeatureFlags { val ENABLE_VOICE_NOTES: Boolean get() = readFlag(KEY_ENABLE_VOICE_NOTES, true) + val ENABLE_DOCUMENTS: Boolean + get() = readFlag(KEY_ENABLE_DOCUMENTS, true) + // Online features val ENABLE_REVIEW_GENERATION: Boolean get() = readFlag(KEY_ENABLE_REVIEW_GENERATION, true) @@ -92,6 +158,15 @@ object FeatureFlags { val ENABLE_BOOKING_ASSISTANT: Boolean get() = readFlag(KEY_ENABLE_BOOKING_ASSISTANT, true) + val BOOKING_BACKEND_MODE: String + get() = readStringFlag(KEY_BOOKING_BACKEND_MODE, BOOKING_BACKEND_MODE_LIVE) + + val BACKEND_URL: String + get() = readStringFlag(KEY_BACKEND_URL, DEFAULT_BACKEND_URL) + + 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 return context @@ -110,6 +185,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/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Color.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Color.kt index 328a6b86..29bb2f5b 100644 --- a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Color.kt +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Color.kt @@ -16,83 +16,99 @@ package com.example.jetpacker.core.ui +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor -val primaryLight = Color(0xFF72DAEC) -val onPrimaryLight = Color(0xFFF1F2F3F) -val primaryContainerLight = Color(0xFF72DAEC) -val onPrimaryContainerLight = Color(0xFF1F2F3F) -val secondaryLight = Color(0xFF306DAC) -val onSecondaryLight = Color(0xFFEDF1FF) -val secondaryContainerLight = Color(0xFF306DAC) -val onSecondaryContainerLight = Color(0xFFEDF1FF) -val tertiaryLight = Color(0xFFFF5314) -val onTertiaryLight = Color(0xFFfcf8e5) -val tertiaryContainerLight = Color(0xFFFF5314) -val onTertiaryContainerLight = Color(0xFFfcf8e5) -val errorLight = Color(0xFFB51A25) -val onErrorLight = Color(0xFFFFFFFF) -val errorContainerLight = Color(0xFFd8373b) -val onErrorContainerLight = Color(0xFFfffbff) -val backgroundLight = Color(0xFFf6fafb) -val onBackgroundLight = Color(0xFF171c1d) -val surfaceLight = Color(0xFFFCF8E5) -val onSurfaceLight = Color(0xFF1f2f3f) -val surfaceVariantLight = Color(0xFFe6e2d7) -val onSurfaceVariantLight = Color(0xFF48473f) -val outlineLight = Color(0xFF1f2f3f) -val outlineVariantLight = Color(0xFFb0c4d8) +// Android Dev Summit brand & accent tokens +val AdsBackground = Color(0xFF131314) +val AdsGreen = Color(0xFF01AF57) +val AdsGreenBright = Color(0xFF3DDC84) +val AdsPink = Color(0xFFFDADEE) +val AdsBlue = Color(0xFF183A9C) +val AdsCyan = Color(0xFF26C6DA) +val AdsIconBadgeLight = Color(0xFFEEEEEE) +val AdsIconBadgeDark = Color(0xFF131314) + +val AdsBorderBrush: Brush = SolidColor(AdsBlue) +val AdsToolbarBorderBrush: Brush = SolidColor(AdsGreen) +val AdsLoadingBorderBrush: Brush = Brush.horizontalGradient(listOf(AdsBlue, AdsGreen)) +val AdsGlowColors = listOf(AdsGreen, AdsCyan, AdsBlue) + +val primaryLight = Color(0xFF01AF57) +val onPrimaryLight = Color(0xFF061B0E) +val primaryContainerLight = Color(0xFF0B3820) +val onPrimaryContainerLight = Color(0xFF6FF9B4) +val secondaryLight = Color(0xFF26C6DA) +val onSecondaryLight = Color(0xFF062024) +val secondaryContainerLight = Color(0xFF102E38) +val onSecondaryContainerLight = Color(0xFF9CEFFF) +val tertiaryLight = Color(0xFF3DDC84) +val onTertiaryLight = Color(0xFF061B0E) +val tertiaryContainerLight = Color(0xFF0D3B23) +val onTertiaryContainerLight = Color(0xFF8CF5B7) +val errorLight = Color(0xFFFF897D) +val onErrorLight = Color(0xFF4C0002) +val errorContainerLight = Color(0xFF3B1518) +val onErrorContainerLight = Color(0xFFFFB4AB) +val backgroundLight = Color(0xFF131314) +val onBackgroundLight = Color(0xFFF1F3F4) +val surfaceLight = Color(0xFF131314) +val onSurfaceLight = Color(0xFFF1F3F4) +val surfaceVariantLight = Color(0xFF23272C) +val onSurfaceVariantLight = Color(0xFFA8AEB5) +val outlineLight = AdsBlue +val outlineVariantLight = Color(0xFF2C333A) val scrimLight = Color(0xFF000000) -val inverseSurfaceLight = Color(0xFF1f2f3f) -val inverseOnSurfaceLight = Color(0xFFf4f0ed) -val inversePrimaryLight = Color(0xFF6dd5e7) -val surfaceDimLight = Color(0xFF72daec) -val surfaceBrightLight = Color(0xFF72daec) -val surfaceContainerLowestLight = Color(0xFFFFFFFF) -val surfaceContainerLowLight = Color(0xFFe8dff) -val surfaceContainerLight = Color(0xFFedf1ff) -val surfaceContainerHighLight = Color(0xFFd1dbff) -val surfaceContainerHighestLight = Color(0xFFc6d2ff) +val inverseSurfaceLight = Color(0xFFEEEEEE) +val inverseOnSurfaceLight = Color(0xFF131314) +val inversePrimaryLight = Color(0xFF006D35) +val surfaceDimLight = Color(0xFF0E0F10) +val surfaceBrightLight = Color(0xFF2C3137) +val surfaceContainerLowestLight = Color(0xFF0D0E10) +val surfaceContainerLowLight = Color(0xFF181A1D) +val surfaceContainerLight = Color(0xFF1D2024) +val surfaceContainerHighLight = Color(0xFF25292E) +val surfaceContainerHighestLight = Color(0xFF2E3339) -val primaryDark = Color(0xFF044B57) -val onPrimaryDark = Color(0xFFFAFEFF) -val primaryContainerDark = Color(0xFF72DAEC) -val onPrimaryContainerDark = Color(0xFF005f6a) -val secondaryDark = Color(0xFFa1d0ff) -val onSecondaryDark = Color(0xFF00325A) -val secondaryContainerDark = Color(0xFF306DAC) -val onSecondaryContainerDark = Color(0xFFA1D0FF) -val tertiaryDark = Color(0xFFFFB59F) -val onTertiaryDark = Color(0xFF5E1700) -val tertiaryContainerDark = Color(0xFFF47549FDC2B1) -val onTertiaryContainerDark = Color(0xFF5E1700) -val errorDark = Color(0xFFFFB3AE) -val onErrorDark = Color(0xFF68000C) -val errorContainerDark = Color(0xFFFF5353) -val onErrorContainerDark = Color(0xFF5B0009) -val backgroundDark = Color(0xFF0F1415) -val onBackgroundDark = Color(0xFFDFE3E4) -val surfaceDark = Color(0xFF3C392B) -val onSurfaceDark = Color(0xFFE6E2DF) -val surfaceVariantDark = Color(0xFF48473F) -val onSurfaceVariantDark = Color(0xFFCAC6BC) -val outlineDark = Color(0xFF939187) -val outlineVariantDark = Color(0xFF48473F) +val primaryDark = Color(0xFF01AF57) +val onPrimaryDark = Color(0xFF061B0E) +val primaryContainerDark = Color(0xFF0B3820) +val onPrimaryContainerDark = Color(0xFF6FF9B4) +val secondaryDark = Color(0xFF26C6DA) +val onSecondaryDark = Color(0xFF062024) +val secondaryContainerDark = Color(0xFF102E38) +val onSecondaryContainerDark = Color(0xFF9CEFFF) +val tertiaryDark = Color(0xFF3DDC84) +val onTertiaryDark = Color(0xFF061B0E) +val tertiaryContainerDark = Color(0xFF0D3B23) +val onTertiaryContainerDark = Color(0xFF8CF5B7) +val errorDark = Color(0xFFFF897D) +val onErrorDark = Color(0xFF4C0002) +val errorContainerDark = Color(0xFF3B1518) +val onErrorContainerDark = Color(0xFFFFB4AB) +val backgroundDark = Color(0xFF131314) +val onBackgroundDark = Color(0xFFF1F3F4) +val surfaceDark = Color(0xFF131314) +val onSurfaceDark = Color(0xFFF1F3F4) +val surfaceVariantDark = Color(0xFF23272C) +val onSurfaceVariantDark = Color(0xFFA8AEB5) +val outlineDark = AdsBlue +val outlineVariantDark = Color(0xFF2C333A) val scrimDark = Color(0xFF000000) -val inverseSurfaceDark = Color(0xFFA2CCF7) -val inverseOnSurfaceDark = Color(0xFF31302F) -val inversePrimaryDark = Color(0xFF006875) -val surfaceDimDark = Color(0xFF121214) -val surfaceBrightDark = Color(0xFF37383a) -val surfaceContainerLowestDark = Color(0xFF0d0d0f) -val surfaceContainerLowDark = Color(0xFF1A1A1C) -val surfaceContainerDark = Color(0xFF1E1E20) -val surfaceContainerHighDark = Color(0xFF28292B) -val surfaceContainerHighestDark = Color(0xFF333436) +val inverseSurfaceDark = Color(0xFFEEEEEE) +val inverseOnSurfaceDark = Color(0xFF131314) +val inversePrimaryDark = Color(0xFF006D35) +val surfaceDimDark = Color(0xFF0E0F10) +val surfaceBrightDark = Color(0xFF2C3137) +val surfaceContainerLowestDark = Color(0xFF0D0E10) +val surfaceContainerLowDark = Color(0xFF181A1D) +val surfaceContainerDark = Color(0xFF1D2024) +val surfaceContainerHighDark = Color(0xFF25292E) +val surfaceContainerHighestDark = Color(0xFF2E3339) // Semantic colors -val StarRatingGold = Color(0xFFFFB300) -val ShimmerPlaceholderGray = Color(0xFFE0E0E0) -val TimelineNodeBorderGreen = Color(0xFFDDF8D8) -val TimelineAudioBorderBlue = Color(0xFF299EAF) - +val StarRatingGold = Color(0xFFFFC107) +val ShimmerPlaceholderGray = Color(0xFF2E3339) +val TimelineNodeBorderGreen = Color(0xFF0B3820) +val TimelineAudioBorderBlue = Color(0xFFFDADEE) diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/EventColors.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/EventColors.kt index 979f9edd..583a85a8 100644 --- a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/EventColors.kt +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/EventColors.kt @@ -21,11 +21,11 @@ import androidx.compose.ui.graphics.Color data class EventColor(val container: Color, val content: Color) object EventColors { - val Flight = EventColor(container = Color(0xFFF3E5F5), content = Color(0xFF8523C2)) - val Hotel = EventColor(container = Color(0xFFE3F2FD), content = Color(0xFF1976D2)) - val Activity = EventColor(container = Color(0xFFFFF3E0), content = Color(0xFFE65100)) - val Museum = EventColor(container = Color(0xFFFFF3E0), content = Color(0xFF8BC34A)) - val Food = EventColor(container = Color(0xFFE8F5E9), content = Color(0xFF2E7D32)) - val Shopping = EventColor(container = Color(0xFFFCE4EC), content = Color(0xFFC2185B)) - val Entertainment = EventColor(container = Color(0xFFFFFDE7), content = Color(0xFFFBC02D)) + val Flight = EventColor(container = Color(0xFF102A4C), content = Color(0xFF60A5FA)) + val Hotel = EventColor(container = Color(0xFF0D333B), content = Color(0xFF26C6DA)) + val Activity = EventColor(container = Color(0xFF0B3820), content = Color(0xFF3DDC84)) + val Museum = EventColor(container = Color(0xFF12362C), content = Color(0xFF38D9A9)) + val Food = EventColor(container = Color(0xFF0A3622), content = Color(0xFF01AF57)) + val Shopping = EventColor(container = Color(0xFF3E1F38), content = Color(0xFFFDADEE)) + val Entertainment = EventColor(container = Color(0xFF241B4B), content = Color(0xFFA78BFA)) } diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Theme.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Theme.kt index ce1a01b2..9f5eb893 100644 --- a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Theme.kt +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/Theme.kt @@ -102,7 +102,7 @@ private val LightColorScheme = @Composable fun JetPackerTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + darkTheme: Boolean = true, content: @Composable () -> Unit, ) { val colorScheme = diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerExtendedFloatingActionButton.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerExtendedFloatingActionButton.kt index 229c75b9..18a8b7da 100644 --- a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerExtendedFloatingActionButton.kt +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerExtendedFloatingActionButton.kt @@ -61,6 +61,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.ui.AdsGreen +import com.example.jetpacker.core.ui.AdsGreenBright import com.example.jetpacker.core.ui.JetPackerTheme @Composable @@ -74,19 +76,18 @@ fun JetPackerExtendedFloatingActionButton( trailingContent: @Composable (RowScope.() -> Unit)? = null, ) { val shape = CircleShape - val inverseSurface = MaterialTheme.colorScheme.inverseSurface Row( modifier = modifier .dropShadow(shape = shape) { - radius = 0f + radius = 16.dp.toPx() spread = 0f - offset = Offset(x = 2.dp.toPx(), y = 3.dp.toPx()) - color = inverseSurface + offset = Offset(x = 0f, y = 4.dp.toPx()) + color = AdsGreen.copy(alpha = 0.35f) } .background(MaterialTheme.colorScheme.primary, shape) - .border(1.dp, MaterialTheme.colorScheme.onSurfaceVariant, shape) + .border(1.dp, AdsGreenBright.copy(alpha = 0.45f), shape) .clip(shape) .padding(horizontal = 8.dp, vertical = 12.dp) .height(IntrinsicSize.Min), diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerFab.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerFab.kt index 0535f535..0e11c55d 100644 --- a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerFab.kt +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerFab.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.ui.AdsToolbarBorderBrush import com.example.jetpacker.core.ui.JetPackerTheme @Composable @@ -51,22 +52,22 @@ fun JetPackerFab( icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, - containerColor: Color = MaterialTheme.colorScheme.tertiary, - contentColor: Color = MaterialTheme.colorScheme.onTertiary, + containerColor: Color = MaterialTheme.colorScheme.primary, + contentColor: Color = MaterialTheme.colorScheme.onPrimary, ) { val shape = RoundedCornerShape(16.dp) Box( modifier = modifier .sizeIn(minWidth = 56.dp, minHeight = 56.dp) - .border(1.dp, MaterialTheme.colorScheme.onSurface, shape = shape) .dropShadow(shape = shape) { - radius = 0f + radius = 12.dp.toPx() spread = 0f - offset = Offset(x = 2.dp.toPx(), y = 3.dp.toPx()) - color = Color(0xFF20290A) + offset = Offset(x = 0f, y = 4.dp.toPx()) + color = Color.Black.copy(alpha = 0.5f) } .background(containerColor, shape) + .border(1.5.dp, AdsToolbarBorderBrush, shape = shape) .clip(shape) .clickable { onClick() }, contentAlignment = Alignment.Center, diff --git a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerToolbar.kt b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerToolbar.kt index acca5fd4..d23ab8fb 100644 --- a/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerToolbar.kt +++ b/jetpacker/android/core/ui/src/main/kotlin/com/example/jetpacker/core/ui/components/JetPackerToolbar.kt @@ -20,6 +20,7 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateOffsetAsState import androidx.compose.animation.core.animateSizeAsState import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -62,6 +63,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.ui.AdsToolbarBorderBrush import com.example.jetpacker.core.ui.JetPackerTheme import kotlin.math.roundToInt @@ -92,17 +94,12 @@ fun JetPackerToolbar(modifier: Modifier = Modifier, content: @Composable RowScop modifier = modifier .dropShadow(shape = shape) { - radius = 3.dp.toPx() - offset = Offset(0f, 1.dp.toPx()) - color = Color.Black.copy(alpha = 0.3f) - } - .dropShadow(shape = shape) { - radius = 8.dp.toPx() + radius = 12.dp.toPx() offset = Offset(0f, 4.dp.toPx()) - spread = 3.dp.toPx() - color = Color.Black.copy(alpha = 0.15f) + color = Color.Black.copy(alpha = 0.55f) } - .background(MaterialTheme.colorScheme.onSurface, shape) + .background(MaterialTheme.colorScheme.surfaceContainerLow, shape) + .border(1.5.dp, AdsToolbarBorderBrush, shape) .onGloballyPositioned { state.toolbarCoords = it } ) { if (state.selectedBounds != null) { @@ -144,8 +141,8 @@ fun JetPackerToolbarAction( val contentColor by animateColorAsState( targetValue = - if (selected) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.surfaceContainer, + if (selected) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant, label = "contentColor", ) 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..5da35935 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,10 +18,13 @@ 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 import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager import com.example.jetpacker.data.trips.TripDao import dagger.Module import dagger.Provides @@ -66,4 +69,23 @@ object DatabaseModule { fun provideExpenseDao(db: JetPackerDatabase): ExpenseDao { return db.expenseDao() } + + @Provides + fun provideTravelDocumentDao(db: JetPackerDatabase): TravelDocumentDao { + return db.travelDocumentDao() + } + + @Provides + fun provideAssistantConversationDao(db: JetPackerDatabase): AssistantConversationDao { + return db.assistantConversationDao() + } + + @Provides + @Singleton + fun provideTravelDocumentManager( + @ApplicationContext context: Context, + travelDocumentDao: TravelDocumentDao, + ): TravelDocumentManager { + return TravelDocumentManager(context, travelDocumentDao) + } } 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..820d65d5 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 @@ -31,6 +33,8 @@ import com.example.jetpacker.data.itinerary.MuseumDetail import com.example.jetpacker.data.itinerary.TimelineEvent import com.example.jetpacker.data.itinerary.TourDetail import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao import com.example.jetpacker.data.itinerary.VoiceNoteEntity import com.example.jetpacker.data.trips.Trip import com.example.jetpacker.data.trips.TripDao @@ -49,8 +53,10 @@ import com.example.jetpacker.data.trips.TripDao MuseumDetail::class, Expense::class, VoiceNoteEntity::class, + TravelDocument::class, + AssistantConversation::class, ], - version = 1, + version = 4, exportSchema = false, ) abstract class JetPackerDatabase : RoomDatabase() { @@ -63,4 +69,8 @@ abstract class JetPackerDatabase : RoomDatabase() { abstract fun dayThemeDao(): DayThemeDao abstract fun expenseDao(): ExpenseDao + + abstract fun travelDocumentDao(): TravelDocumentDao + + 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..ae60f292 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/AssistantConversationDao.kt @@ -0,0 +1,57 @@ +/* + * 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) + + @Query("DELETE FROM assistant_conversations") + suspend fun deleteAllConversations() +} diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DocumentSemanticSearchProvider.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DocumentSemanticSearchProvider.kt new file mode 100644 index 00000000..4fc183bb --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/DocumentSemanticSearchProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +/** + * Provider interface for on-device semantic document search using ML Kit Embedding API + * (Embedding Gemma v2) and Jetpack AppSearch hybrid vector retrieval. + */ +interface DocumentSemanticSearchProvider { + /** + * Finds travel documents semantically relevant to [query]. + * + * @param query The natural language user query. + * @param tripId Optional trip ID filter. + * @param maxResults Maximum number of documents to return. + */ + suspend fun findRelevantDocuments( + query: String, + tripId: String? = null, + maxResults: Int = 10, + ): List + + /** Clears any cached search indexes, vectors, or metadata. */ + suspend fun clearIndex() {} + + /** Synchronizes the search index with current stored documents. */ + suspend fun syncIndex() {} +} 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/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocument.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocument.kt new file mode 100644 index 00000000..098136a4 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocument.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.data.itinerary + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.UUID + +/** Room entity representing a travel document such as a passport or visa. */ +@Entity(tableName = "travel_documents") +data class TravelDocument( + @PrimaryKey @JvmField val id: String = UUID.randomUUID().toString(), + @JvmField val documentId: String = "", + @JvmField val documentType: String = "PASSPORT", + @JvmField val issuingCountry: String = "", + @JvmField val expiryDate: Long = 0L, + @JvmField val documentNumberMasked: String = "", + @JvmField val notes: String = "", + @JvmField val title: String = "", + @JvmField val filePath: String = "", + @JvmField val mimeType: String = "", + @JvmField val tripId: String? = null, + @JvmField val eventId: String? = null, +) diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentDao.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentDao.kt new file mode 100644 index 00000000..b9046c52 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentDao.kt @@ -0,0 +1,64 @@ +/* + * 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 travel documents such as passports and visas. */ +@Dao +interface TravelDocumentDao { + @Query("SELECT * FROM travel_documents ORDER BY expiryDate ASC") + fun getAllDocuments(): Flow> + + @Query( + "SELECT * FROM travel_documents WHERE UPPER(documentType) = UPPER(:documentType) ORDER BY expiryDate ASC" + ) + fun getDocumentsByType(documentType: String): Flow> + + @Query("SELECT * FROM travel_documents WHERE tripId = :tripId ORDER BY expiryDate ASC") + fun getDocumentsForTrip(tripId: String): Flow> + + @Query("SELECT * FROM travel_documents WHERE eventId = :eventId ORDER BY expiryDate ASC") + fun getDocumentsForEvent(eventId: String): Flow> + + @Query("SELECT * FROM travel_documents WHERE documentId = :documentId OR id = :documentId LIMIT 1") + suspend fun getDocumentById(documentId: String): TravelDocument? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDocument(document: TravelDocument) + + @Query( + "UPDATE travel_documents SET eventId = :eventId, tripId = CASE WHEN :tripId IS NOT NULL THEN :tripId ELSE tripId END WHERE documentId = :documentId OR id = :documentId" + ) + suspend fun linkDocumentToEvent(documentId: String, eventId: String?, tripId: String? = null) + + @Query( + "UPDATE travel_documents SET tripId = :tripId WHERE documentId = :documentId OR id = :documentId" + ) + suspend fun linkDocumentToTrip(documentId: String, tripId: String?) + + @Delete suspend fun deleteDocument(document: TravelDocument) + + @Query("DELETE FROM travel_documents WHERE id = :id") suspend fun deleteDocumentById(id: String) + + @Query("DELETE FROM travel_documents") suspend fun deleteAllDocuments() +} diff --git a/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentManager.kt b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentManager.kt new file mode 100644 index 00000000..b0552229 --- /dev/null +++ b/jetpacker/android/data/itinerary/src/main/kotlin/com/example/jetpacker/data/itinerary/TravelDocumentManager.kt @@ -0,0 +1,2406 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("GlobalCoroutineDispatchers") + +package com.example.jetpacker.data.itinerary + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.graphics.pdf.PdfRenderer +import android.media.ExifInterface +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.OpenableColumns +import android.util.Log +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.nio.charset.StandardCharsets +import java.time.Instant +import java.util.Base64 +import java.util.Locale +import java.util.UUID +import java.util.zip.Inflater +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +/** Manages travel documents, local app-private file storage, and default seed documents. */ +open class TravelDocumentManager( + private val context: Context, + private val travelDocumentDao: TravelDocumentDao, +) { + + private val documentsDir: File by lazy { + File(context.filesDir, "travel_documents").apply { mkdirs() } + } + + val documents: Flow> = travelDocumentDao.getAllDocuments() + + /** + * Optional semantic search provider using on-device embedding models (Embedding Gemma v2) + * and AppSearch vector indexing. + */ + @Volatile + var semanticSearchProvider: DocumentSemanticSearchProvider? = null + + /** + * Executes a semantic search across travel documents using the registered [semanticSearchProvider]. + */ + open suspend fun searchDocumentsSemantically( + query: String, + tripId: String? = null, + maxResults: Int = 10, + ): List { + val provider = semanticSearchProvider ?: return emptyList() + return try { + provider.findRelevantDocuments(query, tripId, maxResults) + } catch (e: Exception) { + Log.w(TAG, "Semantic document search failed for query: $query", e) + emptyList() + } + } + + /** + * Imports a document from an external or shared [Uri] into app-private storage and saves it to + * Room. + */ + open suspend fun importDocumentFromUri( + uri: Uri, + overrideTitle: String? = null, + documentType: String = "OTHER", + issuingCountry: String = "", + expiryDate: Long = 0L, + documentNumberMasked: String = "", + notes: String = "", + tripId: String? = null, + eventId: String? = null, + ): TravelDocument = + withContext(Dispatchers.IO) { + val rawFilename = queryFilename(uri) ?: "document_${System.currentTimeMillis()}" + val originalFilename = File(rawFilename).name.replace(Regex("""[^a-zA-Z0-9._-]"""), "_") + val mimeType = + context.contentResolver.getType(uri) ?: getMimeTypeFromFilename(originalFilename) + val safePrefix = UUID.randomUUID().toString().take(8) + val destFile = File(documentsDir, "${safePrefix}_$originalFilename") + check(destFile.canonicalFile.startsWith(documentsDir.canonicalFile)) { + "Invalid destination file path" + } + + var copySuccess = false + try { + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(destFile).use { output -> + val bytes = input.copyTo(output) + copySuccess = bytes > 0L || destFile.exists() + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to copy URI stream to private file", e) + } + + if (!copySuccess || !destFile.exists()) { + destFile.delete() + error("Failed to copy document from URI: $uri") + } + + val extractedText = extractTextFromFile(destFile, mimeType) + val emailSubject = + if (isEmailMimeOrExt(mimeType, destFile.extension)) { + runCatching { parseEmailContent(destFile.readText()).subject }.getOrNull() + } else { + null + } + + val finalTitle = + overrideTitle?.takeIf { it.isNotBlank() } + ?: emailSubject?.takeIf { it.isNotBlank() } + ?: originalFilename + val resolvedType = + if (documentType == "OTHER") { + detectDocumentType(originalFilename, destFile, extractedText) + } else { + documentType + } + val resolvedNotes = notes.ifBlank { + extractedText?.take(MAX_AUTO_NOTES_CHARS)?.trim().orEmpty() + } + + val doc = + TravelDocument( + documentId = "doc_${UUID.randomUUID().toString().take(8)}", + documentType = resolvedType, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + notes = resolvedNotes, + title = finalTitle, + filePath = destFile.absolutePath, + mimeType = mimeType, + tripId = tripId, + eventId = eventId, + ) + + travelDocumentDao.insertDocument(doc) + doc + } + + /** + * Creates an in-app text document (e.g. itinerary notes, passport text, booking voucher, or + * shared email) in private storage. + */ + open suspend fun createTextDocument( + title: String, + content: String, + documentType: String = "OTHER", + issuingCountry: String = "", + expiryDate: Long = 0L, + documentNumberMasked: String = "", + notes: String = "", + tripId: String? = null, + eventId: String? = null, + ): TravelDocument = + withContext(Dispatchers.IO) { + val parsedEmail = + if (looksLikeEmailOrHtml(content)) { + parseEmailContent(content) + } else { + null + } + val cleanedContent = parsedEmail?.formattedSummary()?.ifBlank { content } ?: content + val resolvedTitle = + if ( + (title.isBlank() || title.equals("Shared Document", ignoreCase = true)) && + !parsedEmail?.subject.isNullOrBlank() + ) { + parsedEmail?.subject ?: title.ifBlank { "Untitled Document" } + } else { + title.ifBlank { "Untitled Document" } + } + + val safePrefix = UUID.randomUUID().toString().take(8) + val safeName = resolvedTitle.replace(Regex("""[^a-zA-Z0-9_-]"""), "_").take(32) + val file = File(documentsDir, "${safePrefix}_${safeName}.txt") + + try { + file.writeText(cleanedContent) + } catch (e: Exception) { + Log.e(TAG, "Failed to write text document to file", e) + } + + val resolvedType = + if (documentType == "OTHER") { + detectDocumentType(resolvedTitle, file, cleanedContent) + } else { + documentType + } + val resolvedNotes = notes.ifBlank { cleanedContent.take(MAX_AUTO_NOTES_CHARS).trim() } + + val doc = + TravelDocument( + documentId = "doc_${UUID.randomUUID().toString().take(8)}", + documentType = resolvedType, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + notes = resolvedNotes, + title = resolvedTitle, + filePath = file.absolutePath, + mimeType = "text/plain", + tripId = tripId, + eventId = eventId, + ) + + travelDocumentDao.insertDocument(doc) + doc + } + + /** + * Saves a document bitmap (e.g. from camera capture) into private app storage and inserts Room + * metadata. + */ + open suspend fun saveDocumentBitmap( + bitmap: Bitmap, + title: String, + documentType: String = "OTHER", + issuingCountry: String = "", + expiryDate: Long = 0L, + documentNumberMasked: String = "", + notes: String = "", + tripId: String? = null, + eventId: String? = null, + ): TravelDocument = + withContext(Dispatchers.IO) { + val safePrefix = UUID.randomUUID().toString().take(8) + val safeName = title.replace(Regex("""[^a-zA-Z0-9_-]"""), "_").take(32).ifBlank { "scan" } + val file = File(documentsDir, "${safePrefix}_${safeName}.jpg") + + try { + FileOutputStream(file).use { out -> bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) } + } catch (e: Exception) { + Log.e(TAG, "Failed to write bitmap document to file", e) + } + + val doc = + TravelDocument( + documentId = "doc_${UUID.randomUUID().toString().take(8)}", + documentType = documentType, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + notes = notes, + title = title, + filePath = file.absolutePath, + mimeType = "image/jpeg", + tripId = tripId, + eventId = eventId, + ) + + travelDocumentDao.insertDocument(doc) + doc + } + + /** + * Decodes a high-resolution bitmap from an image [Uri], scaling down large images to + * [maxDimension] to prevent OOM errors while preserving fine print for vision models, and + * correcting EXIF rotation. + */ + open suspend fun loadBitmapFromUri(uri: Uri, maxDimension: Int = 1920): Bitmap? = + withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Loading bitmap from uri: $uri") + val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } + val streamOpened = + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, boundsOptions) + true + } ?: false + + if (!streamOpened || boundsOptions.outWidth <= 0 || boundsOptions.outHeight <= 0) { + Log.e( + TAG, + "Failed to read image bounds for $uri: streamOpened=$streamOpened, w=${boundsOptions.outWidth}, h=${boundsOptions.outHeight}", + ) + return@withContext null + } + + Log.d( + TAG, + "Original image dimensions: ${boundsOptions.outWidth}x${boundsOptions.outHeight}", + ) + + var sampleSize = 1 + val maxSide = maxOf(boundsOptions.outWidth, boundsOptions.outHeight) + if (maxSide > maxDimension) { + while ((maxSide / (sampleSize * 2)) >= maxDimension) { + sampleSize *= 2 + } + } + + val decodeOptions = + BitmapFactory.Options().apply { + inSampleSize = sampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + val decoded = + context.contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, decodeOptions) + } + + if (decoded == null) { + Log.e(TAG, "BitmapFactory failed to decode stream for $uri with sampleSize=$sampleSize") + return@withContext null + } + + Log.d( + TAG, + "Decoded bitmap size: ${decoded.width}x${decoded.height}, sampleSize=$sampleSize", + ) + + val orientation = + try { + context.contentResolver.openInputStream(uri)?.use { stream -> + val exif = ExifInterface(stream) + exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + } ?: ExifInterface.ORIENTATION_NORMAL + } catch (e: Exception) { + Log.w(TAG, "Could not read EXIF orientation from $uri", e) + ExifInterface.ORIENTATION_NORMAL + } + + val rotationDegrees = + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90f + ExifInterface.ORIENTATION_ROTATE_180 -> 180f + ExifInterface.ORIENTATION_ROTATE_270 -> 270f + else -> 0f + } + + if (rotationDegrees != 0f) { + Log.d(TAG, "Rotating bitmap by $rotationDegrees degrees based on EXIF") + val matrix = Matrix().apply { postRotate(rotationDegrees) } + val rotated = + Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true) + if (rotated != decoded) { + decoded.recycle() + } + rotated + } else { + decoded + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load bitmap from uri: $uri", e) + null + } + } + + open fun getDocumentsForTrip(tripId: String): Flow> = + travelDocumentDao.getDocumentsForTrip(tripId) + + open fun getDocumentsForEvent(eventId: String): Flow> = + travelDocumentDao.getDocumentsForEvent(eventId) + + open suspend fun linkDocumentToEvent( + documentId: String, + eventId: String?, + tripId: String? = null, + ) = + withContext(Dispatchers.IO) { + travelDocumentDao.linkDocumentToEvent(documentId, eventId, tripId) + } + + open suspend fun linkDocumentToTrip(documentId: String, tripId: String?) = + withContext(Dispatchers.IO) { travelDocumentDao.linkDocumentToTrip(documentId, tripId) } + + open suspend fun updateDocument(document: TravelDocument) = + withContext(Dispatchers.IO) { travelDocumentDao.insertDocument(document) } + + /** Returns true when [document] is backed by a raster image file. */ + open fun isImageDocument(document: TravelDocument): Boolean { + val ext = document.filePath.substringAfterLast('.', "").lowercase() + return document.mimeType.startsWith("image/", ignoreCase = true) || + ext in setOf("png", "jpg", "jpeg", "webp") + } + + /** Returns true when [document] is backed by a PDF file. */ + open fun isPdfDocument(document: TravelDocument): Boolean { + val ext = document.filePath.substringAfterLast('.', "").lowercase() + return document.mimeType.equals("application/pdf", ignoreCase = true) || ext == "pdf" + } + + /** Returns true when [document] is an email file (.eml / message/rfc822). */ + open fun isEmailDocument(document: TravelDocument): Boolean { + val ext = document.filePath.substringAfterLast('.', "").lowercase() + return isEmailMimeOrExt(document.mimeType, ext) + } + + /** + * Returns true when [document] can be rendered into a [Bitmap] for multimodal embedding or Gemini + * Nano vision extraction (images and PDFs). + */ + open fun isVisualDocument(document: TravelDocument): Boolean = + document.filePath.isNotBlank() && (isImageDocument(document) || isPdfDocument(document)) + + /** + * Loads a [Bitmap] representation of [document] for multimodal embedding (`ImagePart`) or Gemini + * Nano vision scanning. + * - For image documents, decodes and downsamples the file to [maxDimension]. + * - For PDF documents, renders page 0 via Android's [PdfRenderer] onto a white canvas. + */ + open fun loadDocumentBitmap(document: TravelDocument, maxDimension: Int = 1024): Bitmap? { + if (document.filePath.isBlank()) return null + val file = File(document.filePath) + if (!file.exists() || !file.isFile) return null + return when { + isPdfDocument(document) -> + renderPdfPageToBitmap(file, pageIndex = 0, maxDimension = maxDimension) + isImageDocument(document) -> decodeFileBitmap(file, maxDimension = maxDimension) + else -> null + } + } + + /** + * Renders a single page of a PDF [file] into an [Bitmap.Config.ARGB_8888] [Bitmap] with a white + * background using Android's built-in [PdfRenderer]. + */ + open fun renderPdfPageToBitmap( + file: File, + pageIndex: Int = 0, + maxDimension: Int = 1024, + ): Bitmap? { + if (!file.exists() || !file.isFile) return null + return try { + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + PdfRenderer(pfd).use { renderer -> + if (renderer.pageCount <= pageIndex) return null + renderer.openPage(pageIndex).use { page -> + val rawW = page.width.coerceAtLeast(1) + val rawH = page.height.coerceAtLeast(1) + val maxSide = maxOf(rawW, rawH) + val scale = + if (maxSide > maxDimension) { + maxDimension.toFloat() / maxSide.toFloat() + } else { + // Upscale standard 72dpi PDF pages slightly for crisp OCR/embedding, capped at + // maxDimension. + minOf(2f, maxDimension.toFloat() / maxSide.toFloat()) + } + val targetW = (rawW * scale).toInt().coerceAtLeast(1) + val targetH = (rawH * scale).toInt().coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(targetW, targetH, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bitmap + } + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to render PDF page to bitmap: ${file.name}", e) + null + } + } + + private fun decodeFileBitmap(file: File, maxDimension: Int): Bitmap? { + return try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + return BitmapFactory.decodeFile(file.absolutePath) + } + var sampleSize = 1 + val maxSide = maxOf(bounds.outWidth, bounds.outHeight) + if (maxSide > maxDimension) { + while ((maxSide / (sampleSize * 2)) >= maxDimension) { + sampleSize *= 2 + } + } + val opts = + BitmapFactory.Options().apply { + inSampleSize = sampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + BitmapFactory.decodeFile(file.absolutePath, opts) + } catch (e: Exception) { + Log.w(TAG, "Failed to decode image file: ${file.name}", e) + null + } + } + + /** + * Builds a concise text representation of [document] suitable for on-device embedding models + * (~256 token window) and AppSearch full-text indexing. + * + * Combines metadata (`title`, `documentType`, `issuingCountry`, `documentNumberMasked`, `notes`) + * with extracted text from backing `.txt`, `.eml`, `.html`, or `.pdf` files when available. + */ + open fun readDocumentEmbeddingText(document: TravelDocument, maxChars: Int = 1200): String { + val fileText = + if (document.filePath.isNotBlank()) { + val file = File(document.filePath) + if (file.exists() && file.isFile) { + extractTextFromFile(file, document.mimeType) + } else { + null + } + } else { + null + } + + val combinedBody = + when { + fileText.isNullOrBlank() -> document.notes + document.notes.isBlank() -> fileText + fileText.startsWith(document.notes.take(80)) -> fileText + document.notes.contains(fileText.take(80)) -> document.notes + else -> "${document.notes} $fileText" + } + + return buildString { + append("${document.title} (${document.documentType}) ") + if (document.issuingCountry.isNotBlank()) { + append("Country: ${document.issuingCountry} ") + } + if (document.documentNumberMasked.isNotBlank()) { + append("ID: ${document.documentNumberMasked} ") + } + if (combinedBody.isNotBlank()) { + append(combinedBody) + } + } + .replace(Regex("""\s+"""), " ") + .trim() + .take(maxChars) + } + + /** Reads the content of a document file for inspection or model consumption. */ + open suspend fun readDocumentContent(document: TravelDocument): String = + withContext(Dispatchers.IO) { + val fileContent: String? = + if (document.filePath.isNotBlank()) { + val file = File(document.filePath) + if (file.exists() && file.isFile) { + extractTextFromFile(file, document.mimeType) + } else { + null + } + } else { + null + } + + buildString { + append("Document: ${document.title} (${document.documentType})\n") + if (document.issuingCountry.isNotBlank()) + append("Issuing Country: ${document.issuingCountry}\n") + if (document.documentNumberMasked.isNotBlank()) + append("Document Number: ${document.documentNumberMasked}\n") + if (document.expiryDate > 0L) { + try { + val expiry = Instant.ofEpochMilli(document.expiryDate).toString().substringBefore('T') + append("Expiry Date: $expiry\n") + } catch (_: Exception) {} + } + if (document.notes.isNotBlank()) append("Details/Notes:\n${document.notes}\n") + if (!fileContent.isNullOrBlank()) { + append("File Content:\n$fileContent\n") + } else if (document.filePath.isNotBlank()) { + val f = File(document.filePath) + if (f.exists()) { + append("Attached File: ${f.name} (${f.length()} bytes, MIME: ${document.mimeType})\n") + } + } + } + .trim() + } + + /** Deletes a document from both private storage and the Room database. */ + open suspend fun deleteDocument(document: TravelDocument) = + withContext(Dispatchers.IO) { + if (document.filePath.isNotBlank()) { + try { + val file = File(document.filePath) + if (file.exists()) file.delete() + } catch (e: Exception) { + Log.w(TAG, "Failed to delete file ${document.filePath}", e) + } + } + travelDocumentDao.deleteDocument(document) + } + + /** + * Pre-populates a diverse set of mock travel documents across all supported modalities (`.txt`, + * `.pdf`, `.eml`, and `.jpg`) on initial launch, and automatically upgrades legacy 3-file + * plain-text seed states. + */ + open suspend fun seedDefaultDocumentsIfEmpty() = + withContext(Dispatchers.IO) { + val existing = travelDocumentDao.getAllDocuments().first() + if (existing.isNotEmpty()) { + val hasMultimodalSeed = + existing.any { it.mimeType == "application/pdf" } && + existing.any { it.mimeType == "message/rfc822" } + val hasLegacySeed = existing.any { + it.title == "Passport (Netherlands)" || + it.title == "US ESTA Travel Authorization" || + it.title == "Flight Voucher - United Airlines UA 991" + } + if (!hasMultimodalSeed && hasLegacySeed) { + val legacyTitles = setOf( + "Passport (Netherlands)", + "US ESTA Travel Authorization", + "Flight Voucher - United Airlines UA 991", + ) + existing.filter { it.title in legacyTitles }.forEach { deleteDocument(it) } + seedDiverseMockDocuments() + runCatching { semanticSearchProvider?.syncIndex() } + } + return@withContext + } + seedDiverseMockDocuments() + } + + /** + * Clears all existing travel documents from Room, deletes their private storage files, + * and clears the semantic search index. + */ + open suspend fun clearAllDocuments() = + withContext(Dispatchers.IO) { + val existing = travelDocumentDao.getAllDocuments().first() + for (doc in existing) { + if (doc.filePath.isNotBlank()) { + runCatching { File(doc.filePath).takeIf { it.exists() }?.delete() } + } + } + runCatching { + documentsDir.listFiles()?.forEach { file -> + if (file.isFile) file.delete() + } + } + travelDocumentDao.deleteAllDocuments() + try { + semanticSearchProvider?.clearIndex() + } catch (e: Exception) { + Log.w(TAG, "Failed to clear semantic search index", e) + } + } + + /** + * Clears all existing travel documents and private files, then seeds the fresh 8-document + * multimodal mock set covering `.txt`, `.pdf`, `.eml`, and `.jpg` across demo trips. + */ + open suspend fun resetDefaultDocuments() = + withContext(Dispatchers.IO) { + clearAllDocuments() + seedDiverseMockDocuments() + try { + semanticSearchProvider?.syncIndex() + } catch (e: Exception) { + Log.w(TAG, "Failed to re-index documents after reset", e) + } + } + + private suspend fun seedDiverseMockDocuments() { + // 1. Global Text Document: Dutch Biometric Passport (.txt) + val passportContent = + """ + PASSPORT / PASSEPORT + Type: P + Issuing State: NLD (Kingdom of the Netherlands) + Passport No: ••••••4821 + Surname: CHEN + Given Names: ALEX + Nationality: NEDERLANDSE / DUTCH + Date of Birth: 12 AUG 1992 + Sex: F + Date of Issue: 15 NOV 2019 + Date of Expiry: 15 NOV 2029 + Authority: BURGEMEESTER VAN AMSTERDAM + Type: Biometric with electronic RFID chip. + Emergency Contact: Netherlands Ministry of Foreign Affairs +31 247 247 247 + """ + .trimIndent() + + createTextDocument( + title = "Passport (Netherlands)", + content = passportContent, + documentType = "PASSPORT", + issuingCountry = "Netherlands", + expiryDate = Instant.parse("2029-11-15T00:00:00Z").toEpochMilli(), + documentNumberMasked = "••••••4821", + notes = "Valid EU biometric travel passport", + ) + + // 2. Visa / Travel Authorization Text Document: US ESTA (.txt) + val estaContent = + """ + U.S. CUSTOMS AND BORDER PROTECTION + ELECTRONIC SYSTEM FOR TRAVEL AUTHORIZATION (ESTA) + Application Number: ESTA-77492109 + Status: AUTHORIZATION APPROVED + Passport Number: ••••••4821 + Issuing Country: Netherlands + Expiration Date: April 10, 2027 + Valid for multiple entries to the United States for business or pleasure for up to 90 days per visit. + Port of Entry: San Francisco International Airport (SFO) - Global Entry Lane Eligible + """ + .trimIndent() + + createTextDocument( + title = "US ESTA Travel Authorization", + content = estaContent, + documentType = "VISA", + issuingCountry = "United States", + expiryDate = Instant.parse("2027-04-10T00:00:00Z").toEpochMilli(), + documentNumberMasked = "ESTA-••••9012", + notes = "Approved ESTA waiver for US travel (Global Entry eligible)", + tripId = "2026-1", + eventId = null, + ) + + // 3. PDF Boarding Pass & E-Ticket: United Airlines UA 991 (.pdf) + createSeedPdfDocument( + filename = "UA991_Boarding_Pass_SFO.pdf", + title = "Flight Voucher - United Airlines UA 991", + lines = + listOf( + "UNITED AIRLINES - ELECTRONIC TICKET & BOARDING PASS", + "Confirmation Reference (PNR): KL8821UA", + "Passenger: ALEX CHEN (Premier Gold / Star Alliance Gold)", + "Flight: UA 991 - Boeing 777-300ER", + "Depart: CDG Paris Charles de Gaulle - Oct 27, 2026 @ 09:00 AM (Terminal 2E, Gate K42)", + "Arrive: SFO San Francisco Intl - Oct 27, 2026 @ 12:15 PM (Intl Terminal G, Gate G94)", + "Seat: 32B (Economy Plus - Extra Legroom Window)", + "Boarding Group: Group 1 (Premier Access)", + "Baggage Allowance: 2 checked bags (23kg each) + 1 carry-on + 1 personal item", + "Wi-Fi Voucher Code: UA-WIFI-883920", + ), + documentType = "TICKET", + issuingCountry = "United States", + expiryDate = Instant.parse("2026-11-05T00:00:00Z").toEpochMilli(), + documentNumberMasked = "UA991-KL8821UA", + notes = + "PDF E-Ticket & Boarding Pass • Flight UA 991 CDG->SFO • Gate K42 • Seat 32B • PNR KL8821UA • Baggage 2x23kg", + tripId = "2026-1", + eventId = "io_1", + ) + + // 4. Email Reservation (.eml with HTML): The Redwood Grove Hotel + val redwoodEmailHtml = + """ + From: "The Redwood Grove Hotel Reservations" + To: "Alex Chen" + Subject: Confirmation #RG-90421: The Redwood Grove Hotel (Oct 27 - Nov 1) + Date: Mon, 12 Oct 2026 14:22:00 -0700 + MIME-Version: 1.0 + Content-Type: text/html; charset="UTF-8" + + + +

The Redwood Grove Hotel — Mountain View

+

Dear Alex Chen, your reservation for Android Dev Summit '26 is confirmed.

+ + + + + + + + +
Confirmation Number:RG-90421-MV
Room Type:Room 412 — Deluxe King Suite (Redwood Courtyard View)
Check-in:Tuesday, October 27, 2026 after 3:00 PM (Digital Key enabled)
Check-out:Sunday, November 1, 2026 at 11:00 AM (Late check-out 1:00 PM approved)
Guest Wi-Fi:Network: RedwoodGuest / Passcode: SEQUOIA2026
Breakfast & Amenities:Complimentary breakfast 6:30 AM - 10:00 AM at Canopy Bistro; EV Level-2 chargers in Garage B2.
Conference Shuttle:Direct shuttle to Shoreline Amphitheatre departs every 20 minutes from Main Lobby.
+ + + """ + .trimIndent() + + createSeedEmailDocument( + filename = "redwood_grove_hotel_confirmation.eml", + title = "The Redwood Grove Hotel — Booking Email", + rawEml = redwoodEmailHtml, + documentType = "HOTEL", + issuingCountry = "United States", + expiryDate = Instant.parse("2026-11-02T00:00:00Z").toEpochMilli(), + documentNumberMasked = "RG-90421-MV", + tripId = "2026-1", + eventId = "io_3", + ) + + // 5. PDF Rental Voucher: Zephyr Wheels EV Rental @ SFO (.pdf) + createSeedPdfDocument( + filename = "Zephyr_EV_Rental_SFO.pdf", + title = "Zephyr Wheels Confirmation", + lines = + listOf( + "ZEPHYR WHEELS ELECTRIC VEHICLE RENTAL - SFO AIRPORT", + "Reservation Code: ZW-55820-SFO", + "Primary Driver: Alex Chen", + "Vehicle Class: Polestar 2 Long Range Dual Motor (Full Electric EV)", + "Pick-up Location: SFO Rental Car Center, Level 4, Express Stall #18", + "Pick-up Time: Tuesday, October 27, 2026 @ 1:30 PM", + "Return Time: Sunday, November 1, 2026 @ 4:00 PM (Return with at least 20% battery)", + "Toll Pass Included: Bay Area FasTrak Transponder #FT-883920 (All bridge & express lanes)", + "Charging Perk: Unlimited complimentary fast charging at Electrify America stations (PIN: 2026)", + "Insurance Coverage: Full Loss Damage Waiver (LDW) + Zero Deductible Roadside Assistance", + ), + documentType = "OTHER", + issuingCountry = "United States", + expiryDate = Instant.parse("2026-11-02T00:00:00Z").toEpochMilli(), + documentNumberMasked = "ZW-55820-SFO", + notes = + "PDF EV Car Rental Voucher • Polestar 2 at SFO Level 4 Stall 18 • FasTrak #FT-883920 • Charging PIN 2026", + tripId = "2026-1", + eventId = "io_2", + ) + + // 6. Scanned Image Card (.jpg): SFMOMA VIP Press & Museum Pass + createSeedImageDocument( + title = "SFMOMA VIP Museum & Exhibition Pass", + headerBanner = "SFMOMA • SAN FRANCISCO MUSEUM OF MODERN ART", + accentColor = Color.rgb(183, 28, 28), + cardLines = + listOf( + "Pass ID: SFM-2026-8841 (VIP Press & Member All-Access)", + "Holder: Alex Chen", + "Timed Entry: Thursday, Oct 29, 2026 @ 2:00 PM", + "Special Exhibition: Digital Horizons & AI Art (Floor 5)", + "Audio Guide Code: SFMOMA-AI-77 • Coat Check Locker: #C-42", + ), + documentType = "TICKET", + issuingCountry = "United States", + expiryDate = Instant.parse("2026-10-30T00:00:00Z").toEpochMilli(), + documentNumberMasked = "SFM-2026-8841", + notes = + "Scanned VIP Museum Pass Image • SFMOMA Floor 5 Digital Horizons exhibition • Audio Guide SFMOMA-AI-77 • Locker #C-42", + tripId = "2026-1", + eventId = "io_5", + ) + + // 7. Email E-Ticket (.eml with HTML): Eiffel Tower Summit & Seine River Cruise (Paris Trip) + val parisEmailHtml = + """ + From: "Societe d'Exploitation de la Tour Eiffel" + To: "Alex Chen" + Subject: Vos billets Tour Eiffel Sommet #EF-44210 & Croisiere Seine + Date: Thu, 01 Oct 2026 09:15:00 +0200 + MIME-Version: 1.0 + Content-Type: text/html; charset="UTF-8" + + + +

Eiffel Tower Summit Access & Champagne River Cruise

+

Booking Reference: EF-44210-PAR — 2 Adult Summit Elevator Tickets

+
    +
  • Entry Gate: Pilier Sud (South Security Entrance, Green Fast-Track Lane)
  • +
  • Summit Elevator Time Slot: 21:00 (Arrive 20 minutes early for security screening)
  • +
  • Included Access: 1st Floor Glass Walk, 2nd Floor Observation Deck, and Top Summit Champagne Bar
  • +
  • Seine Illuminations Cruise: Bateaux Parisiens Pier 3 (Port de la Bourdonnais) — QR Code SEINE-9921
  • +
+ + + """ + .trimIndent() + + createSeedEmailDocument( + filename = "eiffel_tower_summit_tickets.eml", + title = "Eiffel Tower Summit & Seine Cruise — Email", + rawEml = parisEmailHtml, + documentType = "TICKET", + issuingCountry = "France", + expiryDate = Instant.parse("2026-12-31T00:00:00Z").toEpochMilli(), + documentNumberMasked = "EF-44210-PAR", + tripId = "2026-3", + eventId = "par_5", + ) + + // 8. Scanned Image Card (.jpg): JR East Welcome Suica & Shinkansen IC Card (Tokyo Trip) + createSeedImageDocument( + title = "JR East Welcome Suica & Shinkansen IC Card", + headerBanner = "JR EAST • WELCOME SUICA & SHINKANSEN EX-IC", + accentColor = Color.rgb(27, 94, 32), + cardLines = + listOf( + "IC Card Number: JE4092-8812-3301 (No deposit required)", + "Preloaded Transit Balance: 10,000 JPY (Subway, JR Lines & Konbini)", + "Linked Bullet Train: Tokaido Shinkansen Nozomi 24 (Tokyo -> Kyoto)", + "Reserved Seat: Car 8, Seat 12E (Mt. Fuji Window Side + Oversized Baggage)", + "Coin Locker PIN at Tokyo Station Yaesu North Exit: 8419", + ), + documentType = "TICKET", + issuingCountry = "Japan", + expiryDate = Instant.parse("2026-12-15T00:00:00Z").toEpochMilli(), + documentNumberMasked = "JE4092-3301", + notes = + "Scanned IC Transit Card Image • Welcome Suica 10,000 JPY • Linked Shinkansen Nozomi 24 Car 8 Seat 12E • Tokyo Station Locker PIN 8419", + tripId = "2026-4", + eventId = null, + ) + } + + private suspend fun createSeedPdfDocument( + filename: String, + title: String, + lines: List, + documentType: String, + issuingCountry: String, + expiryDate: Long, + documentNumberMasked: String, + notes: String, + tripId: String? = null, + eventId: String? = null, + ): TravelDocument { + val safePrefix = UUID.randomUUID().toString().take(8) + val destFile = File(documentsDir, "${safePrefix}_$filename") + destFile.writeBytes(buildPdfDocumentBytes(filename, title, lines)) + val docId = "${documentType}_${UUID.randomUUID().toString().take(8)}" + val doc = + TravelDocument( + id = UUID.randomUUID().toString(), + documentId = docId, + documentType = documentType.uppercase(), + title = title, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + filePath = destFile.absolutePath, + mimeType = "application/pdf", + notes = notes, + tripId = tripId, + eventId = eventId, + ) + travelDocumentDao.insertDocument(doc) + return doc + } + + private suspend fun createSeedEmailDocument( + filename: String, + title: String, + rawEml: String, + documentType: String, + issuingCountry: String, + expiryDate: Long, + documentNumberMasked: String, + tripId: String? = null, + eventId: String? = null, + ): TravelDocument { + val safePrefix = UUID.randomUUID().toString().take(8) + val destFile = File(documentsDir, "${safePrefix}_$filename") + destFile.writeText(rawEml) + val parsedSummary = parseEmailContent(rawEml).formattedSummary().take(MAX_AUTO_NOTES_CHARS) + val docId = "${documentType}_${UUID.randomUUID().toString().take(8)}" + val doc = + TravelDocument( + id = UUID.randomUUID().toString(), + documentId = docId, + documentType = documentType.uppercase(), + title = title, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + filePath = destFile.absolutePath, + mimeType = "message/rfc822", + notes = parsedSummary, + tripId = tripId, + eventId = eventId, + ) + travelDocumentDao.insertDocument(doc) + return doc + } + + private suspend fun createSeedImageDocument( + title: String, + headerBanner: String, + accentColor: Int, + cardLines: List, + documentType: String, + issuingCountry: String, + expiryDate: Long, + documentNumberMasked: String, + notes: String, + tripId: String? = null, + eventId: String? = null, + ): TravelDocument { + val width = 640 + val height = 380 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + // Card background + canvas.drawColor(Color.rgb(250, 250, 252)) + + // Colored header banner + paint.color = accentColor + canvas.drawRect(0f, 0f, width.toFloat(), 64f, paint) + + // Header text + paint.color = Color.WHITE + paint.textSize = 20f + paint.isFakeBoldText = true + canvas.drawText(headerBanner, 24f, 40f, paint) + + // Document title + paint.color = Color.rgb(25, 28, 32) + paint.textSize = 22f + canvas.drawText(title, 24f, 104f, paint) + + // Body lines + paint.isFakeBoldText = false + paint.textSize = 16f + paint.color = Color.rgb(55, 60, 68) + var y = 144f + for (line in cardLines) { + canvas.drawText(line, 24f, y, paint) + y += 34f + } + + // Simulated high-contrast QR code block in the bottom-right corner for visual realism + paint.color = Color.rgb(30, 30, 30) + val qrOriginX = width - 116f + val qrOriginY = height - 116f + val cell = 10f + for (row in 0 until 9) { + for (col in 0 until 9) { + val isFinder = + (row in 0..2 && col in 0..2) || + (row in 0..2 && col in 6..8) || + (row in 6..8 && col in 0..2) + val isPattern = (row * 7 + col * 13 + title.length) % 3 == 0 + if (isFinder || isPattern) { + canvas.drawRect( + qrOriginX + col * cell, + qrOriginY + row * cell, + qrOriginX + (col + 1) * cell - 1f, + qrOriginY + (row + 1) * cell - 1f, + paint, + ) + } + } + } + + val saved = + saveDocumentBitmap( + bitmap = bitmap, + title = title, + documentType = documentType, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + notes = notes, + tripId = tripId, + eventId = eventId, + ) + return saved + } + + /** + * Generates a rich, realistic PDF document binary using Android's native [PdfDocument] canvas API. + * Renders high-fidelity branded travel vouchers (e.g. Zephyr Wheels EV Rental, United Airlines Boarding Pass) + * that display sharply in [PdfRenderer] and keynote presentation slides. + */ + private fun buildPdfDocumentBytes( + filename: String, + title: String, + lines: List, + ): ByteArray { + return try { + when { + filename.contains("Zephyr", ignoreCase = true) -> buildZephyrRentalPdf() + filename.contains("UA991", ignoreCase = true) -> buildFlightTicketPdf() + else -> buildGenericPdf(title, lines) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to render visual PDF via PdfDocument; falling back to basic PDF", e) + buildSinglePagePdfBytes(lines) + } + } + + private fun buildZephyrRentalPdf(): ByteArray { + val pdfDoc = PdfDocument() + val pageInfo = PdfDocument.PageInfo.Builder(612, 792, 1).create() + val page = pdfDoc.startPage(pageInfo) + val canvas = page.canvas + + // Clean background + canvas.drawColor(Color.rgb(248, 250, 252)) + + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val left = 24f + val right = 588f + + // 1. Header Banner (Deep emerald #0A2E26) + paint.color = Color.rgb(10, 46, 38) + paint.style = Paint.Style.FILL + canvas.drawRoundRect(RectF(left, 24f, right, 120f), 14f, 14f, paint) + + // Brand logo & name + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 22f + canvas.drawText("ZEPHYR WHEELS", left + 20f, 62f, paint) + + // Electric vehicle mobility tagline + paint.color = Color.rgb(167, 243, 208) + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 10f + canvas.drawText("ELECTRIC MOBILITY • SFO PREMIER FLEET", left + 20f, 82f, paint) + + // Status Pill on top right + val pillRight = right - 20f + val pillLeft = pillRight - 116f + paint.color = Color.rgb(16, 185, 129) + paint.style = Paint.Style.FILL + canvas.drawRoundRect(RectF(pillLeft, 42f, pillRight, 70f), 14f, 14f, paint) + + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 11f + canvas.drawText("● CONFIRMED", pillLeft + 14f, 60f, paint) + + // Reservation Ref below status pill + paint.color = Color.rgb(226, 232, 240) + paint.typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + paint.textSize = 11f + canvas.drawText("RES: ZW-55820-SFO", pillLeft - 22f, 96f, paint) + + // 2. Reserved Vehicle Showcase Card + val vehTop = 132f + val vehBottom = 260f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, vehTop, right, vehBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, vehTop, right, vehBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + // Section caption + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("RESERVED VEHICLE SPECIFICATION", left + 18f, vehTop + 24f, paint) + + // Vehicle Name + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 18f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("Polestar 2 Long Range Dual Motor", left + 18f, vehTop + 50f, paint) + + // Vehicle category description + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 11f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText( + "Class: Premium Full-Electric Sedan • All-Wheel Drive (AWD) • Void Black", + left + 18f, + vehTop + 70f, + paint, + ) + + // Badges Row + fun drawBadge(x: Float, y: Float, text: String, bgColor: Int, textColor: Int, borderColor: Int): Float { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + val textW = paint.measureText(text) + val padH = 10f + val badgeW = textW + (padH * 2) + val badgeH = 22f + + paint.style = Paint.Style.FILL + paint.color = bgColor + canvas.drawRoundRect(RectF(x, y, x + badgeW, y + badgeH), 6f, 6f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1f + paint.color = borderColor + canvas.drawRoundRect(RectF(x, y, x + badgeW, y + badgeH), 6f, 6f, paint) + + paint.style = Paint.Style.FILL + paint.color = textColor + canvas.drawText(text, x + padH, y + 15f, paint) + return badgeW + } + + var badgeX = left + 18f + val badgeY = vehTop + 88f + badgeX += + drawBadge( + badgeX, + badgeY, + "⚡ 100% Electric", + Color.rgb(236, 253, 245), + Color.rgb(6, 95, 70), + Color.rgb(167, 243, 208), + ) + 8f + badgeX += + drawBadge( + badgeX, + badgeY, + "🔋 300+ mi Range", + Color.rgb(239, 246, 255), + Color.rgb(30, 64, 175), + Color.rgb(191, 219, 254), + ) + 8f + badgeX += + drawBadge( + badgeX, + badgeY, + "🏎 408 hp AWD", + Color.rgb(248, 250, 252), + Color.rgb(51, 65, 85), + Color.rgb(226, 232, 240), + ) + 8f + drawBadge( + badgeX, + badgeY, + "✦ 85%+ Battery Guaranteed", + Color.rgb(254, 243, 199), + Color.rgb(146, 64, 14), + Color.rgb(253, 230, 138), + ) + + // 3. Rental Itinerary Grid (Two Columns: Pick-up & Return) + val itinTop = 272f + val itinBottom = 416f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, itinTop, right, itinBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, itinTop, right, itinBottom), 14f, 14f, paint) + + // Vertical Divider + val midX = (left + right) / 2f + paint.color = Color.rgb(241, 245, 249) + paint.strokeWidth = 1.5f + canvas.drawLine(midX, itinTop + 14f, midX, itinBottom - 14f, paint) + paint.style = Paint.Style.FILL + + // Column 1: PICK-UP + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + paint.color = Color.rgb(5, 150, 105) + canvas.drawText("▲ PICK-UP (DEPARTURE)", left + 18f, itinTop + 26f, paint) + + paint.textSize = 13.5f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("Tuesday, Oct 27, 2026 @ 1:30 PM", left + 18f, itinTop + 48f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 11.5f + paint.color = Color.rgb(30, 41, 59) + canvas.drawText("SFO International Airport", left + 18f, itinTop + 72f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 10.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Rental Car Center • Level 4, Express Stall #18", left + 18f, itinTop + 90f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(37, 99, 235) + canvas.drawText("✓ Direct Stall Access • Digital Key Active in App", left + 18f, itinTop + 116f, paint) + + // Column 2: RETURN + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + paint.color = Color.rgb(220, 38, 38) + canvas.drawText("▼ RETURN (DROP-OFF)", midX + 18f, itinTop + 26f, paint) + + paint.textSize = 13.5f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("Sunday, Nov 1, 2026 @ 4:00 PM", midX + 18f, itinTop + 48f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 11.5f + paint.color = Color.rgb(30, 41, 59) + canvas.drawText("SFO International Airport", midX + 18f, itinTop + 72f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 10.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Rental Car Center • Level 4, Express Key Drop", midX + 18f, itinTop + 90f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("✓ Return with ≥ 20% battery (No recharge fee)", midX + 18f, itinTop + 116f, paint) + + // 4. Inclusions & Driver Details Card + val incTop = 428f + val incBottom = 570f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, incTop, right, incBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, incTop, right, incBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("EXCLUSIVE INCLUSIONS & DRIVER CREDENTIALS", left + 18f, incTop + 24f, paint) + + var rowY = incTop + 48f + fun drawInclusionRow(label: String, value: String, highlight: Boolean = false) { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 11f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText(label, left + 18f, rowY, paint) + + paint.typeface = + Typeface.create(Typeface.SANS_SERIF, if (highlight) Typeface.BOLD else Typeface.NORMAL) + paint.textSize = 11f + paint.color = if (highlight) Color.rgb(5, 150, 105) else Color.rgb(51, 65, 85) + canvas.drawText(value, left + 160f, rowY, paint) + rowY += 24f + } + + drawInclusionRow("Primary Driver:", "Alex Chen (Verified Global Driving License)") + drawInclusionRow( + "Toll Transponder:", + "Bay Area FasTrak #FT-883920 (All Bridges & Lanes Included)", + highlight = true, + ) + drawInclusionRow( + "EV Charging Perk:", + "Electrify America Unlimited Fast Charging (PIN: 2026)", + highlight = true, + ) + drawInclusionRow( + "Insurance Cover:", + "Full Loss Damage Waiver (LDW) + Zero Deductible 24/7 Roadside", + ) + + // 5. Payment & Financial Summary Bar + val payTop = 582f + val payBottom = 654f + paint.style = Paint.Style.FILL + paint.color = Color.rgb(241, 245, 249) + canvas.drawRoundRect(RectF(left, payTop, right, payBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1f + paint.color = Color.rgb(203, 213, 225) + canvas.drawRoundRect(RectF(left, payTop, right, payBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("BILLING & CHARGES (5 DAYS RENTAL)", left + 18f, payTop + 22f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 10f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText( + "Rate: 5 x $64.00/day • Clean Mobility EV Credit • SFO Concession Recovery Fee Included", + left + 18f, + payTop + 40f, + paint, + ) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10.5f + paint.color = Color.rgb(5, 150, 105) + canvas.drawText("✓ PAID IN FULL (VISA ••••4821)", left + 18f, payTop + 58f, paint) + + // Total Amount on Right + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("TOTAL CHARGED", right - 130f, payTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 24f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("$360.50", right - 130f, payTop + 54f, paint) + + // 6. Express Gate Barcode & QR Verification Card + val barTop = 666f + val barBottom = 768f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, barTop, right, barBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, barTop, right, barBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + // Draw realistic barcode pattern + val barLeft = left + 20f + val barWidth = 380f + val barHeight = 40f + val barY = barTop + 14f + paint.color = Color.rgb(15, 23, 42) + val barPattern = + intArrayOf( + 2, 1, 3, 1, 1, 2, 3, 2, 1, 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 1, 3, 2, 1, 2, 2, 1, 1, 3, 2, 1, 1, + 2, 3, 1, 2, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1, 1, 3, 2, 1, 2, 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 3, + 1, 2, 1, 1, 3, 2, 1, 2, 3, 1, 1, 2, 1, 3, 2, 1, 2, 2, 1, 3, 1, 2, 1, 1, 3, 2, 1, 2, 1, + ) + var currentBarX = barLeft + var isBar = true + for (w in barPattern) { + if (isBar && currentBarX < barLeft + barWidth) { + val barW = w.toFloat() * 1.8f + canvas.drawRect(currentBarX, barY, currentBarX + barW, barY + barHeight, paint) + } + currentBarX += w.toFloat() * 1.8f + isBar = !isBar + } + + // Barcode number text + paint.typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText( + "* ZW-55820-SFO-20261027-POLESTAR2-AWD *", + barLeft + 16f, + barY + barHeight + 16f, + paint, + ) + + // Footer instruction + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 8.5f + paint.color = Color.rgb(148, 163, 184) + canvas.drawText( + "Scan barcode at Express Gate Stall #18 barrier for automated access • 24/7 Support: 1-800-ZEPHYR-EV", + barLeft, + barY + barHeight + 30f, + paint, + ) + + // QR Code square on right side + val qrSize = 64f + val qrX = right - qrSize - 20f + val qrY = barTop + 14f + val cellSize = qrSize / 8f + paint.color = Color.rgb(15, 23, 42) + for (r in 0 until 8) { + for (c in 0 until 8) { + val isFinder = + (r in 0..2 && c in 0..2) || (r in 0..2 && c in 5..7) || (r in 5..7 && c in 0..2) + val isPixel = isFinder || ((r * 3 + c * 7 + 5) % 2 == 0) + if (isPixel) { + canvas.drawRect( + qrX + c * cellSize, + qrY + r * cellSize, + qrX + (c + 1) * cellSize - 0.5f, + qrY + (r + 1) * cellSize - 0.5f, + paint, + ) + } + } + } + + pdfDoc.finishPage(page) + val out = ByteArrayOutputStream() + pdfDoc.writeTo(out) + pdfDoc.close() + return out.toByteArray() + } + + private fun buildFlightTicketPdf(): ByteArray { + val pdfDoc = PdfDocument() + val pageInfo = PdfDocument.PageInfo.Builder(612, 792, 1).create() + val page = pdfDoc.startPage(pageInfo) + val canvas = page.canvas + + canvas.drawColor(Color.rgb(248, 250, 252)) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val left = 24f + val right = 588f + + // 1. Header Banner (United Navy #002244) + paint.color = Color.rgb(0, 34, 68) + paint.style = Paint.Style.FILL + canvas.drawRoundRect(RectF(left, 24f, right, 120f), 14f, 14f, paint) + + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 22f + canvas.drawText("UNITED AIRLINES", left + 20f, 62f, paint) + + paint.color = Color.rgb(147, 197, 253) + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 10f + canvas.drawText("ELECTRONIC TICKET & BOARDING PASS • STAR ALLIANCE", left + 20f, 82f, paint) + + val pillRight = right - 20f + val pillLeft = pillRight - 116f + paint.color = Color.rgb(16, 185, 129) + paint.style = Paint.Style.FILL + canvas.drawRoundRect(RectF(pillLeft, 42f, pillRight, 70f), 14f, 14f, paint) + + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 11f + canvas.drawText("● CONFIRMED", pillLeft + 14f, 60f, paint) + + paint.color = Color.rgb(226, 232, 240) + paint.typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + paint.textSize = 11f + canvas.drawText("PNR: KL8821UA", pillLeft - 18f, 96f, paint) + + // 2. Passenger & Status Card + val passTop = 132f + val passBottom = 222f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, passTop, right, passBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, passTop, right, passBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("PASSENGER DETAILS & STATUS", left + 18f, passTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 18f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("ALEX CHEN", left + 18f, passTop + 50f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 11f + paint.color = Color.rgb(37, 99, 235) + canvas.drawText( + "Premier Gold • Star Alliance Gold • Global Entry Lane Eligible", + left + 18f, + passTop + 72f, + paint, + ) + + // 3. Flight Route Card (Two Columns) + val routeTop = 234f + val routeBottom = 384f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, routeTop, right, routeBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, routeTop, right, routeBottom), 14f, 14f, paint) + + val midX = (left + right) / 2f + paint.color = Color.rgb(241, 245, 249) + paint.strokeWidth = 1.5f + canvas.drawLine(midX, routeTop + 14f, midX, routeBottom - 14f, paint) + paint.style = Paint.Style.FILL + + // CDG + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + paint.color = Color.rgb(5, 150, 105) + canvas.drawText("▲ DEPARTURE (ORIGIN)", left + 18f, routeTop + 26f, paint) + + paint.textSize = 14f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("CDG Paris Charles de Gaulle", left + 18f, routeTop + 50f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 11f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Tue, Oct 27, 2026 @ 09:00 AM", left + 18f, routeTop + 70f, paint) + canvas.drawText("Terminal 2E • Gate K42", left + 18f, routeTop + 90f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(37, 99, 235) + canvas.drawText("Boarding Closes 08:45 AM", left + 18f, routeTop + 116f, paint) + + // SFO + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 10f + paint.color = Color.rgb(30, 64, 175) + canvas.drawText("▼ ARRIVAL (DESTINATION)", midX + 18f, routeTop + 26f, paint) + + paint.textSize = 14f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("SFO San Francisco Intl", midX + 18f, routeTop + 50f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 11f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Tue, Oct 27, 2026 @ 12:15 PM", midX + 18f, routeTop + 70f, paint) + canvas.drawText("International Terminal G • Gate G94", midX + 18f, routeTop + 90f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(5, 150, 105) + canvas.drawText("Nonstop • 11h 15m Flight Time", midX + 18f, routeTop + 116f, paint) + + // 4. Seat & Boarding Grid + val seatTop = 396f + val seatBottom = 496f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, seatTop, right, seatBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, seatTop, right, seatBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + val colW = (right - left) / 4f + for (i in 0 until 4) { + val cX = left + (i * colW) + 12f + when (i) { + 0 -> { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("SEAT", cX, seatTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 24f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("32B", cX, seatTop + 58f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 9.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Economy Plus", cX, seatTop + 78f, paint) + } + 1 -> { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("GROUP", cX, seatTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 24f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("1", cX, seatTop + 58f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 9.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Premier Access", cX, seatTop + 78f, paint) + } + 2 -> { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("BAGGAGE", cX, seatTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 20f + paint.color = Color.rgb(15, 23, 42) + canvas.drawText("2x 23kg", cX, seatTop + 58f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 9.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Included Free", cX, seatTop + 78f, paint) + } + 3 -> { + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(100, 116, 139) + canvas.drawText("IN-FLIGHT WI-FI", cX, seatTop + 24f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 14f + paint.color = Color.rgb(37, 99, 235) + canvas.drawText("ACTIVE", cX, seatTop + 56f, paint) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 9f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText("Code: 883920", cX, seatTop + 78f, paint) + } + } + } + + // 5. Perforated divider & Boarding Barcode Card + val barTop = 510f + val barBottom = 768f + paint.style = Paint.Style.FILL + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, barTop, right, barBottom), 14f, 14f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, barTop, right, barBottom), 14f, 14f, paint) + paint.style = Paint.Style.FILL + + // Boarding barcode lines + val barLeft = left + 20f + val barWidth = 380f + val barHeight = 44f + val barY = barTop + 20f + paint.color = Color.rgb(15, 23, 42) + val barPattern = + intArrayOf( + 3, 1, 2, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1, 3, 1, 1, 2, 3, 2, 1, 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 1, + 3, 2, 1, 2, 2, 1, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1, 3, 2, 1, 1, 2, 3, 1, 2, 1, 1, 3, 2, 1, 2, + 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 3, 1, 2, 1, 1, 3, 2, 1, 2, 3, 1, 1, 2, 1, 3, 2, 1, 2, 2, 1, + ) + var currentBarX = barLeft + var isBar = true + for (w in barPattern) { + if (isBar && currentBarX < barLeft + barWidth) { + val barW = w.toFloat() * 1.8f + canvas.drawRect(currentBarX, barY, currentBarX + barW, barY + barHeight, paint) + } + currentBarX += w.toFloat() * 1.8f + isBar = !isBar + } + + paint.typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + paint.textSize = 9.5f + paint.color = Color.rgb(71, 85, 105) + canvas.drawText( + "* M1CHEN/ALEX EKL8821UA CDGSFOUA 0991 300Y032B0042 100 *", + barLeft, + barY + barHeight + 18f, + paint, + ) + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 8.5f + paint.color = Color.rgb(148, 163, 184) + canvas.drawText( + "Scan digital boarding pass at Gate K42 automated gate turnstile • Star Alliance Gold Premier Access", + barLeft, + barY + barHeight + 32f, + paint, + ) + + // QR Code square on right side + val qrSize = 64f + val qrX = right - qrSize - 20f + val qrY = barTop + 16f + val cellSize = qrSize / 8f + paint.color = Color.rgb(15, 23, 42) + for (r in 0 until 8) { + for (c in 0 until 8) { + val isFinder = + (r in 0..2 && c in 0..2) || (r in 0..2 && c in 5..7) || (r in 5..7 && c in 0..2) + val isPixel = isFinder || ((r * 5 + c * 3 + 7) % 2 == 0) + if (isPixel) { + canvas.drawRect( + qrX + c * cellSize, + qrY + r * cellSize, + qrX + (c + 1) * cellSize - 0.5f, + qrY + (r + 1) * cellSize - 0.5f, + paint, + ) + } + } + } + + pdfDoc.finishPage(page) + val out = ByteArrayOutputStream() + pdfDoc.writeTo(out) + pdfDoc.close() + return out.toByteArray() + } + + private fun buildGenericPdf(title: String, lines: List): ByteArray { + val pdfDoc = PdfDocument() + val pageInfo = PdfDocument.PageInfo.Builder(612, 792, 1).create() + val page = pdfDoc.startPage(pageInfo) + val canvas = page.canvas + + canvas.drawColor(Color.rgb(248, 250, 252)) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val left = 24f + val right = 588f + + // Header banner + paint.color = Color.rgb(30, 41, 59) + paint.style = Paint.Style.FILL + canvas.drawRoundRect(RectF(left, 24f, right, 100f), 12f, 12f, paint) + + paint.color = Color.WHITE + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) + paint.textSize = 20f + canvas.drawText(title.take(36), left + 18f, 64f, paint) + + // Body Card + val bodyTop = 112f + val bodyBottom = 768f + paint.color = Color.WHITE + canvas.drawRoundRect(RectF(left, bodyTop, right, bodyBottom), 12f, 12f, paint) + + paint.style = Paint.Style.STROKE + paint.strokeWidth = 1.5f + paint.color = Color.rgb(226, 232, 240) + canvas.drawRoundRect(RectF(left, bodyTop, right, bodyBottom), 12f, 12f, paint) + paint.style = Paint.Style.FILL + + paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL) + paint.textSize = 12f + paint.color = Color.rgb(51, 65, 85) + var textY = bodyTop + 36f + for (line in lines) { + if (textY > bodyBottom - 30f) break + canvas.drawText(line.take(80), left + 20f, textY, paint) + textY += 24f + } + + pdfDoc.finishPage(page) + val out = ByteArrayOutputStream() + pdfDoc.writeTo(out) + pdfDoc.close() + return out.toByteArray() + } + + /** + * Builds a standards-compliant 1-page PDF 1.4 binary with exact `xref` byte offsets so both + * Android's native [PdfRenderer] and [extractPdfText] can read it. + */ + private fun buildSinglePagePdfBytes(lines: List): ByteArray { + fun escapePdfLiteral(text: String): String = + text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + + val contentStream = buildString { + append("BT\n/F1 11 Tf\n18 TL\n42 730 Td\n") + lines.forEachIndexed { idx, line -> + if (idx > 0) append("T*\n") + append("(${escapePdfLiteral(line)}) Tj\n") + } + append("ET") + } + val streamBytes = contentStream.toByteArray(StandardCharsets.ISO_8859_1) + + val out = ByteArrayOutputStream() + fun writeAscii(str: String) { + out.write(str.toByteArray(StandardCharsets.ISO_8859_1)) + } + + writeAscii("%PDF-1.4\n") + val offsets = IntArray(6) + + offsets[1] = out.size() + writeAscii("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") + + offsets[2] = out.size() + writeAscii("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") + + offsets[3] = out.size() + writeAscii( + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n" + ) + + offsets[4] = out.size() + writeAscii("4 0 obj\n<< /Length ${streamBytes.size} >>\nstream\n") + out.write(streamBytes) + writeAscii("\nendstream\nendobj\n") + + offsets[5] = out.size() + writeAscii("5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n") + + val xrefOffset = out.size() + writeAscii("xref\n0 6\n0000000000 65535 f \n") + for (i in 1..5) { + writeAscii(String.format(Locale.US, "%010d 00000 n \n", offsets[i])) + } + writeAscii("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n$xrefOffset\n%%EOF\n") + return out.toByteArray() + } + + private fun extractTextFromFile(file: File, mimeType: String?): String? { + if (!file.exists() || !file.isFile) return null + val ext = file.extension.lowercase() + return try { + when { + isEmailMimeOrExt(mimeType, ext) -> { + val raw = file.readText() + parseEmailContent(raw).formattedSummary().ifBlank { raw } + } + mimeType.equals("text/html", ignoreCase = true) || ext in setOf("html", "htm") -> { + stripHtml(file.readText()) + } + mimeType.equals("application/pdf", ignoreCase = true) || ext == "pdf" -> { + extractPdfText(file) + } + isTextMime(mimeType) || ext in setOf("txt", "md", "json", "csv", "text") -> { + val raw = file.readText() + if (looksLikeEmailOrHtml(raw)) { + parseEmailContent(raw).formattedSummary().ifBlank { raw } + } else { + raw + } + } + else -> null + } + } catch (e: Exception) { + Log.w(TAG, "Could not extract text from file ${file.name}", e) + null + } + } + + /** + * Extracts selectable text from a PDF [file] by scanning uncompressed and `/FlateDecode` PDF + * content streams for `Tj` and `TJ` text operators. + * + * For image-only (scanned) PDFs where this returns null, callers fall back to + * [renderPdfPageToBitmap] for multimodal `ImagePart` embedding and Gemini Nano vision OCR. + */ + open fun extractPdfText(file: File): String? { + if (!file.exists() || !file.isFile || file.length() > MAX_PDF_BYTES) return null + return try { + val bytes = file.readBytes() + val rawLatin1 = String(bytes, StandardCharsets.ISO_8859_1) + val contentStreams = mutableListOf() + + var searchIdx = 0 + while (searchIdx < rawLatin1.length) { + val streamStartMarker = rawLatin1.indexOf("stream", searchIdx) + if (streamStartMarker == -1) break + // Advance past "stream\r\n" or "stream\n" + var dataStart = streamStartMarker + 6 + if (dataStart < rawLatin1.length && rawLatin1[dataStart] == '\r') dataStart++ + if (dataStart < rawLatin1.length && rawLatin1[dataStart] == '\n') dataStart++ + + val streamEndMarker = rawLatin1.indexOf("endstream", dataStart) + if (streamEndMarker == -1) break + + var dataEnd = streamEndMarker + while ( + dataEnd > dataStart && + (bytes[dataEnd - 1] == '\n'.code.toByte() || bytes[dataEnd - 1] == '\r'.code.toByte()) + ) { + dataEnd-- + } + + if (dataEnd > dataStart) { + val streamBytes = bytes.copyOfRange(dataStart, dataEnd) + val inflated = tryInflateStream(streamBytes) + if (inflated != null) { + contentStreams.add(String(inflated, StandardCharsets.ISO_8859_1)) + } else { + contentStreams.add(String(streamBytes, StandardCharsets.ISO_8859_1)) + } + } + searchIdx = streamEndMarker + 9 + } + + if (contentStreams.isEmpty()) { + contentStreams.add(rawLatin1) + } + + val extractedPieces = mutableListOf() + val tjRegex = Regex("""\((?:\\.|[^\\)])*\)\s*Tj""") + val tjArrayRegex = Regex("""\[(.*?)\]\s*TJ""", RegexOption.DOT_MATCHES_ALL) + val parenStringRegex = Regex("""\((?:\\.|[^\\)])*\)""") + + for (stream in contentStreams) { + if (!stream.contains("Tj") && !stream.contains("TJ")) continue + // Walk in document order by matching either (..) Tj or [..] TJ + val combinedRegex = + Regex("""\((?:\\.|[^\\)])*\)\s*Tj|\[.*?\]\s*TJ""", RegexOption.DOT_MATCHES_ALL) + val lineBuilder = StringBuilder() + for (match in combinedRegex.findAll(stream)) { + val token = match.value + if (token.endsWith("Tj")) { + val inner = parenStringRegex.find(token)?.value + if (inner != null) { + lineBuilder.append(decodePdfLiteralString(inner.substring(1, inner.length - 1))) + } + } else { + val arrayBody = tjArrayRegex.find(token)?.groupValues?.getOrNull(1).orEmpty() + for (strMatch in parenStringRegex.findAll(arrayBody)) { + val s = strMatch.value + lineBuilder.append(decodePdfLiteralString(s.substring(1, s.length - 1))) + } + } + lineBuilder.append(" ") + } + val cleaned = lineBuilder.toString().replace(Regex("""\s+"""), " ").trim() + if (cleaned.isNotBlank()) { + extractedPieces.add(cleaned) + } + } + + extractedPieces.joinToString("\n").trim().takeIf { it.isNotBlank() } + } catch (e: Exception) { + Log.w(TAG, "Failed to extract text from PDF ${file.name}", e) + null + } + } + + private fun tryInflateStream(data: ByteArray): ByteArray? { + for (nowrap in listOf(false, true)) { + val inflater = Inflater(nowrap) + try { + inflater.setInput(data) + val out = ByteArrayOutputStream() + val buffer = ByteArray(4096) + while (!inflater.finished()) { + val count = inflater.inflate(buffer) + if (count == 0) { + if (inflater.needsInput() || inflater.needsDictionary()) break + } else { + out.write(buffer, 0, count) + } + } + val result = out.toByteArray() + if (result.isNotEmpty()) return result + } catch (_: Exception) { + // Not a valid deflate stream with this nowrap setting; try next. + } finally { + inflater.end() + } + } + return null + } + + private fun decodePdfLiteralString(raw: String): String { + val sb = StringBuilder(raw.length) + var i = 0 + while (i < raw.length) { + val c = raw[i] + if (c == '\\' && i + 1 < raw.length) { + val next = raw[i + 1] + when (next) { + 'n' -> { + sb.append('\n') + i += 2 + } + 'r' -> { + sb.append('\r') + i += 2 + } + 't' -> { + sb.append('\t') + i += 2 + } + '(', + ')', + '\\' -> { + sb.append(next) + i += 2 + } + in '0'..'7' -> { + var oct = next.toString() + var consumed = 2 + if (i + 2 < raw.length && raw[i + 2] in '0'..'7') { + oct += raw[i + 2] + consumed++ + if (i + 3 < raw.length && raw[i + 3] in '0'..'7') { + oct += raw[i + 3] + consumed++ + } + } + sb.append(oct.toInt(8).toChar()) + i += consumed + } + else -> { + sb.append(next) + i += 2 + } + } + } else { + sb.append(c) + i++ + } + } + return sb.toString() + } + + /** Parsed representation of an RFC 822 `.eml` or shared email message. */ + data class ParsedEmail( + val subject: String?, + val from: String?, + val date: String?, + val body: String, + ) { + fun formattedSummary(): String = + buildString { + if (!subject.isNullOrBlank()) append("Subject: $subject\n") + if (!from.isNullOrBlank()) append("From: $from\n") + if (!date.isNullOrBlank()) append("Date: $date\n") + if (isNotEmpty() && body.isNotBlank()) append("\n") + append(body.trim()) + } + .trim() + } + + /** + * Parses an `.eml` (`message/rfc822`), forwarded email text, or HTML email into structured + * headers (`Subject`, `From`, `Date`) and clean plain-text body. + */ + open fun parseEmailContent(raw: String): ParsedEmail { + val normalized = raw.replace("\r\n", "\n") + if (!looksLikeEmailHeaders(normalized) && looksLikeHtml(normalized)) { + return ParsedEmail(subject = null, from = null, date = null, body = stripHtml(normalized)) + } + + val headerBlock: String + val rawBodyBlock: String + val blankLineIdx = normalized.indexOf("\n\n") + if (blankLineIdx != -1) { + headerBlock = normalized.substring(0, blankLineIdx) + rawBodyBlock = normalized.substring(blankLineIdx + 2) + } else { + val lines = normalized.lines() + val headerLines = mutableListOf() + var bodyStartIndex = lines.size + val headerRegex = Regex("^[A-Za-z][A-Za-z0-9-]*:\\s*.*") + for (i in lines.indices) { + val line = lines[i] + if (headerRegex.matches(line)) { + headerLines.add(line) + } else if ((line.startsWith(" ") || line.startsWith("\t")) && headerLines.isNotEmpty()) { + headerLines.add(line) + } else { + bodyStartIndex = i + break + } + } + if (headerLines.isNotEmpty() && bodyStartIndex < lines.size) { + headerBlock = headerLines.joinToString("\n") + rawBodyBlock = lines.subList(bodyStartIndex, lines.size).joinToString("\n") + } else { + headerBlock = normalized + rawBodyBlock = "" + } + } + + val headers = parseHeaders(headerBlock) + val subject = headers["subject"] + val from = headers["from"] + val date = headers["date"] + val contentType = headers["content-type"].orEmpty() + val transferEncoding = headers["content-transfer-encoding"].orEmpty() + + if (subject == null && from == null && !contentType.contains("multipart", ignoreCase = true)) { + val cleaned = if (looksLikeHtml(normalized)) stripHtml(normalized) else normalized.trim() + return ParsedEmail(subject = null, from = null, date = null, body = cleaned) + } + + val extractedBody = extractMimeBody(rawBodyBlock, contentType, transferEncoding) + return ParsedEmail(subject = subject, from = from, date = date, body = extractedBody) + } + + private fun parseHeaders(headerBlock: String): Map { + val unfoldedLines = mutableListOf() + for (line in headerBlock.lines()) { + if ((line.startsWith(" ") || line.startsWith("\t")) && unfoldedLines.isNotEmpty()) { + val lastIdx = unfoldedLines.lastIndex + unfoldedLines[lastIdx] = unfoldedLines[lastIdx] + " " + line.trim() + } else { + unfoldedLines.add(line) + } + } + val map = mutableMapOf() + for (line in unfoldedLines) { + val colonIdx = line.indexOf(':') + if (colonIdx > 0) { + val key = line.substring(0, colonIdx).trim().lowercase() + val value = line.substring(colonIdx + 1).trim() + if ( + key in setOf("subject", "from", "to", "date", "content-type", "content-transfer-encoding") + ) { + map.putIfAbsent(key, value) + } + } + } + return map + } + + private fun extractMimeBody( + rawBody: String, + contentType: String, + transferEncoding: String, + ): String { + val boundaryMatch = + Regex("""boundary="?([^";\r\n]+)"?""", RegexOption.IGNORE_CASE).find(contentType) + if (boundaryMatch != null) { + val boundary = boundaryMatch.groupValues[1] + val parts = rawBody.split("--$boundary") + var plainCandidate: String? = null + var htmlCandidate: String? = null + for (part in parts) { + val trimmedPart = part.trim() + if (trimmedPart.isEmpty() || trimmedPart == "--") continue + val splitIdx = trimmedPart.indexOf("\n\n") + if (splitIdx == -1) continue + val partHeaders = parseHeaders(trimmedPart.substring(0, splitIdx)) + val partType = partHeaders["content-type"].orEmpty().lowercase() + val partEncoding = partHeaders["content-transfer-encoding"].orEmpty() + val partContent = trimmedPart.substring(splitIdx + 2) + if (partType.contains("multipart/")) { + val nested = extractMimeBody(partContent, partType, partEncoding) + if (nested.isNotBlank()) return nested + } else if (partType.contains("text/plain") || partType.isEmpty()) { + val decoded = decodeTransferEncoding(partContent, partEncoding).trim() + if (decoded.isNotBlank() && plainCandidate == null) { + plainCandidate = decoded + } + } else if (partType.contains("text/html")) { + val decoded = stripHtml(decodeTransferEncoding(partContent, partEncoding)).trim() + if (decoded.isNotBlank() && htmlCandidate == null) { + htmlCandidate = decoded + } + } + } + return plainCandidate ?: htmlCandidate ?: stripHtml(rawBody) + } + + val decoded = decodeTransferEncoding(rawBody, transferEncoding) + return if (contentType.contains("text/html", ignoreCase = true) || looksLikeHtml(decoded)) { + stripHtml(decoded) + } else { + decoded.trim() + } + } + + private fun decodeTransferEncoding(content: String, encoding: String): String { + return when (encoding.trim().lowercase()) { + "quoted-printable" -> decodeQuotedPrintable(content) + "base64" -> + runCatching { + val cleanBase64 = content.replace(Regex("""\s+"""), "") + String(Base64.getDecoder().decode(cleanBase64), StandardCharsets.UTF_8) + } + .getOrDefault(content) + else -> content + } + } + + private fun decodeQuotedPrintable(input: String): String { + val softJoined = input.replace("=\r\n", "").replace("=\n", "") + val out = ByteArrayOutputStream(softJoined.length) + var i = 0 + while (i < softJoined.length) { + val c = softJoined[i] + if (c == '=' && i + 2 < softJoined.length) { + val hex = softJoined.substring(i + 1, i + 3) + val byteVal = hex.toIntOrNull(16) + if (byteVal != null) { + out.write(byteVal) + i += 3 + continue + } + } + out.write(c.code) + i++ + } + return String(out.toByteArray(), StandardCharsets.UTF_8) + } + + private fun stripHtml(html: String): String { + return html + .replace(Regex("""(?is)<(script|style|head)\b[^>]*>.*?"""), " ") + .replace(Regex("""(?i)"""), "\n") + .replace(Regex("""(?i)"""), "\n") + .replace(Regex("""<[^>]+>"""), " ") + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .lines() + .map { it.replace(Regex("""\s+"""), " ").trim() } + .filter { it.isNotBlank() } + .joinToString("\n") + } + + private fun looksLikeEmailHeaders(text: String): Boolean { + val head = text.take(1000) + return Regex("""(?m)^(Subject|From|MIME-Version|Content-Type):\s+.+""").containsMatchIn(head) + } + + private fun looksLikeHtml(text: String): Boolean { + val head = text.take(1000).lowercase() + return "" in head + } + + private fun looksLikeEmailOrHtml(text: String): Boolean = + looksLikeEmailHeaders(text) || looksLikeHtml(text) + + private fun queryFilename(uri: Uri): String? { + if (uri.scheme == "content") { + try { + context.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx != -1) return File(cursor.getString(idx)).name + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to resolve filename for content URI $uri", e) + } + } + return uri.lastPathSegment?.substringAfterLast('/') + } + + private fun getMimeTypeFromFilename(filename: String): String { + val ext = filename.substringAfterLast('.', "").lowercase() + return when (ext) { + "pdf" -> "application/pdf" + "jpg", + "jpeg" -> "image/jpeg" + "png" -> "image/png" + "webp" -> "image/webp" + "txt" -> "text/plain" + "eml", + "msg" -> "message/rfc822" + "html", + "htm" -> "text/html" + "json" -> "application/json" + "md" -> "text/markdown" + else -> "application/octet-stream" + } + } + + private fun isEmailMimeOrExt(mime: String?, ext: String): Boolean { + val lowerExt = ext.lowercase() + return mime.equals("message/rfc822", ignoreCase = true) || + lowerExt == "eml" || + lowerExt == "msg" + } + + private fun isTextMime(mime: String?): Boolean { + if (mime == null) return false + return mime.startsWith("text/") || + mime == "application/json" || + mime == "application/xml" || + mime.equals("message/rfc822", ignoreCase = true) + } + + private fun detectDocumentType( + filename: String, + file: File? = null, + extractedText: String? = null, + ): String { + val combined = "${filename.lowercase()} ${extractedText?.take(600)?.lowercase().orEmpty()}" + return when { + "passport" in combined || "passeport" in combined -> "PASSPORT" + "visa" in combined || "esta" in combined -> "VISA" + "flight" in combined || + "ticket" in combined || + "boarding" in combined || + "voucher" in combined || + "airlines" in combined || + "airways" in combined || + "train" in combined || + "rail" in combined || + "eurostar" in combined || + "shinkansen" in combined || + "amtrak" in combined || + "ferry" in combined || + "bus" in combined || + "coach" in combined || + "transit" in combined -> "TICKET" + "hotel" in combined || + "reservation" in combined || + "booking" in combined || + "check-in" in combined -> "HOTEL" + "license" in combined || "national_id" in combined || "driver" in combined -> "NATIONAL_ID" + else -> "OTHER" + } + } + + companion object { + private const val TAG = "TravelDocumentManager" + private const val MAX_AUTO_NOTES_CHARS = 1000 + private const val MAX_PDF_BYTES = 8L * 1024L * 1024L + } +} 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..c1b18e04 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,320 +14,1361 @@ * 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.AppFunctionAccessLevel +import androidx.appfunctions.AppFunctionDeclaration import androidx.appfunctions.AppFunctionService import androidx.appfunctions.AppFunctionServiceEntryPoint -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - +import androidx.appfunctions.ExperimentalAppFunctionsApi +import androidx.appfunctions.metadata.AppFunctionMetadata import com.example.jetpacker.data.itinerary.DayTheme import com.example.jetpacker.data.itinerary.DayThemeDao +import com.example.jetpacker.data.itinerary.DocumentSemanticSearchProvider import com.example.jetpacker.data.itinerary.EventDao import com.example.jetpacker.data.itinerary.EventType import com.example.jetpacker.data.itinerary.Expense import com.example.jetpacker.data.itinerary.ExpenseDao import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TourDetailDao +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager 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.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.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 + @Inject internal lateinit var travelDocumentDao: TravelDocumentDao + @Inject internal lateinit var travelDocumentManager: TravelDocumentManager + @Inject internal lateinit var semanticSearchProvider: DocumentSemanticSearchProvider + + /** + * 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. + */ + @AppFunctionDeclaration(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. + */ + @AppFunctionDeclaration(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 taxi 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 taxi 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. + */ + @AppFunctionDeclaration(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.", + ) + } + + 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)}.", ) - return withContext(Dispatchers.IO) { - tripDao.insertTrip(trip) - trip.toSerializable() + } + + 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. + */ + @AppFunctionDeclaration(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. + */ + @AppFunctionDeclaration(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. + */ + @AppFunctionDeclaration(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. + */ + @AppFunctionDeclaration(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" - /** - * 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 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) + } + + 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 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. + */ + @AppFunctionDeclaration(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() } + } + } + + /** + * 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. + */ + @AppFunctionDeclaration(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, + ) + 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. + */ + @AppFunctionDeclaration(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. + */ + @AppFunctionDeclaration(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() + } + } + + /** + * Searches for saved travel documents, passports, visas, boarding passes, museum tickets, + * hotel reservations, Wi-Fi passwords, and travel notes. Restricted to scope SELF. + * Call this when the user asks about their travel documents, ticket confirmation numbers, + * passport expiry, hotel check-in details, or notes for their trip. + * + * @param query Optional search keywords to match document title, notes, or issuing country + * (e.g. 'passport', 'SFMOMA', 'hotel', 'wifi', 'visa'). + * @param documentType Optional filter for document type ('PASSPORT', 'VISA', 'TICKET', 'HOTEL', 'OTHER'). + * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. + * @return A list of matching travel documents. + */ + @OptIn(ExperimentalAppFunctionsApi::class) + @AppFunctionDeclaration(isDescribedByKDoc = true) + @AppFunctionAccessLevel( + level = AppFunctionMetadata.ACCESS_LEVEL_SELF, + isCompatEnforcementEnabled = true, + ) + suspend fun searchTravelDocuments( + query: String? = null, + documentType: String? = null, + tripId: String? = null, + ): List { + return withContext(Dispatchers.IO) { + var allDocs = travelDocumentDao.getAllDocuments().first() + if (allDocs.isEmpty()) { + travelDocumentManager.seedDefaultDocumentsIfEmpty() + allDocs = travelDocumentDao.getAllDocuments().first() + } + val trip = resolveTrip(tripId = tripId, textHint = query) + val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } + + // 1. Natural language semantic search using on-device Embedding Gemma v2 and AppSearch + // (same engine as the DocumentsScreen vault search) + val semanticDocs = if (!query.isNullOrBlank()) { + try { + val searcher = + if (::semanticSearchProvider.isInitialized) semanticSearchProvider + else travelDocumentManager.semanticSearchProvider + searcher?.findRelevantDocuments(query = query, tripId = targetTripId, maxResults = 10) + ?: travelDocumentManager.searchDocumentsSemantically(query = query, tripId = targetTripId, maxResults = 10) + } catch (_: Exception) { + emptyList() + } + } else { + emptyList() + } + + val candidateDocs = if (semanticDocs.isNotEmpty()) { + semanticDocs + } else { + allDocs + } + + val queryWords = + query + ?.lowercase() + ?.split(Regex("[\\s,;.]+")) + ?.filter { it.length > 2 && it !in setOf("the", "for", "our", "and", "this", "validity") } + ?: emptyList() - /** - * 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() } + val filtered = candidateDocs.filter { doc -> + val matchesTrip = targetTripId == null || doc.tripId == null || doc.tripId == targetTripId + val matchesType = documentType.isNullOrBlank() || + doc.documentType.equals(documentType, ignoreCase = true) || + (documentType.equals("TICKET", ignoreCase = true) && doc.documentType.contains("TICKET", ignoreCase = true)) || + (documentType.equals("HOTEL", ignoreCase = true) && doc.documentType.contains("HOTEL", ignoreCase = true)) + + if (semanticDocs.isNotEmpty()) { + matchesTrip && matchesType + } else { + val content = if (query != null) travelDocumentManager.readDocumentContent(doc) else "" + val matchesQuery = query.isNullOrBlank() || + doc.title.contains(query, ignoreCase = true) || + doc.notes.contains(query, ignoreCase = true) || + doc.issuingCountry.contains(query, ignoreCase = true) || + doc.documentType.contains(query, ignoreCase = true) || + doc.documentNumberMasked.contains(query, ignoreCase = true) || + content.contains(query, ignoreCase = true) || + queryWords.any { word -> + doc.title.contains(word, ignoreCase = true) || + doc.notes.contains(word, ignoreCase = true) || + doc.documentType.contains(word, ignoreCase = true) || + doc.issuingCountry.contains(word, ignoreCase = true) || + content.contains(word, ignoreCase = true) + } + + matchesTrip && matchesType && matchesQuery } + } + + filtered.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 + /** + * Retrieves the full details, extracted content, notes, and metadata of a specific travel document + * by its title, keyword, or document ID. Restricted to scope SELF. Call this when the user needs + * specific information from a document, such as the full notes, passport number, Wi-Fi password, + * or booking confirmation details. + * + * @param titleOrId The title, keyword, or ID of the document (e.g. 'US Passport', 'SFMOMA Pass', 'Hotel Confirmation'). + * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. + * @return The detailed document information including extracted content and notes. + */ + @OptIn(ExperimentalAppFunctionsApi::class) + @AppFunctionDeclaration(isDescribedByKDoc = true) + @AppFunctionAccessLevel( + level = AppFunctionMetadata.ACCESS_LEVEL_SELF, + isCompatEnforcementEnabled = true, + ) + suspend fun getTravelDocumentDetails( + titleOrId: String, + tripId: String? = null, + ): TravelDocumentDetailsSerializable { + return withContext(Dispatchers.IO) { + var allDocs = travelDocumentDao.getAllDocuments().first() + if (allDocs.isEmpty()) { + travelDocumentManager.seedDefaultDocumentsIfEmpty() + allDocs = travelDocumentDao.getAllDocuments().first() + } + val trip = resolveTrip(tripId = tripId, textHint = titleOrId) + val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } + + val titleWords = + titleOrId + .lowercase() + .split(Regex("[\\s,;.]+")) + .filter { it.length > 2 && it !in setOf("the", "for", "our", "and", "this") } + + var doc = allDocs.find { + it.documentId.equals(titleOrId, ignoreCase = true) || it.id.equals(titleOrId, ignoreCase = true) + } ?: allDocs.find { + it.title.equals(titleOrId, ignoreCase = true) && (targetTripId == null || it.tripId == null || it.tripId == targetTripId) + } ?: allDocs.find { + it.title.contains(titleOrId, ignoreCase = true) && (targetTripId == null || it.tripId == null || it.tripId == targetTripId) + } ?: allDocs.find { + it.title.contains(titleOrId, ignoreCase = true) + } ?: allDocs.find { candidate -> + (targetTripId == null || candidate.tripId == null || candidate.tripId == targetTripId) && + titleWords.any { word -> + candidate.title.contains(word, ignoreCase = true) || + candidate.documentType.contains(word, ignoreCase = true) || + candidate.notes.contains(word, ignoreCase = true) + } + } + + if (doc == null && titleOrId.isNotBlank()) { + try { + val searcher = + if (::semanticSearchProvider.isInitialized) semanticSearchProvider + else travelDocumentManager.semanticSearchProvider + val matches = + searcher?.findRelevantDocuments(query = titleOrId, tripId = targetTripId, maxResults = 1) + ?: travelDocumentManager.searchDocumentsSemantically(query = titleOrId, tripId = targetTripId, maxResults = 1) + doc = matches.firstOrNull() + } catch (_: Exception) {} + } + + if (doc == null) { + return@withContext TravelDocumentDetailsSerializable( + id = "not_found", + title = titleOrId, + documentType = "OTHER", + status = "NOT_FOUND", + message = "No travel document found matching '$titleOrId'.", ) - return withContext(Dispatchers.IO) { - dayThemeDao.insertTheme(dayTheme) - dayTheme.toSerializable() - } + } + + val content = travelDocumentManager.readDocumentContent(doc) + val (validity, expiryFormatted) = getDocumentValidity(doc.expiryDate, doc.documentType, doc.title) + + TravelDocumentDetailsSerializable( + id = doc.documentId.ifBlank { doc.id }, + title = doc.title, + documentType = doc.documentType, + issuingCountry = doc.issuingCountry.takeIf { it.isNotBlank() }, + documentNumberMasked = doc.documentNumberMasked.takeIf { it.isNotBlank() }, + expiryDateFormatted = expiryFormatted, + notes = doc.notes.takeIf { it.isNotBlank() }, + content = content, + validityStatus = validity, + status = "SUCCESS", + message = "Found document '${doc.title}'. ${if (doc.notes.isNotBlank()) "Notes: ${doc.notes}." else ""}", + ) } + } + + /** + * Saves a new travel note, reservation detail, Wi-Fi code, or document text into the traveler's + * document storage. Restricted to scope SELF. Call this when the user asks to save important + * trip notes, Wi-Fi codes, door access codes, or document text into their documents (e.g., 'Save our + * Airbnb door code 4829 to my documents' or 'Add a note to my documents with the conference Wi-Fi password'). + * + * @param title Title of the document or note (e.g., 'Airbnb Door Code', 'Conference Wi-Fi'). + * @param content Full content or details to store in the document. + * @param documentType Optional document type (e.g. 'OTHER', 'NOTE', 'HOTEL', 'TICKET'). Defaults to 'OTHER'. + * @param tripId Optional ID of the trip. Defaults to the active trip if omitted. + * @return The created travel document. + */ + @OptIn(ExperimentalAppFunctionsApi::class) + @AppFunctionDeclaration(isDescribedByKDoc = true) + @AppFunctionAccessLevel( + level = AppFunctionMetadata.ACCESS_LEVEL_SELF, + isCompatEnforcementEnabled = true, + ) + suspend fun saveTravelNoteDocument( + title: String, + content: String, + documentType: String? = null, + tripId: String? = null, + ): TravelDocumentSerializable { + return withContext(Dispatchers.IO) { + val trip = resolveTrip(tripId = tripId, textHint = "$title $content") + val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1" + val resolvedType = (documentType?.takeIf { it.isNotBlank() } ?: "OTHER").uppercase() - private fun Trip.toSerializable() = TripSerializable( - id = id, + val createdDoc = travelDocumentManager.createTextDocument( title = title, - location = location, - startDate = startDate, - endDate = endDate, - participants = participants + content = content, + documentType = resolvedType, + notes = content, + tripId = targetTripId, + ) + + createdDoc.toSerializable( + message = "Successfully saved '$title' to your travel documents in ${trip?.title ?: "your trip"}.", + ) + } + } + + 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 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 Trip.toSerializable() = + TripSerializable( + id = id, + title = title, + location = location, + startDate = startDate, + endDate = endDate, + participants = participants, + budget = budget, + currency = currency, ) - private fun Expense.toSerializable() = ExpenseSerializable( - id = id, - tripId = tripId, - title = title, - amount = amount, - currency = currency, - category = category, - timestamp = timestamp + 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 TimelineEvent.toSerializable() = TimelineEventSerializable( - id = id, - tripId = tripId, - type = type.name, - timestamp = timestamp, - title = title, - location = location, - description = description + 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 VoiceNoteEntity.toSerializable() = VoiceNoteSerializable( - id = id, - tripId = tripId, - title = title, - transcription = transcription, - timestamp = timestamp + 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 + private fun DayTheme.toSerializable() = + DayThemeSerializable(id = id, tripId = tripId, date = date, theme = theme) + + private fun getDocumentValidity( + expiryMillis: Long, + docType: String, + title: String, + ): Pair { + if (expiryMillis <= 0L) return Pair("NO_EXPIRY", null) + val now = System.currentTimeMillis() + val formatted = + try { + Instant.ofEpochMilli(expiryMillis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DateTimeFormatter.ISO_LOCAL_DATE) + } catch (_: Exception) { + null + } + val combined = (docType + " " + title).uppercase() + val isIdentity = combined.contains("PASSPORT") || combined.contains("VISA") || combined.contains("LICENSE") + val validity = + when { + expiryMillis < now -> "EXPIRED" + isIdentity && (expiryMillis - now < 180L * 24 * 60 * 60 * 1000) -> "EXPIRING_SOON" + else -> "VALID" + } + return Pair(validity, formatted) + } + + private fun TravelDocument.toSerializable(message: String? = null): TravelDocumentSerializable { + val (validity, expiryFormatted) = getDocumentValidity(expiryDate, documentType, title) + return TravelDocumentSerializable( + id = documentId.ifBlank { id }, + title = title, + documentType = documentType, + issuingCountry = issuingCountry.takeIf { it.isNotBlank() }, + documentNumberMasked = documentNumberMasked.takeIf { it.isNotBlank() }, + expiryDateFormatted = expiryFormatted, + notes = notes.takeIf { it.isNotBlank() }, + tripId = tripId, + validityStatus = validity, + status = "SUCCESS", + message = message ?: "Found document '$title' ($documentType).", ) + } } 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..3f7a9747 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,224 @@ 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, +) + +/** Represents a saved travel document (passport, visa, ticket, booking confirmation, or note). */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class TravelDocumentSerializable( + /** The document's unique identifier. */ + val id: String, + /** The title or name of the document (e.g. 'US Passport', 'SFMOMA Admission Ticket'). */ + val title: String, + /** The category or type of the document (e.g. 'PASSPORT', 'VISA', 'TICKET', 'HOTEL', 'OTHER'). */ + val documentType: String, + /** The issuing country or authority if applicable. */ + val issuingCountry: String? = null, + /** The masked document number or confirmation reference if applicable. */ + val documentNumberMasked: String? = null, + /** The expiration date formatted as YYYY-MM-DD, or null if none. */ + val expiryDateFormatted: String? = null, + /** Notes, booking codes, Wi-Fi passwords, or important details. */ + val notes: String? = null, + /** The trip ID this document belongs to, or null if general. */ + val tripId: String? = null, + /** Validity status ('VALID', 'EXPIRING_SOON', 'EXPIRED', 'NO_EXPIRY'). */ + val validityStatus: String? = "VALID", + /** Status of the tool execution. */ + val status: String? = "SUCCESS", + /** User-friendly completion message. */ + val message: String? = null, +) + +/** Represents detailed content and text extracted from a travel document. */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class TravelDocumentDetailsSerializable( + /** The document's unique identifier. */ + val id: String, + /** The title of the document. */ + val title: String, + /** The category or type of the document. */ + val documentType: String, + /** The issuing country if applicable. */ + val issuingCountry: String? = null, + /** The masked document number or confirmation code. */ + val documentNumberMasked: String? = null, + /** The expiration date formatted as YYYY-MM-DD. */ + val expiryDateFormatted: String? = null, + /** Notes or extra information stored with the document. */ + val notes: String? = null, + /** Full textual content or extracted document text. */ + val content: String? = null, + /** Validity status ('VALID', 'EXPIRING_SOON', 'EXPIRED', 'NO_EXPIRY'). */ + val validityStatus: String? = "VALID", + /** Status of the tool execution. */ + val status: String? = "SUCCESS", + /** User-friendly completion message. */ + val message: 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..b96b9296 --- /dev/null +++ b/jetpacker/android/feature/assistant/build.gradle.kts @@ -0,0 +1,91 @@ +/* + * 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") +} + +tasks.withType().configureEach { + jvmArgs("-Xverify:none") +} + +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..729ad0f1 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt @@ -0,0 +1,1722 @@ +/* + * 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.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager +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.documents.DocumentSemanticSearch +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.TravelDocumentHighlight +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 travelDocumentDao: TravelDocumentDao, + private val travelDocumentManager: TravelDocumentManager, + private val expenseDao: ExpenseDao? = null, + private val documentSemanticSearch: DocumentSemanticSearch, + 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 var simulatedVoiceJob: Job? = null + + 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)) { + simulatedVoiceJob?.cancel() + simulatedVoiceJob = null + clearChat() + viewModelScope.launch { + conversationDao?.deleteAllConversations() + if (intent.getBooleanExtra("reset_expenses", true)) { + expenseDao?.deleteAllExpenses() + DummyData.expenses.forEach { expenseDao?.insertExpense(it) } + } + } + return + } + if (intent.getBooleanExtra("listen", false)) { + simulatedVoiceJob?.cancel() + simulatedVoiceJob = null + 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()) { + simulatedVoiceJob?.cancel() + val wordDelayMs = intent.getLongExtra("word_delay_ms", 260L) + val preDelayMs = intent.getLongExtra("pre_delay_ms", 2200L) + val postDelayMs = intent.getLongExtra("post_delay_ms", 2200L) + simulatedVoiceJob = viewModelScope.launch { + val currentOverlay = _overlayState.value + val isFollowUp = + currentOverlay is AssistantOverlayState.Conversational && + _conversationTurns.value.isNotEmpty() + + if (!isFollowUp) { + sessionId = "trip-session-${currentTripId ?: "default"}-${UUID.randomUUID()}" + _conversationTurns.value = emptyList() + // 1. Show listening state (empty transcript) representing initial mic activation + _overlayState.value = AssistantOverlayState.Listening(liveTranscript = "") + } else { + // 1. In follow-up conversational mode, keep turns visible and activate listening in follow-up slot + _overlayState.value = + (currentOverlay as AssistantOverlayState.Conversational).copy( + isListening = true, + liveTranscript = "", + ) + } + delay(preDelayMs) + + // 2. Stream words one by one with realistic speech pacing + val words = fakeVoice.split(" ") + val accumulated = StringBuilder() + for (i in words.indices) { + if (i > 0) accumulated.append(" ") + accumulated.append(words[i]) + if (!isFollowUp) { + _overlayState.value = + AssistantOverlayState.Listening(liveTranscript = accumulated.toString()) + } else { + val conv = + _overlayState.value as? AssistantOverlayState.Conversational + ?: (currentOverlay as AssistantOverlayState.Conversational) + _overlayState.value = + conv.copy( + isListening = true, + liveTranscript = accumulated.toString(), + ) + } + delay(wordDelayMs) + } + + // 3. Pause between saying the final word and pressing stop / submitting prompt + delay(postDelayMs) + + // 4. Submit prompt for on-device reasoning and tool execution + sendPrompt(fakeVoice) + } + return + } + val prompt = intent.getStringExtra("prompt")?.trim().orEmpty() + if (prompt.isNotEmpty()) { + simulatedVoiceJob?.cancel() + simulatedVoiceJob = null + sendPrompt(prompt) + } + } + } + + /** Starts a new voice interaction from the bottom-right AI Assistant FAB. */ + fun startVoiceAssistant(onPermissionRequired: () -> Unit) { + simulatedVoiceJob?.cancel() + simulatedVoiceJob = null + 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 + travelDocumentManager.semanticSearchProvider = documentSemanticSearch + viewModelScope.launch { travelDocumentManager.seedDefaultDocumentsIfEmpty() } + 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. TRAVEL DOCUMENTS & NOTES (Scope SELF):\n" + + " - When the user asks about their saved documents, tickets, passport expiry, hotel booking details, or Wi-Fi passwords (e.g. 'What is the Wi-Fi password for our hotel?', 'Is my passport valid for this trip?', 'Show my museum pass details', 'Do I have tickets for SFMOMA?'):\n" + + " * Call searchTravelDocuments or getTravelDocumentDetails.\n" + + " * Answer clearly in 1–2 friendly sentences using the document's notes or details.\n" + + " - When the user asks to save a document note or secret (e.g. 'Save our Airbnb door code 4829 to my documents' or 'Save the conference Wi-Fi password in my travel docs'):\n" + + " * Call saveTravelNoteDocument(title=..., content=...).\n" + + " * Confirm in 1 friendly sentence.\n" + + "7. CRITICAL EXECUTION RULE: Once a mutation tool (addExpense, addVoiceNote, addItineraryEvent, or saveTravelNoteDocument) 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" || cleanName == "saveTravelNoteDocument") { + _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", "saveTravelNoteDocument") + } + val isRedundantPostMutationRead = + hasPriorMutation && + effectiveStepToolName in setOf("getExpenses", "getActiveTrip", "searchTrip") + val isRedundantDuplicateMutation = + hasPriorMutation && + cleanName in setOf("addExpense", "addVoiceNote", "addItineraryEvent", "saveTravelNoteDocument") + + 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" || cleanToolName == "saveTravelNoteDocument") { + _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) { + "searchTravelDocuments", + "getTravelDocumentDetails", + "saveTravelNoteDocument", + "check_document_validity" -> { + val docItem = + (payload["result"] as? List<*>)?.firstOrNull() as? Map<*, *> + ?: (event.response["result"] as? List<*>)?.firstOrNull() as? Map<*, *> + ?: payload + val title = docItem["title"]?.toString() ?: "" + if (title.isNotBlank()) { + val status = docItem["validityStatus"]?.toString() ?: docItem["status"]?.toString() ?: "VALID" + val explanation = docItem["message"]?.toString() ?: docItem["notes"]?.toString() ?: "" + val docType = docItem["documentType"]?.toString() ?: "PASSPORT" + val expiryLong = + (docItem["expiryDateLong"] as? Number)?.toLong() + ?: (docItem["expiryDate"] as? Number)?.toLong() + ?: 0L + val docNum = docItem["documentNumberMasked"]?.toString() ?: "" + val country = docItem["issuingCountry"]?.toString() ?: "" + val docId = docItem["id"]?.toString() ?: docItem["documentId"]?.toString() ?: "" + val notes = docItem["notes"]?.toString() ?: "" + val doc = + TravelDocument( + documentId = docId, + documentType = docType, + issuingCountry = country, + expiryDate = expiryLong, + documentNumberMasked = docNum, + title = title, + notes = notes, + ) + activeHighlightedEntity = + TravelDocumentHighlight( + document = doc, + validityStatus = status, + validityExplanation = explanation, + ) + } + } + "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() { + simulatedVoiceJob?.cancel() + simulatedVoiceJob = null + 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 + lower.contains("passport") || lower.contains("visa") || lower.contains("document") -> + ActionStepCategory.DOCUMENT + 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…", + ) + "check_document_validity", + "get_travel_document", + "read_document_content", + "searchTravelDocuments", + "getTravelDocumentDetails" -> + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.IN_PROGRESS, + title = "Checking documents…", + ) + "saveTravelNoteDocument", + "add_travel_document" -> + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.IN_PROGRESS, + title = "Saving document…", + ) + 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", + ) + } + "searchTravelDocuments" -> { + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.COMPLETED, + title = "Searched travel documents", + ) + } + "getTravelDocumentDetails" -> { + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.COMPLETED, + title = "Found document details", + ) + } + "saveTravelNoteDocument" -> { + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.COMPLETED, + title = "Saved travel document", + ) + } + "check_document_validity", + "get_travel_document", + "read_document_content", + "add_travel_document" -> + UserActionStep( + id = id, + category = ActionStepCategory.DOCUMENT, + status = ActionStepStatus.COMPLETED, + title = "Checked documents", + ) + 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/documents/DocumentAppSearchManager.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentAppSearchManager.kt new file mode 100644 index 00000000..58372538 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentAppSearchManager.kt @@ -0,0 +1,426 @@ +/* + * 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.documents + +import android.content.Context +import android.util.Log +import androidx.appsearch.app.AppSearchSchema +import androidx.appsearch.app.AppSearchSession +import androidx.appsearch.app.EmbeddingVector +import androidx.appsearch.app.GenericDocument +import androidx.appsearch.app.GetByDocumentIdRequest +import androidx.appsearch.app.PutDocumentsRequest +import androidx.appsearch.app.RemoveByDocumentIdRequest +import androidx.appsearch.app.SearchSpec +import androidx.appsearch.app.SetSchemaRequest +import androidx.appsearch.localstorage.LocalStorage +import androidx.concurrent.futures.await +import com.example.jetpacker.data.itinerary.TravelDocument +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Manages the on-device Jetpack AppSearch index for travel documents. + * + * Provides high-performance, native hybrid search combining: + * 1. BM25 text prefix matching on title, document type, and notes. + * 2. Hardware-optimized vector similarity (cosine / dot-product) using [EmbeddingVector] properties + * with 8-bit quantization. + * 3. Model signature validation to cleanly handle on-device embedding model upgrades (e.g. v1 to + * v2). + */ +@Singleton +class DocumentAppSearchManager +@Inject +constructor(@ApplicationContext private val context: Context) { + private val sessionMutex = Mutex() + @Volatile private var session: AppSearchSession? = null + @Volatile private var isInitialized = false + + suspend fun getSession(): AppSearchSession? { + if (isInitialized && session != null) return session + return sessionMutex.withLock { + if (isInitialized && session != null) return@withLock session + try { + val searchContext = LocalStorage.SearchContext.Builder(context, DATABASE_NAME).build() + val s = LocalStorage.createSearchSessionAsync(searchContext).await() + setSchemas(s) + session = s + isInitialized = true + s + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to initialize AppSearch session", e) + null + } + } + } + + private suspend fun setSchemas(s: AppSearchSession) { + val documentSchema = + AppSearchSchema.Builder(SCHEMA_TYPE_DOCUMENT) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_TITLE) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES) + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_PLAIN) + .build() + ) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_DOCUMENT_TYPE) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES) + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_PLAIN) + .build() + ) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_TEXT_CONTENT) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES) + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_PLAIN) + .build() + ) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_TRIP_ID) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_EXACT_TERMS) + // Verbatim keeps IDs like "2026-3" as a single token so trip scoping is exact + // instead of matching the sub-tokens "2026" and "3". + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_VERBATIM) + .build() + ) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_ISSUING_COUNTRY) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES) + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_PLAIN) + .build() + ) + .addProperty( + AppSearchSchema.EmbeddingPropertyConfig.Builder(PROPERTY_EMBEDDING) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setIndexingType(AppSearchSchema.EmbeddingPropertyConfig.INDEXING_TYPE_SIMILARITY) + .setQuantizationType(AppSearchSchema.EmbeddingPropertyConfig.QUANTIZATION_TYPE_8_BIT) + .build() + ) + .build() + + val metadataSchema = + AppSearchSchema.Builder(SCHEMA_TYPE_METADATA) + .addProperty( + AppSearchSchema.StringPropertyConfig.Builder(PROPERTY_MODEL_SIGNATURE) + .setCardinality(AppSearchSchema.PropertyConfig.CARDINALITY_OPTIONAL) + .setTokenizerType(AppSearchSchema.StringPropertyConfig.TOKENIZER_TYPE_NONE) + .setIndexingType(AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_NONE) + .build() + ) + .build() + + val request = + SetSchemaRequest.Builder() + .addSchemas(documentSchema, metadataSchema) + .setForceOverride(true) + .build() + s.setSchemaAsync(request).await() + } + + /** + * Retrieves the stored model signature from the AppSearch metadata document, or null if no model + * signature has been recorded. + */ + suspend fun getStoredModelSignature(): String? { + val s = getSession() ?: return null + val req = GetByDocumentIdRequest.Builder(NAMESPACE).addIds(METADATA_ID).build() + return try { + val res = s.getByDocumentIdAsync(req).await() + res.successes[METADATA_ID]?.getPropertyString(PROPERTY_MODEL_SIGNATURE) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + + /** Records the active embedding model signature in AppSearch metadata. */ + suspend fun setStoredModelSignature(signature: String) { + val s = getSession() ?: return + val doc = + GenericDocument.Builder>( + NAMESPACE, + METADATA_ID, + SCHEMA_TYPE_METADATA, + ) + .setPropertyString(PROPERTY_MODEL_SIGNATURE, signature) + .build() + val req = PutDocumentsRequest.Builder().addGenericDocuments(doc).build() + try { + s.putAsync(req).await() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to write metadata signature", e) + } + } + + /** Indexes a [TravelDocument] into AppSearch with optional vector embedding. */ + suspend fun indexDocument( + doc: TravelDocument, + textContent: String, + vector: FloatArray? = null, + modelSignature: String? = null, + ) { + val s = getSession() ?: return + val builder = + GenericDocument.Builder>(NAMESPACE, doc.id, SCHEMA_TYPE_DOCUMENT) + .setPropertyString(PROPERTY_TITLE, doc.title) + .setPropertyString(PROPERTY_DOCUMENT_TYPE, doc.documentType) + .setPropertyString(PROPERTY_TEXT_CONTENT, textContent) + .setPropertyString( + PROPERTY_TRIP_ID, + doc.tripId?.takeIf { it.isNotBlank() } ?: GLOBAL_TRIP_TOKEN, + ) + .setPropertyString(PROPERTY_ISSUING_COUNTRY, doc.issuingCountry) + + if (vector != null && vector.isNotEmpty() && !modelSignature.isNullOrBlank()) { + builder.setPropertyEmbedding(PROPERTY_EMBEDDING, EmbeddingVector(vector, modelSignature)) + } + + val req = PutDocumentsRequest.Builder().addGenericDocuments(builder.build()).build() + try { + val res = s.putAsync(req).await() + if (res.failures.isNotEmpty()) { + Log.w(TAG, "AppSearch put failures: ${res.failures}") + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to index document ${doc.id}", e) + } + } + + /** + * Performs hybrid semantic vector and full-text search against indexed travel documents. + * + * @param query The natural language user query. + * @param queryVector The query embedding float array, if available. + * @param modelSignature The signature of the embedding model that produced [queryVector]. + * @param tripId Optional trip ID to restrict matches to. Documents belonging to the trip and + * global documents (those with no trip) are both returned. + * @param maxResults Maximum number of matched document IDs to return. + * @return List of matching [TravelDocument.id] strings in descending score order. + */ + suspend fun search( + query: String, + queryVector: FloatArray? = null, + modelSignature: String? = null, + tripId: String? = null, + maxResults: Int = 5, + ): List { + val s = getSession() ?: return emptyList() + + val useVectors = + queryVector != null && queryVector.isNotEmpty() && !modelSignature.isNullOrBlank() + val sanitizedQuery = query.replace(Regex("""[^\w\s]"""), " ").trim() + + val matchQuery: String = + if (useVectors) { + if (sanitizedQuery.isBlank()) { + "semanticSearch(getEmbeddingParameter(0), $SEMANTIC_SIMILARITY_FLOOR)" + } else { + "(($sanitizedQuery) OR semanticSearch(getEmbeddingParameter(0), $SEMANTIC_SIMILARITY_FLOOR))" + } + } else { + sanitizedQuery.ifBlank { + return emptyList() + } + } + + val tripScope = buildTripScopeClause(tripId) + + // Primary attempt: scope the query to the active trip plus global documents. + if (tripScope != null) { + val scoped = + runSearch( + s, + "$tripScope AND $matchQuery", + queryVector, + modelSignature, + useVectors, + maxResults, + ) + if (scoped != null) return scoped + Log.w(TAG, "Trip-scoped AppSearch query failed; retrying without the trip restrict") + } + + return runSearch(s, matchQuery, queryVector, modelSignature, useVectors, maxResults) + ?: emptyList() + } + + /** + * Builds an AppSearch property restrict clause matching the trip plus global documents, or null + * when no trip scoping is requested or the ID is not safely expressible. + */ + private fun buildTripScopeClause(tripId: String?): String? { + if (tripId.isNullOrBlank()) return null + // Verbatim terms are quoted; reject IDs containing quotes/backslashes rather than risk + // producing a malformed query that AppSearch would reject at parse time. + if (tripId.any { it == '"' || it == '\\' }) return null + return """($PROPERTY_TRIP_ID:"$tripId" OR $PROPERTY_TRIP_ID:"$GLOBAL_TRIP_TOKEN")""" + } + + /** + * Executes a single AppSearch query, returning null if the query itself could not be run so the + * caller can fall back to a simpler form. + */ + private suspend fun runSearch( + s: AppSearchSession, + searchQuery: String, + queryVector: FloatArray?, + modelSignature: String?, + useVectors: Boolean, + maxResults: Int, + ): List? { + val specBuilder = SearchSpec.Builder().addFilterSchemas(SCHEMA_TYPE_DOCUMENT) + specBuilder.setResultCountPerPage(maxResults) + specBuilder.setListFilterQueryLanguageEnabled(true) + // The trip restrict quotes the trip ID as a verbatim term; without this the quoted term is + // not honoured and the restrict silently matches nothing useful. + specBuilder.setVerbatimSearchEnabled(true) + + if (useVectors && queryVector != null && !modelSignature.isNullOrBlank()) { + specBuilder.setDefaultEmbeddingSearchMetricType( + SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_COSINE + ) + specBuilder.addEmbeddingParameters(EmbeddingVector(queryVector, modelSignature)) + // Hybrid ranking: cosine similarity + text relevance + specBuilder.setRankingStrategy( + "sum(this.matchedSemanticScores(getEmbeddingParameter(0))) + 0.3 * this.relevanceScore()" + ) + } + + val searchResults = + try { + s.search(searchQuery, specBuilder.build()) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "AppSearch search failed for query: $searchQuery", e) + return null + } + + return try { + val page = searchResults.nextPageAsync.await() + page.map { it.genericDocument.id } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "AppSearch results iteration failed", e) + null + } finally { + searchResults.close() + } + } + + /** + * Returns the IDs of every document currently held in the AppSearch index. + * + * Used to prune entries whose backing Room rows have been deleted. + */ + suspend fun getIndexedDocumentIds(limit: Int = MAX_INDEXED_IDS): Set { + val s = getSession() ?: return emptySet() + val spec = + SearchSpec.Builder() + .addFilterSchemas(SCHEMA_TYPE_DOCUMENT) + .setResultCountPerPage(limit) + .build() + // An empty query string matches every document of the filtered schema. + val searchResults = + try { + s.search("", spec) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Failed to enumerate indexed documents", e) + return emptySet() + } + + return try { + searchResults.nextPageAsync.await().map { it.genericDocument.id }.toSet() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Failed to read indexed document page", e) + emptySet() + } finally { + searchResults.close() + } + } + + /** Removes a document from the AppSearch index by document ID. */ + suspend fun removeDocument(id: String) { + val s = getSession() ?: return + val req = RemoveByDocumentIdRequest.Builder(NAMESPACE).addIds(id).build() + try { + s.removeAsync(req).await() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to remove document $id from AppSearch", e) + } + } + + /** Clears all indexed documents and metadata from the AppSearch index. */ + suspend fun clearAll() { + val s = getSession() ?: return + val spec = + SearchSpec.Builder().addFilterSchemas(SCHEMA_TYPE_DOCUMENT, SCHEMA_TYPE_METADATA).build() + try { + s.removeAsync("", spec).await() + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "Failed to clear AppSearch index", e) + } + } + + companion object { + private const val TAG = "DocAppSearchManager" + const val DATABASE_NAME = "jetpacker_documents" + const val NAMESPACE = "travel_documents" + const val METADATA_ID = "db_metadata" + + const val SCHEMA_TYPE_DOCUMENT = "TravelDocument" + const val SCHEMA_TYPE_METADATA = "DbMetadata" + + const val PROPERTY_TITLE = "title" + const val PROPERTY_DOCUMENT_TYPE = "documentType" + const val PROPERTY_TEXT_CONTENT = "textContent" + const val PROPERTY_TRIP_ID = "tripId" + const val PROPERTY_ISSUING_COUNTRY = "issuingCountry" + const val PROPERTY_EMBEDDING = "embedding" + const val PROPERTY_MODEL_SIGNATURE = "modelSignature" + + /** + * Sentinel trip token written for documents that are not tied to a specific trip, so that + * trip-scoped queries can include them with a single property restrict. + */ + const val GLOBAL_TRIP_TOKEN = "global" + + /** Minimum cosine similarity a document must reach to be considered a semantic match. */ + const val SEMANTIC_SIMILARITY_FLOOR = "0.15" + + /** + * Upper bound on IDs returned by [getIndexedDocumentIds]; the vault is a small local corpus. + */ + const val MAX_INDEXED_IDS = 500 + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearch.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearch.kt new file mode 100644 index 00000000..b755873f --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearch.kt @@ -0,0 +1,487 @@ +/* + * 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.documents + +import android.util.Log +import com.example.jetpacker.data.itinerary.DocumentSemanticSearchProvider +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager +import com.google.mlkit.genai.embedding.Content +import com.google.mlkit.genai.embedding.EmbeddingGeneration +import com.google.mlkit.genai.embedding.EmbeddingModel +import com.google.mlkit.genai.embedding.EmbeddingRequest +import com.google.mlkit.genai.embedding.ImagePart +import com.google.mlkit.genai.embedding.Modality +import com.google.mlkit.genai.embedding.Part +import com.google.mlkit.genai.embedding.TaskConfig +import com.google.mlkit.genai.embedding.TextPart +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** + * Coordinates on-device semantic document search by bridging the ML Kit Embedding API (Embedding + * Gemma v2) with Jetpack AppSearch native vector indexing and retrieval. + * + * Implements Google's canonical on-device RAG architecture: + * 1. Room serves as the relational source of truth. + * 2. Jetpack AppSearch provides hardware-accelerated hybrid search (BM25 + cosine vector + * similarity). + * 3. ML Kit Embedding API generates high-dimensional embeddings on-device. + */ +@Singleton +class DocumentSemanticSearch +@Inject +constructor( + private val travelDocumentDao: TravelDocumentDao, + private val travelDocumentManager: TravelDocumentManager, + private val appSearchManager: DocumentAppSearchManager? = null, +) : DocumentSemanticSearchProvider { + + /** + * Vector cache keyed by the document's embedding text, so that re-indexing an unchanged document + * never re-runs the embedding model. + */ + private val embeddingCache = ConcurrentHashMap() + + /** + * Content hash of the payload most recently written to AppSearch, keyed by document ID. This + * replaces a one-shot "synced" latch so that documents added after the first query (for example, + * a document scanned live on stage) are picked up immediately. + */ + private val indexedContentHashes = ConcurrentHashMap() + + private val indexMutex = Mutex() + + @Volatile private var cachedModel: EmbeddingModel? = null + @Volatile private var modelChecked = false + @Volatile private var signatureVerified = false + + @androidx.annotation.VisibleForTesting + internal fun setEmbeddingModelForTesting(model: EmbeddingModel?) { + cachedModel = model + modelChecked = true + signatureVerified = false + } + + @androidx.annotation.VisibleForTesting + internal fun isDocumentHashIndexed(docId: String): Boolean = + indexedContentHashes.containsKey(docId) + + init { + travelDocumentManager.semanticSearchProvider = this + Log.i( + TAG, + if (appSearchManager != null) { + "Vault retrieval: AppSearch hybrid index enabled" + } else { + "Vault retrieval: no AppSearch index, falling back to in-memory scoring" + }, + ) + } + + private suspend fun getEmbeddingModel(): EmbeddingModel? { + if (modelChecked) return cachedModel + return try { + val models = EmbeddingGeneration.getEmbeddingModels() + val model = models.firstOrNull() + cachedModel = model + modelChecked = true + model + } catch (e: Throwable) { + Log.w(TAG, "Embedding model not available on device", e) + modelChecked = true + null + } + } + + private suspend fun getModelSignature(model: EmbeddingModel?): String? = model?.let { + runCatching { it.getModelSignature() }.getOrNull() + } + + /** + * Finds the most relevant travel documents for a natural language query. + * + * Scopes search to the specified [tripId] plus global documents (where [TravelDocument.tripId] is + * null). Queries Jetpack AppSearch using native hybrid semantic search when available; falls back + * to token and metadata similarity scoring if AppSearch or ML models are unavailable. + * + * @param query Natural language user query (e.g. "passport", "flight ticket to Tokyo", "health + * insurance"). + * @param tripId Optional trip ID to restrict results to. + * @param maxResults Maximum number of documents to return. + */ + override suspend fun findRelevantDocuments( + query: String, + tripId: String?, + maxResults: Int, + ): List = + withContext(Dispatchers.IO) { + val trimmedQuery = query.trim() + if (trimmedQuery.isBlank()) return@withContext emptyList() + + val allDocs = travelDocumentDao.getAllDocuments().first() + if (allDocs.isEmpty()) return@withContext emptyList() + + // Filter to documents in scope: matching tripId OR global documents (tripId == null) + val scopedDocs = + if (!tripId.isNullOrBlank()) { + allDocs.filter { it.tripId == null || it.tripId == tripId } + } else { + allDocs + } + + if (scopedDocs.isEmpty()) return@withContext emptyList() + + val model = getEmbeddingModel() + val signature = getModelSignature(model) + + // Primary retrieval engine: Jetpack AppSearch hybrid search + if (appSearchManager != null) { + try { + syncIndex(allDocs, model, signature) + + val queryVector = + if (model != null) { + val queryReq = + EmbeddingRequest( + content = Content(listOf(TextPart(trimmedQuery))), + taskConfig = TaskConfig.RetrievalQuery(), + ) + model.generateEmbedding(queryReq).embedding.vector + } else { + null + } + + val matchedIds = + appSearchManager.search( + query = trimmedQuery, + queryVector = queryVector, + modelSignature = signature, + tripId = tripId, + maxResults = maxResults, + ) + + if (matchedIds.isNotEmpty()) { + val scopedMap = scopedDocs.associateBy { it.id } + val matchedDocs = matchedIds.mapNotNull { scopedMap[it] } + if (matchedDocs.isNotEmpty()) { + return@withContext matchedDocs.take(maxResults) + } + } + } catch (e: Exception) { + Log.w(TAG, "AppSearch hybrid query failed; falling back to in-memory/keyword search", e) + } + } + + // Secondary fallback: in-memory cosine loop if model is present, otherwise keyword scoring + if (model != null) { + try { + return@withContext searchWithEmbeddings(model, trimmedQuery, scopedDocs, maxResults) + } catch (e: Exception) { + Log.w(TAG, "Embedding search failed; falling back to keyword search", e) + } + } + + searchWithKeywords(trimmedQuery, scopedDocs, maxResults) + } + + /** + * Ensures the AppSearch index reflects the current contents of Room. + * + * Safe to call repeatedly: unchanged documents are skipped, and documents that no longer exist in + * Room are pruned from the index. + */ + suspend fun ensureIndexUpToDate() = + withContext(Dispatchers.IO) { + if (appSearchManager == null) return@withContext + val docs = travelDocumentDao.getAllDocuments().first() + val model = getEmbeddingModel() + syncIndex(docs, model, getModelSignature(model)) + } + + override suspend fun clearIndex() { + withContext(Dispatchers.IO) { + indexMutex.withLock { + appSearchManager?.clearAll() + indexedContentHashes.clear() + embeddingCache.clear() + } + } + } + + override suspend fun syncIndex() { + ensureIndexUpToDate() + } + + /** Removes a single document from the AppSearch index, for example after it is deleted. */ + suspend fun removeFromIndex(docId: String) = + withContext(Dispatchers.IO) { + val manager = appSearchManager ?: return@withContext + indexMutex.withLock { + manager.removeDocument(docId) + indexedContentHashes.remove(docId) + } + Unit + } + + /** + * Incrementally syncs Room documents into AppSearch, generating embeddings and validating the + * embedding model signature. + */ + private suspend fun syncIndex( + docs: List, + model: EmbeddingModel?, + signature: String?, + ) { + val manager = appSearchManager ?: return + + indexMutex.withLock { + if (!signatureVerified && !signature.isNullOrBlank()) { + verifyModelSignature(manager, signature) + signatureVerified = true + } + + for (doc in docs) { + val text = buildDocumentEmbeddingText(doc) + val contentHash = contentHashOf(doc, text) + if (indexedContentHashes[doc.id] == contentHash) continue + + val vector = + if (model != null && !signature.isNullOrBlank()) { + embedDocument(doc, model, text) + } else { + null + } + manager.indexDocument(doc, text, vector, signature) + if (vector != null) { + indexedContentHashes[doc.id] = contentHash + } + } + + pruneDeletedDocuments(manager, docs.mapTo(mutableSetOf()) { it.id }) + } + } + + /** + * Drops AppSearch entries whose Room rows are gone, so a deleted document can never resurface as + * a search hit. + */ + private suspend fun pruneDeletedDocuments( + manager: DocumentAppSearchManager, + liveIds: Set, + ) { + val indexedIds = runCatching { manager.getIndexedDocumentIds() }.getOrNull() ?: return + for (staleId in indexedIds - liveIds) { + manager.removeDocument(staleId) + indexedContentHashes.remove(staleId) + } + } + + /** + * Clears the index when the embedding model changed, because vectors produced by different models + * are not comparable, then records the current signature. + */ + private suspend fun verifyModelSignature(manager: DocumentAppSearchManager, signature: String?) { + val storedSignature = manager.getStoredModelSignature() + if (signature != null && storedSignature != null && storedSignature != signature) { + Log.i( + TAG, + "Embedding model signature changed ($storedSignature -> $signature); clearing stale index", + ) + manager.clearAll() + indexedContentHashes.clear() + embeddingCache.clear() + } + if (signature != null && storedSignature != signature) { + manager.setStoredModelSignature(signature) + } + } + + /** Includes file path, MIME type, and trip/event linkage so changes force a re-index. */ + private fun contentHashOf(doc: TravelDocument, text: String): Int = + "$text|${doc.filePath}|${doc.mimeType}|${doc.tripId.orEmpty()}|${doc.eventId.orEmpty()}" + .hashCode() + + /** Returns the cached vector for [doc], generating and caching it on first use. */ + private suspend fun embedDocument( + doc: TravelDocument, + model: EmbeddingModel, + text: String, + ): FloatArray? { + val cacheKey = "$text|${doc.filePath}" + embeddingCache[cacheKey]?.let { cached -> + return cached.takeIf { it.isNotEmpty() } + } + val vector = generateDocumentEmbedding(doc, model) + embeddingCache[cacheKey] = vector ?: FloatArray(0) + return vector + } + + private suspend fun supportsImageModality(model: EmbeddingModel): Boolean = + runCatching { model.getMetadata()?.supportedModalities?.contains(Modality.IMAGE) == true } + .getOrDefault(false) + + /** + * Generates a vector embedding for a travel document using the ML Kit GenAI Embedding API. + * + * When the document is backed by an image or PDF and the active [EmbeddingModel] supports + * [Modality.IMAGE] (Embedding Gemma v2), passes both [TextPart] and [ImagePart] (decoded image or + * rendered PDF page 0) so visual content and layout are embedded directly alongside text. Falls + * back gracefully to text-only embedding if multimodal inference is unsupported or fails. + */ + suspend fun generateDocumentEmbedding(doc: TravelDocument, model: EmbeddingModel): FloatArray? { + val docText = buildDocumentEmbeddingText(doc) + val taskConfig = TaskConfig.RetrievalDocument(title = doc.title.takeIf { it.isNotBlank() }) + + if (travelDocumentManager.isVisualDocument(doc) && supportsImageModality(model)) { + val bitmap = travelDocumentManager.loadDocumentBitmap(doc, maxDimension = 768) + if (bitmap != null) { + try { + return ImagePart(bitmap).use { imagePart -> + val multimodalReq = + EmbeddingRequest( + content = Content(listOf(TextPart(docText), imagePart)), + taskConfig = taskConfig, + ) + model.generateEmbedding(multimodalReq).embedding.vector + } + } catch (e: Exception) { + Log.w( + TAG, + "Multimodal ImagePart embedding failed for ${doc.id}; falling back to text-only", + e, + ) + } + } + } + + val textReq = + EmbeddingRequest(content = Content(listOf(TextPart(docText))), taskConfig = taskConfig) + return try { + val docRes = model.generateEmbedding(textReq) + docRes.embedding.vector + } catch (e: Exception) { + Log.w(TAG, "Failed to generate embedding for doc ${doc.id}", e) + null + } + } + + /** + * Indexes or updates a single document in the AppSearch index. + * + * Call this immediately after a document is scanned, created, imported or re-linked so that it is + * retrievable without waiting for the next full sync. + */ + suspend fun indexDocument(doc: TravelDocument) = + withContext(Dispatchers.IO) { + val manager = appSearchManager ?: return@withContext + val text = buildDocumentEmbeddingText(doc) + val model = getEmbeddingModel() + val signature = getModelSignature(model) + indexMutex.withLock { + val vector = + if (model != null && !signature.isNullOrBlank()) embedDocument(doc, model, text) else null + manager.indexDocument(doc, text, vector, signature) + if (vector != null) { + indexedContentHashes[doc.id] = contentHashOf(doc, text) + } + } + Unit + } + + private suspend fun searchWithEmbeddings( + model: EmbeddingModel, + query: String, + docs: List, + maxResults: Int, + ): List { + val queryReq = + EmbeddingRequest( + content = Content(listOf(TextPart(query))), + taskConfig = TaskConfig.RetrievalQuery(), + ) + val queryRes = model.generateEmbedding(queryReq) + val queryVector = queryRes.embedding.vector + + val scored = mutableListOf>() + + for (doc in docs) { + val docVector = embedDocument(doc, model, buildDocumentEmbeddingText(doc)) ?: continue + if (docVector.isNotEmpty()) { + val similarity = cosineSimilarity(queryVector, docVector) + scored.add(doc to similarity) + } + } + + return scored.sortedByDescending { it.second }.take(maxResults).map { it.first } + } + + private fun searchWithKeywords( + query: String, + docs: List, + maxResults: Int, + ): List { + val queryTerms = query.lowercase().split(Regex("""\s+""")).filter { it.length >= 2 } + if (queryTerms.isEmpty()) return docs.take(maxResults) + + val scored = docs.map { doc -> + val searchable = buildDocumentEmbeddingText(doc).lowercase() + var score = 0 + for (term in queryTerms) { + if (doc.documentType.lowercase() == term) score += 5 + if (doc.title.lowercase().contains(term)) score += 3 + if (searchable.contains(term)) score += 1 + } + doc to score + } + + return scored + .filter { it.second > 0 } + .sortedByDescending { it.second } + .take(maxResults) + .map { it.first } + .ifEmpty { docs.take(maxResults) } + } + + internal fun buildDocumentEmbeddingText(doc: TravelDocument): String = + travelDocumentManager.readDocumentEmbeddingText(doc) + + private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float { + if (a.isEmpty() || b.isEmpty() || a.size != b.size) return 0f + var dot = 0f + var normA = 0f + var normB = 0f + for (i in a.indices) { + dot += a[i] * b[i] + normA += a[i] * a[i] + normB += b[i] * b[i] + } + val denom = Math.sqrt((normA * normB).toDouble()).toFloat() + return if (denom > 0f) dot / denom else 0f + } + + companion object { + private const val TAG = "DocSemanticSearch" + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearchModule.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearchModule.kt new file mode 100644 index 00000000..040abdb5 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentSemanticSearchModule.kt @@ -0,0 +1,34 @@ +/* + * 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.documents + +import com.example.jetpacker.data.itinerary.DocumentSemanticSearchProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DocumentSemanticSearchModule { + @Binds + @Singleton + abstract fun bindDocumentSemanticSearchProvider( + impl: DocumentSemanticSearch, + ): DocumentSemanticSearchProvider +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentVisionScanner.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentVisionScanner.kt new file mode 100644 index 00000000..9f340a49 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentVisionScanner.kt @@ -0,0 +1,257 @@ +/* + * 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.documents + +import android.graphics.Bitmap +import android.util.Log +import com.google.mlkit.genai.common.GenAiException +import com.google.mlkit.genai.prompt.Generation +import com.google.mlkit.genai.prompt.ImagePart +import com.google.mlkit.genai.prompt.ModelPreference +import com.google.mlkit.genai.prompt.ModelReleaseStage +import com.google.mlkit.genai.prompt.TextPart +import com.google.mlkit.genai.prompt.generateContentRequest +import com.google.mlkit.genai.prompt.generationConfig +import com.google.mlkit.genai.prompt.modelConfig +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import org.json.JSONObject + +/** Structured data extracted from a travel document photo by on-device Gemini Nano. */ +data class ExtractedDocumentData( + val title: String, + val documentType: String, + val documentNumber: String = "", + val issuingCountry: String = "", + val expiryDateEpochMs: Long = 0L, + val summary: String = "", +) + +/** + * On-device Gemini Nano multimodal vision extractor for travel documents, driver licenses, and + * vouchers. + */ +@Singleton +class DocumentVisionScanner @Inject constructor() { + + companion object { + private const val TAG = "DocumentVisionScanner" + } + + private val generativeModel by lazy { + val previewFastConfig = generationConfig { + modelConfig = modelConfig { + releaseStage = ModelReleaseStage.PREVIEW + preference = ModelPreference.FULL + } + } + Generation.getClient(previewFastConfig) + } + + suspend fun isSupported(): Boolean { + return try { + var status = generativeModel.checkStatus() + if (status == 1) { // 1 is DOWNLOADABLE + Log.d(TAG, "Model is downloadable. Triggering download...") + generativeModel.download().collect { Log.d(TAG, "Downloading progress...") } + status = generativeModel.checkStatus() + } + status == 3 // 3 is AVAILABLE + } catch (e: GenAiException) { + if (e.errorCode == 606) { + Log.w(TAG, "Feature not found, disabling.", e) + false + } else { + throw e + } + } catch (e: Exception) { + Log.e(TAG, "Error checking Gemini Nano status", e) + false + } + } + + suspend fun extractDocument(bitmap: Bitmap): ExtractedDocumentData { + val prompt = + """ + You are an expert on-device multimodal travel assistant. + Examine the provided image carefully. This image could be: + 1. An official ID or travel document (Driver's License, Passport, Visa, Boarding Pass, Flight Ticket, Booking Confirmation). + 2. A real-world travel photo or note (Hotel Wi-Fi card/network info, Museum or artwork plaque, Subway/train schedule or transit map, Baggage claim ticket or luggage tag, Restaurant menu or specials board, Parking spot sign, Hiking trailhead map, or Receipt). + + Extract all key information and return strictly valid JSON with these keys: + 1. "title": Concise, human-friendly title under 6 words (e.g. "Driver's License - Netherlands", "Hotel Wi-Fi & Breakfast Info", "Louvre Museum Plaque - Mona Lisa", "Baggage Claim Tag - UA991", "Tokyo Metro Ginza Line Map", "Level P2 Parking Spot"). + 2. "documentType": Must be one of: "DRIVER_LICENSE", "PASSPORT", "VISA", "TICKET", "HOTEL", "WIFI", "MUSEUM", "TRANSIT", "BAGGAGE", "PARKING", "MENU", "MAP", "NOTE", "OTHER". + 3. "documentNumber": Any visible ID number, license number, confirmation code, ticket number, or wi-fi password (e.g. "DL-••••1234", "WiFi: HotelGuest2026", "Spot: P2-4B"). + 4. "issuingCountry": Issuing country, state, city, or venue name if visible (e.g. "Netherlands", "California", "Tokyo", "Grand Hotel"). + 5. "expiryDate": Expiration date in ISO format "YYYY-MM-DD" if present on the document, or null if not applicable or not found. + IMPORTANT FOR DRIVER'S LICENSES (especially EU/Dutch/standard ISO cards): + - Field 4b ("4b", "Geldig tot", "Valid until") is the EXPIRATION DATE. Always use 4b as the expiryDate. + - Field 4a ("4a", "Afgiften", "Date of issue") is the issue date, NOT the expiry date. + - Field 3 ("3", "Geboortedatum", "Date of birth") is the birth date, NOT the expiry date. + - For non-expired items or non-documents without expiry dates, return null. + 6. "summary": A helpful 2-4 sentence summary of all essential details read from the image (names, dates, Wi-Fi password, opening/closing hours, platform/terminal numbers, baggage carousel, artist notes, ingredients, directions, etc.). + + Return ONLY the JSON object. + """ + .trimIndent() + + val request = generateContentRequest(ImagePart(bitmap), TextPart(prompt)) {} + + var fullResponse = "" + try { + generativeModel.generateContentStream(request).collect { response -> + val text = response.candidates.firstOrNull()?.text ?: "" + fullResponse += text + } + Log.i(TAG, "Raw Gemini Nano extraction response:\n$fullResponse") + } catch (e: Exception) { + Log.e(TAG, "Error generating vision content", e) + } + + val extracted = parseExtractionJson(fullResponse) + Log.i(TAG, "Extracted document data: $extracted") + return extracted + } + + private fun parseExtractionJson(rawText: String): ExtractedDocumentData { + if (rawText.isBlank()) { + return ExtractedDocumentData( + title = "Scanned Travel Photo", + documentType = "OTHER", + summary = "Photo scanned on device.", + ) + } + + val cleaned = + rawText.substringAfter("```json").substringBefore("```").trim().ifEmpty { rawText.trim() } + + return try { + val json = JSONObject(if (cleaned.startsWith("{")) cleaned else rawText) + val title = json.optString("title", "Scanned Document").ifBlank { "Scanned Document" } + val docType = + json.optString("documentType", "OTHER").uppercase().let { type -> + when { + type.contains("DRIVER") || type.contains("LICENSE") -> "DRIVER_LICENSE" + type.contains("PASS") -> "PASSPORT" + type.contains("VISA") -> "VISA" + type.contains("TICKET") || type.contains("FLIGHT") -> "TICKET" + type.contains("HOTEL") -> "HOTEL" + type.contains("INSUR") -> "INSURANCE" + type.contains("WIFI") -> "WIFI" + type.contains("MUSEUM") || type.contains("ART") -> "MUSEUM" + type.contains("TRANSIT") || + type.contains("TRAIN") || + type.contains("METRO") || + type.contains("SUBWAY") -> "TRANSIT" + type.contains("BAGGAGE") || type.contains("LUGGAGE") -> "BAGGAGE" + type.contains("PARKING") -> "PARKING" + type.contains("MENU") || type.contains("FOOD") || type.contains("RESTAURANT") -> "MENU" + type.contains("MAP") || type.contains("TRAIL") -> "MAP" + type.contains("NOTE") -> "NOTE" + else -> type + } + } + val docNum = json.optString("documentNumber", "") + val country = json.optString("issuingCountry", "") + val expiryStr = json.optString("expiryDate", "") + val summary = json.optString("summary", "") + + val expiryEpochMs = parseDateToEpochMs(expiryStr) + + ExtractedDocumentData( + title = title, + documentType = docType, + documentNumber = docNum, + issuingCountry = country, + expiryDateEpochMs = expiryEpochMs, + summary = summary, + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse JSON response: $rawText", e) + val isDriverLicense = + rawText.contains("driver", ignoreCase = true) || + rawText.contains("license", ignoreCase = true) + val isPassport = rawText.contains("passport", ignoreCase = true) + val isFlight = + rawText.contains("flight", ignoreCase = true) || + rawText.contains("airline", ignoreCase = true) + val isWifi = + rawText.contains("wifi", ignoreCase = true) || rawText.contains("wi-fi", ignoreCase = true) + val isMuseum = + rawText.contains("museum", ignoreCase = true) || + rawText.contains("artist", ignoreCase = true) + + val type = + when { + isDriverLicense -> "DRIVER_LICENSE" + isPassport -> "PASSPORT" + isFlight -> "TICKET" + isWifi -> "WIFI" + isMuseum -> "MUSEUM" + else -> "OTHER" + } + val fallbackTitle = + when { + isDriverLicense -> "Driver's License" + isPassport -> "Passport" + isFlight -> "Flight Ticket" + isWifi -> "Wi-Fi Info" + isMuseum -> "Museum Exhibit" + else -> "Scanned Travel Photo" + } + + ExtractedDocumentData(title = fallbackTitle, documentType = type, summary = rawText.take(300)) + } + } + + private fun parseDateToEpochMs(dateStr: String): Long { + val clean = dateStr.trim().removeSurrounding("\"").substringBefore('T').trim() + if (clean.isBlank() || clean.equals("null", ignoreCase = true)) return 0L + + val patterns = + listOf( + "yyyy-MM-dd", + "dd-MM-yyyy", + "dd.MM.yyyy", + "dd/MM/yyyy", + "MM/dd/yyyy", + "yyyy/MM/dd", + "yyyy.MM.dd", + "d-M-yyyy", + "d.M.yyyy", + "d/M/yyyy", + "d MMM yyyy", + "MMMM d, yyyy", + ) + + for (pattern in patterns) { + try { + val formatter = DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH) + val localDate = LocalDate.parse(clean, formatter) + return localDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + } catch (_: Exception) { + // try next pattern + } + } + Log.w(TAG, "Could not parse date string into epoch millis: '$dateStr'") + return 0L + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsScreen.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsScreen.kt new file mode 100644 index 00000000..9d4f1728 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsScreen.kt @@ -0,0 +1,1928 @@ +/* + * 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.documents + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.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.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.NoteAdd +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ConfirmationNumber +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.Flight +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Hotel +import androidx.compose.material.icons.filled.LocalParking +import androidx.compose.material.icons.filled.Luggage +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Museum +import androidx.compose.material.icons.filled.NoteAdd +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Train +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Event +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.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +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.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +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.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.AdsBorderBrush +import com.example.jetpacker.core.ui.AdsIconBadgeDark +import com.example.jetpacker.core.ui.AdsIconBadgeLight +import com.example.jetpacker.core.ui.components.JetPackerFabConfig +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TravelDocument +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + +enum class DocumentCategory(val label: String) { + ALL("All"), + TICKETS("Tickets & Passes"), + BOOKINGS("Reservations"), + IDS("Passports & IDs"), + NOTES("Notes & Info"), +} + +fun matchesCategory(doc: TravelDocument, category: DocumentCategory): Boolean { + if (category == DocumentCategory.ALL) return true + val combined = (doc.documentType + " " + doc.title).uppercase() + return when (category) { + DocumentCategory.ALL -> true + DocumentCategory.TICKETS -> + combined.contains("TICKET") || + combined.contains("FLIGHT") || + combined.contains("BOARDING") || + combined.contains("MUSEUM") || + combined.contains("PASS") || + combined.contains("EXHIBITION") || + combined.contains("CRUISE") || + combined.contains("TOUR") || + combined.contains("EVENT") + DocumentCategory.BOOKINGS -> + combined.contains("HOTEL") || + combined.contains("LODGING") || + combined.contains("RESORT") || + combined.contains("CAR") || + combined.contains("RENTAL") || + combined.contains("VEHICLE") || + combined.contains("BOOKING") || + combined.contains("CONFIRMATION") + DocumentCategory.IDS -> + combined.contains("PASSPORT") || + combined.contains("DRIVER") || + combined.contains("LICENSE") || + combined.contains("VISA") || + combined.contains("ESTA") || + combined.contains("IDENTITY") || + combined.contains("BADGE") + DocumentCategory.NOTES -> + combined.contains("WIFI") || + combined.contains("NOTE") || + combined.contains("MAP") || + combined.contains("MENU") || + combined.contains("FOOD") || + doc.documentType == "OTHER" + } +} + +enum class DocumentValidity { + VALID, + EXPIRING_SOON, + EXPIRED, + NONE, +} + +fun isIdentityDocument(docType: String, title: String): Boolean { + val combined = (docType + " " + title).uppercase() + return combined.contains("PASSPORT") || + combined.contains("DRIVER") || + combined.contains("LICENSE") || + combined.contains("VISA") || + combined.contains("ESTA") +} + +fun calculateValidity(expiryMillis: Long, isIdentity: Boolean = false): DocumentValidity { + if (expiryMillis <= 0L) return DocumentValidity.NONE + val now = System.currentTimeMillis() + if (expiryMillis < now) return DocumentValidity.EXPIRED + if (isIdentity) { + val sixMonthsMillis = 180L * 24 * 60 * 60 * 1000 + if (expiryMillis - now < sixMonthsMillis) return DocumentValidity.EXPIRING_SOON + } + return DocumentValidity.VALID +} + +fun formatExpiryDate(expiryMillis: Long): String { + if (expiryMillis <= 0L) return "No expiration date" + return try { + val date = Instant.ofEpochMilli(expiryMillis).atZone(ZoneId.systemDefault()).toLocalDate() + date.format(DateTimeFormatter.ISO_LOCAL_DATE) + } catch (e: Exception) { + "Unknown" + } +} + +enum class TripDocFilter { + THIS_TRIP, + ALL, +} + +/** + * Screen displaying the traveler's stored travel documents, passports, visas, flight vouchers, and + * imported travel files in a clean, modern Material 3 interface. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentsScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + tripId: String? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + onFabConfigChange: ((JetPackerFabConfig?) -> Unit)? = null, + viewModel: DocumentsViewModel = hiltViewModel(), +) { + val allDocuments by viewModel.documents.collectAsStateWithLifecycle() + val selectedDoc by viewModel.selectedDocument.collectAsStateWithLifecycle() + val previewContent by viewModel.previewContent.collectAsStateWithLifecycle() + val previewBitmap by viewModel.previewBitmap.collectAsStateWithLifecycle() + val userMessage by viewModel.userMessage.collectAsStateWithLifecycle() + val isProcessing by viewModel.isProcessing.collectAsStateWithLifecycle() + val vaultQuery by viewModel.vaultQuery.collectAsStateWithLifecycle() + val vaultResults by viewModel.vaultResults.collectAsStateWithLifecycle() + val vaultSearchStatus by viewModel.vaultSearchStatus.collectAsStateWithLifecycle() + val isSearchingVault by viewModel.isSearchingVault.collectAsStateWithLifecycle() + + val tripEventsFlow = + remember(tripId) { + if (tripId != null) viewModel.getEventsForTrip(tripId) else flowOf(emptyList()) + } + val tripEvents by tripEventsFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + + var selectedFilter by remember { mutableStateOf(TripDocFilter.THIS_TRIP) } + var selectedCategory by remember { mutableStateOf(DocumentCategory.ALL) } + + val filteredByScope = + remember(allDocuments, tripId, selectedFilter) { + if (tripId == null || selectedFilter == TripDocFilter.ALL) { + allDocuments + } else { + allDocuments.filter { it.tripId == tripId } + } + } + + val browsableDocuments = + remember(filteredByScope, selectedCategory) { + if (selectedCategory == DocumentCategory.ALL) { + filteredByScope + } else { + filteredByScope.filter { matchesCategory(it, selectedCategory) } + } + } + + val documents = vaultResults ?: browsableDocuments + val isVaultSearchActive = vaultResults != null + + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + var showAddDialog by remember { mutableStateOf(false) } + var showFilterDialog by remember { mutableStateOf(false) } + var showCreateTextDialog by remember { mutableStateOf(false) } + var documentToDelete by remember { mutableStateOf(null) } + var documentToLink by remember { mutableStateOf(null) } + + val hasActiveFilters = + selectedCategory != DocumentCategory.ALL || + (tripId != null && selectedFilter != TripDocFilter.THIS_TRIP) + + if (onFabConfigChange != null) { + LaunchedEffect(onFabConfigChange) { + onFabConfigChange(null) + } + } + + val filePickerLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + viewModel.importDocument(uri = uri, tripId = tripId) + } + } + + val context = LocalContext.current + var pendingPhotoUri by rememberSaveable { mutableStateOf(null) } + + val cameraLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.TakePicture()) { success -> + if (success) { + pendingPhotoUri?.let { uriString -> + val uri = Uri.parse(uriString) + viewModel.scanDocumentFromUri(uri = uri, tripId = tripId) + } + } + } + + val launchCamera: () -> Unit = { + try { + val capturesDir = File(context.cacheDir, "camera_captures").apply { mkdirs() } + val photoFile = + File(capturesDir, "capture_${System.currentTimeMillis()}.jpg").apply { createNewFile() } + val uri = + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", photoFile) + pendingPhotoUri = uri.toString() + cameraLauncher.launch(uri) + } catch (e: Exception) { + Log.e("DocumentsScreen", "Failed to launch camera capture", e) + } + } + + LaunchedEffect(userMessage) { + userMessage?.let { msg -> + snackbarHostState.showSnackbar(msg) + viewModel.clearUserMessage() + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + if (tripId != null) "Trip Documents" else "Travel Documents", + fontWeight = FontWeight.SemiBold, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { showAddDialog = true }) { + Icon(imageVector = Icons.Default.Add, contentDescription = "Add Document") + } + IconButton(onClick = { viewModel.seedDefaultDocuments() }) { + Icon(imageVector = Icons.Default.Refresh, contentDescription = "Reset Demo Documents") + } + }, + colors = + TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), + ) + }, + floatingActionButton = { + if (onFabConfigChange == null) { + ExtendedFloatingActionButton( + onClick = { showAddDialog = true }, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text("Add Document") }, + ) + } + }, + containerColor = MaterialTheme.colorScheme.surface, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = contentPadding.calculateBottomPadding()), + ) + }, + ) { paddingValues -> + Box( + modifier = + Modifier.fillMaxSize() + .padding( + top = paddingValues.calculateTopPadding(), + bottom = + maxOf(paddingValues.calculateBottomPadding(), contentPadding.calculateBottomPadding()), + ) + ) { + Column(modifier = Modifier.fillMaxSize()) { + DocumentSearchBar( + query = vaultQuery, + isSearching = isSearchingVault, + statusLine = vaultSearchStatus, + hasActiveFilters = hasActiveFilters, + onQueryChange = { q -> + viewModel.onVaultQueryChange( + q, + tripId = tripId.takeIf { selectedFilter == TripDocFilter.THIS_TRIP }, + ) + }, + onSearch = { + viewModel.searchVault(tripId.takeIf { selectedFilter == TripDocFilter.THIS_TRIP }) + }, + onClear = viewModel::clearVaultSearch, + onFilterClick = { showFilterDialog = true }, + ) + + // Show a compact active filter badge row ONLY if filters are active and search is not overriding + if (hasActiveFilters && !isVaultSearchActive) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (tripId != null && selectedFilter == TripDocFilter.ALL) { + FilterChip( + selected = true, + onClick = { showFilterDialog = true }, + label = { Text("All Documents") }, + trailingIcon = { + IconButton( + onClick = { selectedFilter = TripDocFilter.THIS_TRIP }, + modifier = Modifier.size(16.dp), + ) { + Icon( + Icons.Rounded.Close, + contentDescription = "Clear scope filter", + modifier = Modifier.size(12.dp), + ) + } + }, + ) + } + if (selectedCategory != DocumentCategory.ALL) { + FilterChip( + selected = true, + onClick = { showFilterDialog = true }, + label = { Text(selectedCategory.label) }, + trailingIcon = { + IconButton( + onClick = { selectedCategory = DocumentCategory.ALL }, + modifier = Modifier.size(16.dp), + ) { + Icon( + Icons.Rounded.Close, + contentDescription = "Clear category filter", + modifier = Modifier.size(12.dp), + ) + } + }, + ) + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (documents.isEmpty() && !isVaultSearchActive) { + EmptyDocumentsState( + onPickFile = { filePickerLauncher.launch(arrayOf("*/*")) }, + onScanCamera = launchCamera, + onAddText = { showCreateTextDialog = true }, + onSeedDefaults = { viewModel.seedDefaultDocuments() }, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 88.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "header_summary") { + Text( + text = + if (isVaultSearchActive) { + "Best matches for \u201C$vaultQuery\u201D" + } else { + "${documents.size} saved documents" + }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + + if (isVaultSearchActive && documents.isEmpty()) { + item(key = "no_matches") { + Text( + text = "No matching documents found. Try different keywords or add a new document.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 24.dp), + ) + } + } + + items(documents, key = { it.id }) { doc -> + DocumentCard( + document = doc, + tripId = tripId, + events = tripEvents, + loadCardBitmap = viewModel::loadCardBitmap, + onInspect = { viewModel.selectDocumentForPreview(doc) }, + onDelete = { documentToDelete = doc }, + onLinkToEvent = { documentToLink = doc }, + onUnlinkEvent = { + viewModel.linkDocumentToEvent(doc.documentId, null, tripId = tripId) + }, + onAddToTrip = { viewModel.linkDocumentToTrip(doc.documentId, tripId) }, + onCopyText = { text -> + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Document Info", text)) + coroutineScope.launch { + snackbarHostState.showSnackbar("Copied $text to clipboard") + } + }, + onShareDocument = { + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, doc.title) + putExtra( + Intent.EXTRA_TEXT, + "${doc.title}\n${doc.documentType} • ${doc.issuingCountry}\n${doc.notes}", + ) + } + context.startActivity(Intent.createChooser(shareIntent, "Share Document")) + }, + ) + } + } + } + } + } + + if (isProcessing) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } + + if (selectedDoc != null) { + DocumentPreviewSheet( + document = selectedDoc!!, + content = previewContent, + bitmap = previewBitmap, + onDismiss = { viewModel.selectDocumentForPreview(null) }, + onLinkToEvent = { + val doc = selectedDoc + viewModel.selectDocumentForPreview(null) + documentToLink = doc + }, + onDelete = { + val doc = selectedDoc + viewModel.selectDocumentForPreview(null) + documentToDelete = doc + }, + onCopyText = { text -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Document Info", text)) + coroutineScope.launch { snackbarHostState.showSnackbar("Copied $text to clipboard") } + }, + onShare = { + val doc = selectedDoc!! + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, doc.title) + putExtra(Intent.EXTRA_TEXT, previewContent ?: "${doc.title}\n${doc.notes}") + } + context.startActivity(Intent.createChooser(shareIntent, "Share Document")) + }, + ) + } + + if (showAddDialog) { + AddDocumentOptionsDialog( + onDismiss = { showAddDialog = false }, + onPickFile = { + showAddDialog = false + filePickerLauncher.launch(arrayOf("*/*")) + }, + onScanCamera = { + showAddDialog = false + launchCamera() + }, + onCreateText = { + showAddDialog = false + showCreateTextDialog = true + }, + ) + } + + if (showFilterDialog) { + DocumentFilterDialog( + tripId = tripId, + selectedFilter = selectedFilter, + selectedCategory = selectedCategory, + onFilterChange = { selectedFilter = it }, + onCategoryChange = { selectedCategory = it }, + onReset = { + selectedFilter = TripDocFilter.THIS_TRIP + selectedCategory = DocumentCategory.ALL + }, + onDismiss = { showFilterDialog = false }, + ) + } + + if (showCreateTextDialog) { + CreateTextDocumentDialog( + onDismiss = { showCreateTextDialog = false }, + events = tripEvents, + onSave = { title, content, type, country, expiry, number, eventId -> + viewModel.createTextDocument( + title = title, + content = content, + documentType = type, + issuingCountry = country, + expiryDate = expiry, + documentNumberMasked = number, + tripId = tripId, + eventId = eventId, + ) + showCreateTextDialog = false + }, + ) + } + + if (documentToLink != null) { + AlertDialog( + onDismissRequest = { documentToLink = null }, + title = { Text("Link Document to Event") }, + text = { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Select an event to link to \"${documentToLink!!.title}\":", + style = MaterialTheme.typography.bodyMedium, + ) + if (tripEvents.isEmpty()) { + Text( + text = "No events in this trip yet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + tripEvents.forEach { event -> + OutlinedCard( + onClick = { + viewModel.linkDocumentToEvent( + documentToLink!!.documentId, + event.id, + tripId = tripId, + ) + documentToLink = null + }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Event, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = event.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${event.type.name.replace('_', ' ')} • ${event.location}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = { documentToLink = null }) { Text("Cancel") } }, + ) + } + + if (documentToDelete != null) { + AlertDialog( + onDismissRequest = { documentToDelete = null }, + title = { Text("Delete Document") }, + text = { + Text( + "Are you sure you want to delete \"${documentToDelete!!.title}\"? The local file will be removed." + ) + }, + confirmButton = { + Button( + onClick = { + documentToDelete?.let { viewModel.deleteDocument(it) } + documentToDelete = null + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text("Delete") + } + }, + dismissButton = { TextButton(onClick = { documentToDelete = null }) { Text("Cancel") } }, + ) + } +} + +/** Clean, modern search bar for querying documents with integrated filter button. */ +@Composable +private fun DocumentSearchBar( + query: String, + isSearching: Boolean, + statusLine: String?, + hasActiveFilters: Boolean, + onQueryChange: (String) -> Unit, + onSearch: () -> Unit, + onClear: () -> Unit, + onFilterClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.weight(1f), + singleLine = true, + shape = RoundedCornerShape(24.dp), + placeholder = { Text("Search tickets, bookings, IDs...", maxLines = 1) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search documents", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + when { + isSearching -> + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + query.isNotEmpty() -> + IconButton(onClick = onClear) { + Icon(imageVector = Icons.Rounded.Close, contentDescription = "Clear search") + } + } + }, + colors = + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = Color.Transparent, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), + ) + + Surface( + onClick = onFilterClick, + shape = CircleShape, + color = + if (hasActiveFilters) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + contentColor = + if (hasActiveFilters) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(52.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = "Filter documents", + modifier = Modifier.size(22.dp), + ) + } + } + } + + if (statusLine != null && query.isNotEmpty()) { + Text( + text = statusLine, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp, top = 4.dp), + ) + } + } +} + +/** Dialog for scoping and categorizing travel documents cleanly without cluttering the screen. */ +@Composable +private fun DocumentFilterDialog( + tripId: String?, + selectedFilter: TripDocFilter, + selectedCategory: DocumentCategory, + onFilterChange: (TripDocFilter) -> Unit, + onCategoryChange: (DocumentCategory) -> Unit, + onReset: () -> Unit, + onDismiss: () -> Unit, +) { + var tempFilter by remember { mutableStateOf(selectedFilter) } + var tempCategory by remember { mutableStateOf(selectedCategory) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Filter Documents", style = MaterialTheme.typography.titleLarge) + if (tempFilter != TripDocFilter.THIS_TRIP || tempCategory != DocumentCategory.ALL) { + TextButton( + onClick = { + tempFilter = TripDocFilter.THIS_TRIP + tempCategory = DocumentCategory.ALL + } + ) { + Text("Reset") + } + } + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (tripId != null) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Trip Scope", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { tempFilter = TripDocFilter.THIS_TRIP } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = tempFilter == TripDocFilter.THIS_TRIP, + onClick = { tempFilter = TripDocFilter.THIS_TRIP }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + "This trip only", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + "Only show documents added for this trip", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { tempFilter = TripDocFilter.ALL } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = tempFilter == TripDocFilter.ALL, + onClick = { tempFilter = TripDocFilter.ALL }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + "All documents", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + "Show documents from all trips and global storage", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + HorizontalDivider() + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Category", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + + val categories = + listOf( + Triple(DocumentCategory.ALL, "All Categories", Icons.Default.FolderOpen), + Triple(DocumentCategory.TICKETS, "Tickets & Passes", Icons.Default.ConfirmationNumber), + Triple(DocumentCategory.BOOKINGS, "Reservations", Icons.Default.Hotel), + Triple(DocumentCategory.IDS, "Passports & IDs", Icons.Default.Badge), + Triple(DocumentCategory.NOTES, "Notes & Info", Icons.Default.Notes), + ) + + categories.forEach { (cat, label, catIcon) -> + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { tempCategory = cat } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = tempCategory == cat, + onClick = { tempCategory = cat }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = catIcon, + contentDescription = null, + tint = + if (tempCategory == cat) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (tempCategory == cat) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } + } + }, + confirmButton = { + Button( + onClick = { + onFilterChange(tempFilter) + onCategoryChange(tempCategory) + onDismiss() + } + ) { + Text("Apply") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +/** Tonal, modern document card with intuitive layout, quick copy, and overflow options. */ +@Composable +private fun DocumentCard( + document: TravelDocument, + onInspect: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, + tripId: String? = null, + events: List = emptyList(), + loadCardBitmap: suspend (TravelDocument) -> Bitmap? = { null }, + onLinkToEvent: () -> Unit = {}, + onUnlinkEvent: () -> Unit = {}, + onAddToTrip: () -> Unit = {}, + onCopyText: (String) -> Unit = {}, + onShareDocument: () -> Unit = {}, +) { + val isIdentity = isIdentityDocument(document.documentType, document.title) + val validity = calculateValidity(document.expiryDate, isIdentity = isIdentity) + val icon = getDocumentIcon(document.documentType, document.mimeType, document.title) + val cardBitmap by + produceState(initialValue = null, document.filePath, document.mimeType) { + value = loadCardBitmap(document) + } + + var showMenu by remember { mutableStateOf(false) } + val linkedEvent = events.find { it.id == document.eventId } + + Card( + onClick = onInspect, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + border = BorderStroke(1.dp, AdsBorderBrush), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + // Top header row: Icon + Title/Subtitle + Overflow Menu + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = CircleShape, + color = AdsIconBadgeLight, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = AdsIconBadgeDark, + modifier = Modifier.size(22.dp), + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = document.title.ifBlank { document.documentId }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 2.dp), + ) { + Text( + text = document.documentType.replace('_', ' '), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Text( + text = formatDocumentModalityLabel(document.mimeType, document.filePath), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + if (document.issuingCountry.isNotBlank()) { + Text( + text = "• ${document.issuingCountry}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Options", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + if (tripId != null) { + if (document.tripId == tripId) { + if (linkedEvent != null) { + DropdownMenuItem( + text = { Text("Unlink from Event") }, + onClick = { + showMenu = false + onUnlinkEvent() + }, + leadingIcon = { Icon(Icons.Rounded.Close, contentDescription = null) }, + ) + } + DropdownMenuItem( + text = { Text(if (linkedEvent != null) "Change Event" else "Link to Event") }, + onClick = { + showMenu = false + onLinkToEvent() + }, + leadingIcon = { Icon(Icons.Rounded.Event, contentDescription = null) }, + ) + } else { + DropdownMenuItem( + text = { Text("Add to this Trip") }, + onClick = { + showMenu = false + onAddToTrip() + }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, + ) + } + } + DropdownMenuItem( + text = { Text("Share") }, + onClick = { + showMenu = false + onShareDocument() + }, + leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }, + ) + DropdownMenuItem( + text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + onClick = { + showMenu = false + onDelete() + }, + leadingIcon = { + Icon( + Icons.Default.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } + } + + // Middle metadata chips row: Document number with quick-copy + Validity + if (document.documentNumberMasked.isNotBlank() || validity != DocumentValidity.NONE) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (document.documentNumberMasked.isNotBlank()) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + onClick = { onCopyText(document.documentNumberMasked) }, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + if (document.documentType == "WIFI") "WiFi: ${document.documentNumberMasked}" + else "ID: ${document.documentNumberMasked}", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } else { + Spacer(modifier = Modifier.width(1.dp)) + } + + when (validity) { + DocumentValidity.EXPIRING_SOON -> { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color(0xFF3B2A08), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = Color(0xFFFFD54F), + modifier = Modifier.size(13.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Expires soon (${formatExpiryDate(document.expiryDate)})", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFFFFD54F), + fontWeight = FontWeight.SemiBold, + ) + } + } + } + DocumentValidity.EXPIRED -> { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color(0xFF421114), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = Color(0xFFFFB4AB), + modifier = Modifier.size(13.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Expired (${formatExpiryDate(document.expiryDate)})", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFFFFB4AB), + fontWeight = FontWeight.SemiBold, + ) + } + } + } + DocumentValidity.VALID -> { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color(0xFF063E20), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = Color(0xFF6DD58C), + modifier = Modifier.size(13.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Valid (${formatExpiryDate(document.expiryDate)})", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF6DD58C), + fontWeight = FontWeight.SemiBold, + ) + } + } + } + DocumentValidity.NONE -> {} + } + } + } + + // Visual bitmap preview (image / rendered PDF) + if (cardBitmap != null) { + Spacer(modifier = Modifier.height(10.dp)) + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + modifier = + Modifier.fillMaxWidth().heightIn(max = 170.dp).clip(RoundedCornerShape(12.dp)), + ) { + Image( + bitmap = cardBitmap!!.asImageBitmap(), + contentDescription = document.title, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + // Notes summary + if (document.notes.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = document.notes, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + // Linked event tag + if (linkedEvent != null && tripId != null) { + Spacer(modifier = Modifier.height(8.dp)) + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Event, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Linked to: ${linkedEvent.title}", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +/** Comprehensive, modern bottom sheet for inspecting travel documents, passes, and emails. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DocumentPreviewSheet( + document: TravelDocument, + content: String?, + bitmap: Bitmap?, + onDismiss: () -> Unit, + onLinkToEvent: () -> Unit, + onDelete: () -> Unit, + onCopyText: (String) -> Unit, + onShare: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val icon = getDocumentIcon(document.documentType, document.mimeType, document.title) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.size(48.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(28.dp), + ) + } + } + Spacer(modifier = Modifier.width(14.dp)) + Column { + Text( + text = document.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = + "${document.documentType.replace('_', ' ')} • ${formatDocumentModalityLabel(document.mimeType, document.filePath)} • ${document.issuingCountry.ifBlank { "Travel Document" }}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (document.documentNumberMasked.isNotBlank()) { + FilledTonalButton( + onClick = { onCopyText(document.documentNumberMasked) }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + Icon(Icons.Default.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Copy ID", maxLines = 1) + } + } + OutlinedButton( + onClick = onShare, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 8.dp), + ) { + Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Share", maxLines = 1) + } + } + + // Visual bitmap display + if (bitmap != null) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = document.title, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)), + ) + } + } + + // Formatted content (for text files, emails, or fallback when no rendered bitmap) + if (bitmap == null) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth(), + ) { + Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + if (content == null) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else { + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (content.contains("Subject:") || content.contains("From:")) { + // Formatted email card + val lines = content.lines() + val subjectLine = lines.find { it.startsWith("Subject:", ignoreCase = true) } + val fromLine = lines.find { it.startsWith("From:", ignoreCase = true) } + val dateLine = lines.find { it.startsWith("Date:", ignoreCase = true) } + + if (subjectLine != null) { + Text( + text = subjectLine.substringAfter(':').trim(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + if (fromLine != null || dateLine != null) { + Text( + text = + listOfNotNull( + fromLine?.substringAfter(':')?.trim(), + dateLine?.substringAfter(':')?.trim(), + ) + .joinToString(" • "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + + Text( + text = content, + style = + MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } + } + } + + // Bottom actions + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = onDelete, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon(Icons.Default.DeleteOutline, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text("Delete") + } + Spacer(modifier = Modifier.weight(1f)) + Button(onClick = onDismiss) { Text("Done") } + } + } + } +} + +@Composable +private fun EmptyDocumentsState( + onPickFile: () -> Unit, + onScanCamera: () -> Unit, + onAddText: () -> Unit, + onSeedDefaults: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(72.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(36.dp), + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No Travel Documents Yet", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + "Keep your flight confirmations, tickets, hotel bookings, and travel IDs organized in one place for easy access on your trip.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onScanCamera, modifier = Modifier.fillMaxWidth(0.85f)) { + Icon(Icons.Default.PhotoCamera, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Scan Document") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onPickFile, modifier = Modifier.fillMaxWidth(0.85f)) { + Icon(Icons.Default.UploadFile, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Import Document File") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onAddText, modifier = Modifier.fillMaxWidth(0.85f)) { + Icon(Icons.AutoMirrored.Filled.NoteAdd, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Create Text Document") + } + Spacer(modifier = Modifier.height(12.dp)) + TextButton(onClick = onSeedDefaults) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(modifier = Modifier.width(6.dp)) + Text("Load Sample Documents") + } + } +} + +@Composable +private fun AddDocumentOptionsDialog( + onDismiss: () -> Unit, + onPickFile: () -> Unit, + onScanCamera: () -> Unit, + onCreateText: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Travel Document") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedCard(onClick = onScanCamera, modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.PhotoCamera, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text("Scan Document with Camera", fontWeight = FontWeight.Bold) + Text( + "Capture passport, driver's license, ticket, or confirmation photo", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + OutlinedCard(onClick = onPickFile, modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.UploadFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text("Import File from Device", fontWeight = FontWeight.Bold) + Text( + "Import PDF, image, email (.eml / HTML), or text voucher", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + OutlinedCard(onClick = onCreateText, modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.AutoMirrored.Filled.NoteAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text("Create Text Note / Confirmation", fontWeight = FontWeight.Bold) + Text( + "Paste booking confirmations, voucher text, or passport info", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun CreateTextDocumentDialog( + onDismiss: () -> Unit, + events: List = emptyList(), + onSave: + ( + title: String, + content: String, + type: String, + country: String, + expiry: Long, + number: String, + eventId: String?, + ) -> Unit, +) { + var title by remember { mutableStateOf("") } + var type by remember { mutableStateOf("OTHER") } + var content by remember { mutableStateOf("") } + var country by remember { mutableStateOf("") } + var number by remember { mutableStateOf("") } + var selectedEventId by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New Travel Document") }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title (e.g. Flight Confirmation UA 991)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + OutlinedButton( + onClick = { type = "PASSPORT" }, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = + if (type == "PASSPORT") MaterialTheme.colorScheme.primaryContainer + else Color.Transparent + ), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + Text("Passport", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { type = "DRIVER_LICENSE" }, + modifier = Modifier.weight(1.1f), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = + if (type == "DRIVER_LICENSE") MaterialTheme.colorScheme.primaryContainer + else Color.Transparent + ), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + Text("License", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { type = "FLIGHT" }, + modifier = Modifier.weight(1f), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = + if (type == "FLIGHT") MaterialTheme.colorScheme.primaryContainer + else Color.Transparent + ), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + Text("Flight", style = MaterialTheme.typography.labelSmall) + } + OutlinedButton( + onClick = { type = "HOTEL" }, + modifier = Modifier.weight(0.9f), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = + if (type == "HOTEL") MaterialTheme.colorScheme.primaryContainer + else Color.Transparent + ), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + Text("Hotel", style = MaterialTheme.typography.labelSmall) + } + } + + OutlinedTextField( + value = country, + onValueChange = { country = it }, + label = { Text("Country / Airline / Venue (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = number, + onValueChange = { number = it }, + label = { Text("Confirmation / Document Number (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + if (events.isNotEmpty()) { + Text( + text = "Link to Itinerary Event (optional):", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectedEventId == null, + onClick = { selectedEventId = null }, + label = { Text("None") }, + ) + events.forEach { ev -> + FilterChip( + selected = selectedEventId == ev.id, + onClick = { selectedEventId = ev.id }, + label = { Text(ev.title) }, + ) + } + } + } + + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text("Content / Confirmation Details") }, + minLines = 4, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + if (title.isNotBlank() && content.isNotBlank()) { + onSave( + title.trim(), + content.trim(), + type, + country.trim(), + 0L, + number.trim(), + selectedEventId, + ) + } + }, + enabled = title.isNotBlank() && content.isNotBlank(), + ) { + Text("Save Document") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +private fun getDocumentIcon( + documentType: String, + mimeType: String, + title: String = "", +): ImageVector { + val upper = (documentType + " " + title).uppercase() + return when { + upper.contains("MUSEUM") || upper.contains("EXHIBITION") || upper.contains("ART") -> + Icons.Default.Museum + upper.contains("HOTEL") || + upper.contains("LODGING") || + upper.contains("RESORT") || + upper.contains("ACCOMMODATION") -> Icons.Default.Hotel + upper.contains("CAR") || + upper.contains("RENTAL") || + upper.contains("VEHICLE") || + upper.contains("POLESTAR") || + upper.contains("ZEPHYR") -> Icons.Default.DirectionsCar + upper.contains("DRIVER") || upper.contains("LICENSE") -> Icons.Default.DirectionsCar + upper.contains("PASSPORT") || upper.contains("ESTA") -> Icons.Default.Badge + upper.contains("FLIGHT") || + upper.contains("AIRLINE") || + upper.contains("BOARDING") || + upper.contains("AIRPORT") -> Icons.Default.Flight + upper.contains("TRAIN") || + upper.contains("TRANSIT") || + upper.contains("METRO") || + upper.contains("SUBWAY") -> Icons.Default.Train + upper.contains("WIFI") || upper.contains("INTERNET") -> Icons.Default.Wifi + upper.contains("VISA") -> Icons.Default.CheckCircle + upper.contains("TICKET") || + upper.contains("PASS") || + upper.contains("CRUISE") || + upper.contains("TOUR") || + upper.contains("EVENT") -> Icons.Default.ConfirmationNumber + upper.contains("PARKING") -> Icons.Default.LocalParking + upper.contains("MENU") || + upper.contains("FOOD") || + upper.contains("RESTAURANT") || + upper.contains("DINING") -> Icons.Default.Restaurant + upper.contains("MAP") || upper.contains("TRAIL") -> Icons.Default.Map + upper.contains("LUGGAGE") || upper.contains("BAGGAGE") -> Icons.Default.Luggage + mimeType.contains("pdf") -> Icons.Default.Description + else -> Icons.Default.Description + } +} + +private fun formatDocumentModalityLabel(mimeType: String, filePath: String): String { + val lowerMime = mimeType.lowercase() + val lowerExt = filePath.substringAfterLast('.', "").lowercase() + return when { + lowerMime == "application/pdf" || lowerExt == "pdf" -> "PDF" + lowerMime == "message/rfc822" || lowerExt == "eml" || lowerExt == "msg" -> "EMAIL" + lowerMime.startsWith("image/") || lowerExt in setOf("jpg", "jpeg", "png", "webp") -> "IMAGE" + lowerMime == "text/html" || lowerExt in setOf("html", "htm") -> "HTML" + else -> "TEXT" + } +} diff --git a/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsViewModel.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsViewModel.kt new file mode 100644 index 00000000..a5fc2e49 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/documents/DocumentsViewModel.kt @@ -0,0 +1,515 @@ +/* + * 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.documents + +import android.graphics.Bitmap +import android.net.Uri +import android.os.SystemClock +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * ViewModel managing user travel documents, file imports, and document content previews. + * + * Every mutation is written through to the on-device AppSearch vault index so that a document + * scanned or created moments ago is immediately retrievable by natural language, both from the + * vault search bar and from the Nano assistant. + */ +@HiltViewModel +class DocumentsViewModel +@Inject +constructor( + private val travelDocumentManager: TravelDocumentManager, + private val eventDao: EventDao, + private val documentVisionScanner: DocumentVisionScanner, + private val documentSemanticSearch: DocumentSemanticSearch, +) : ViewModel() { + + fun getEventsForTrip(tripId: String): Flow> { + return eventDao.getEventsForTrip(tripId) + } + + val documents: StateFlow> = + travelDocumentManager.documents.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + + private val _selectedDocument = MutableStateFlow(null) + val selectedDocument: StateFlow = _selectedDocument.asStateFlow() + + private val _previewContent = MutableStateFlow(null) + val previewContent: StateFlow = _previewContent.asStateFlow() + + private val _previewBitmap = MutableStateFlow(null) + val previewBitmap: StateFlow = _previewBitmap.asStateFlow() + + private val _userMessage = MutableStateFlow(null) + val userMessage: StateFlow = _userMessage.asStateFlow() + + private val _isProcessing = MutableStateFlow(false) + val isProcessing: StateFlow = _isProcessing.asStateFlow() + + /** Text currently typed into the vault search bar. */ + private val _vaultQuery = MutableStateFlow("") + val vaultQuery: StateFlow = _vaultQuery.asStateFlow() + + /** Ranked vault matches, or null when no search is active and the full list should show. */ + private val _vaultResults = MutableStateFlow?>(null) + val vaultResults: StateFlow?> = _vaultResults.asStateFlow() + + /** Human readable provenance line, e.g. "3 matches · 84 ms · on-device". */ + private val _vaultSearchStatus = MutableStateFlow(null) + val vaultSearchStatus: StateFlow = _vaultSearchStatus.asStateFlow() + + private val _isSearchingVault = MutableStateFlow(false) + val isSearchingVault: StateFlow = _isSearchingVault.asStateFlow() + + private var vaultSearchJob: Job? = null + + init { + travelDocumentManager.semanticSearchProvider = documentSemanticSearch + viewModelScope.launch { + travelDocumentManager.seedDefaultDocumentsIfEmpty() + syncVaultIndex() + } + } + + /** + * Runs a natural language search across the on-device vault. + * + * @param tripId Restricts results to the active trip plus documents not tied to any trip. + */ + fun searchVault(tripId: String? = null) { + val query = _vaultQuery.value.trim() + vaultSearchJob?.cancel() + if (query.isBlank()) { + clearVaultSearch() + return + } + + vaultSearchJob = viewModelScope.launch { + performVaultSearch(query, tripId) + } + } + + fun onVaultQueryChange(query: String, tripId: String? = null) { + _vaultQuery.value = query + if (query.isBlank()) { + clearVaultSearch() + return + } + vaultSearchJob?.cancel() + vaultSearchJob = viewModelScope.launch { + delay(350) + performVaultSearch(_vaultQuery.value.trim(), tripId) + } + } + + private suspend fun performVaultSearch(query: String, tripId: String?) { + if (query.isBlank()) { + clearVaultSearch() + return + } + _isSearchingVault.value = true + val startMs = SystemClock.elapsedRealtime() + try { + val matches = + documentSemanticSearch.findRelevantDocuments( + query = query, + tripId = tripId, + maxResults = VAULT_SEARCH_MAX_RESULTS, + ) + val elapsedMs = SystemClock.elapsedRealtime() - startMs + _vaultResults.value = matches + _vaultSearchStatus.value = + if (matches.isEmpty()) { + "No matching documents (100% on-device, ${elapsedMs}ms)" + } else { + "${matches.size} match${if (matches.size == 1) "" else "es"} (100% on-device, ${elapsedMs}ms)" + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w(TAG, "Vault search failed for query: $query", e) + _vaultResults.value = null + _vaultSearchStatus.value = "Vault search unavailable" + } finally { + _isSearchingVault.value = false + } + } + + /** Dismisses the active search and restores the full document list. */ + fun clearVaultSearch() { + vaultSearchJob?.cancel() + vaultSearchJob = null + _vaultQuery.value = "" + _vaultResults.value = null + _vaultSearchStatus.value = null + _isSearchingVault.value = false + } + + /** + * Brings the AppSearch vault index in line with Room, adding new docs and pruning deleted ones. + */ + private suspend fun syncVaultIndex() { + try { + documentSemanticSearch.ensureIndexUpToDate() + } catch (e: Exception) { + Log.w(TAG, "Failed to sync vault index", e) + } + } + + /** Indexes a single document, never letting an indexing failure break the user-visible flow. */ + private suspend fun indexInVault(doc: TravelDocument) { + try { + documentSemanticSearch.indexDocument(doc) + } catch (e: Exception) { + Log.w(TAG, "Failed to index document ${doc.id} in vault", e) + } + } + + /** + * Imports an external document (image, PDF, email, or text) from a file picker or share [Uri]. + */ + fun importDocument( + uri: Uri, + overrideTitle: String? = null, + documentType: String = "OTHER", + tripId: String? = null, + eventId: String? = null, + ) { + viewModelScope.launch { + _isProcessing.value = true + try { + var imported = + travelDocumentManager.importDocumentFromUri( + uri = uri, + overrideTitle = overrideTitle, + documentType = documentType, + tripId = tripId, + eventId = eventId, + ) + if ( + imported.notes.isBlank() && + travelDocumentManager.isVisualDocument(imported) && + runCatching { documentVisionScanner.isSupported() }.getOrDefault(false) + ) { + val bitmap = travelDocumentManager.loadDocumentBitmap(imported, maxDimension = 1920) + if (bitmap != null) { + val extracted = + runCatching { documentVisionScanner.extractDocument(bitmap) }.getOrNull() + if (extracted != null && extracted.summary.isNotBlank()) { + imported = + imported.copy( + title = + if ( + overrideTitle.isNullOrBlank() && + extracted.title.isNotBlank() && + extracted.title != "Scanned Document" + ) { + extracted.title + } else { + imported.title + }, + documentType = + if (imported.documentType == "OTHER" && extracted.documentType != "OTHER") { + extracted.documentType + } else { + imported.documentType + }, + issuingCountry = imported.issuingCountry.ifBlank { extracted.issuingCountry }, + expiryDate = + if (imported.expiryDate > 0L) imported.expiryDate + else extracted.expiryDateEpochMs, + documentNumberMasked = + imported.documentNumberMasked.ifBlank { extracted.documentNumber }, + notes = extracted.summary, + ) + travelDocumentManager.updateDocument(imported) + } + } + } + indexInVault(imported) + _userMessage.value = "Imported \"${imported.title}\"" + } catch (e: Exception) { + Log.e(TAG, "Failed to import document from $uri", e) + _userMessage.value = "Failed to import document: ${e.message}" + } finally { + _isProcessing.value = false + } + } + } + + suspend fun isScannerSupported(): Boolean = documentVisionScanner.isSupported() + + /** + * Scans a travel document photo with on-device Gemini Nano vision, extracts metadata, and saves + * it. + */ + fun scanDocumentFromBitmap(bitmap: Bitmap, tripId: String? = null, eventId: String? = null) { + viewModelScope.launch { + _isProcessing.value = true + try { + val extracted = documentVisionScanner.extractDocument(bitmap) + val saved = + travelDocumentManager.saveDocumentBitmap( + bitmap = bitmap, + title = extracted.title, + documentType = extracted.documentType, + issuingCountry = extracted.issuingCountry, + expiryDate = extracted.expiryDateEpochMs, + documentNumberMasked = extracted.documentNumber, + notes = extracted.summary, + tripId = tripId, + eventId = eventId, + ) + indexInVault(saved) + _userMessage.value = "Scanned and saved: ${saved.title}" + } catch (e: Exception) { + Log.e(TAG, "Failed to scan document", e) + _userMessage.value = "Failed to scan document: ${e.message}" + } finally { + _isProcessing.value = false + } + } + } + + /** + * Scans a high-resolution captured photo from an image [Uri] with on-device Gemini Nano vision. + */ + fun scanDocumentFromUri(uri: Uri, tripId: String? = null, eventId: String? = null) { + viewModelScope.launch { + _isProcessing.value = true + try { + val bitmap = travelDocumentManager.loadBitmapFromUri(uri) + if (bitmap == null) { + _userMessage.value = "Failed to load captured image." + return@launch + } + val extracted = documentVisionScanner.extractDocument(bitmap) + val saved = + travelDocumentManager.saveDocumentBitmap( + bitmap = bitmap, + title = extracted.title, + documentType = extracted.documentType, + issuingCountry = extracted.issuingCountry, + expiryDate = extracted.expiryDateEpochMs, + documentNumberMasked = extracted.documentNumber, + notes = extracted.summary, + tripId = tripId, + eventId = eventId, + ) + indexInVault(saved) + _userMessage.value = "Scanned and saved: ${saved.title}" + } catch (e: Exception) { + Log.e(TAG, "Failed to scan document from $uri", e) + _userMessage.value = "Failed to scan document: ${e.message}" + } finally { + _isProcessing.value = false + } + } + } + + /** Creates an in-app text document (e.g. flight confirmation or travel notes). */ + fun createTextDocument( + title: String, + content: String, + documentType: String = "OTHER", + issuingCountry: String = "", + expiryDate: Long = 0L, + documentNumberMasked: String = "", + notes: String = "", + tripId: String? = null, + eventId: String? = null, + ) { + viewModelScope.launch { + _isProcessing.value = true + try { + val created = + travelDocumentManager.createTextDocument( + title = title, + content = content, + documentType = documentType, + issuingCountry = issuingCountry, + expiryDate = expiryDate, + documentNumberMasked = documentNumberMasked, + notes = notes, + tripId = tripId, + eventId = eventId, + ) + indexInVault(created) + _userMessage.value = "Saved \"${created.title}\"" + } catch (e: Exception) { + Log.e(TAG, "Failed to create text document", e) + _userMessage.value = "Failed to create document: ${e.message}" + } finally { + _isProcessing.value = false + } + } + } + + /** Links or unlinks a document to/from a specific timeline event. */ + fun linkDocumentToEvent(documentId: String, eventId: String?, tripId: String? = null) { + viewModelScope.launch { + try { + travelDocumentManager.linkDocumentToEvent(documentId, eventId, tripId) + // Trip/event linkage is part of the indexed payload, so the entry must be refreshed. + syncVaultIndex() + _userMessage.value = + if (eventId != null) "Document linked to event" else "Document unlinked" + } catch (e: Exception) { + Log.e(TAG, "Failed to link document $documentId to event $eventId", e) + _userMessage.value = "Failed to update event link: ${e.message}" + } + } + } + + /** Links or unlinks a document to/from a specific trip. */ + fun linkDocumentToTrip(documentId: String, tripId: String?) { + viewModelScope.launch { + try { + travelDocumentManager.linkDocumentToTrip(documentId, tripId) + // The trip ID drives search scoping, so the index must follow the new linkage. + syncVaultIndex() + _userMessage.value = + if (tripId != null) "Document added to trip" else "Document removed from trip" + } catch (e: Exception) { + Log.e(TAG, "Failed to link document $documentId to trip $tripId", e) + _userMessage.value = "Failed to update trip link: ${e.message}" + } + } + } + + /** Deletes a travel document and its private storage file. */ + fun deleteDocument(document: TravelDocument) { + viewModelScope.launch { + try { + travelDocumentManager.deleteDocument(document) + // Prune before surfacing success so a deleted document can never resurface in search. + documentSemanticSearch.removeFromIndex(document.id) + _vaultResults.value = _vaultResults.value?.filterNot { it.id == document.id } + _userMessage.value = "Deleted \"${document.title}\"" + if (_selectedDocument.value?.documentId == document.documentId) { + _selectedDocument.value = null + _previewContent.value = null + _previewBitmap.value = null + } + } catch (e: Exception) { + Log.e(TAG, "Failed to delete document ${document.documentId}", e) + _userMessage.value = "Failed to delete document: ${e.message}" + } + } + } + + /** Loads a thumbnail or rendered page 0 bitmap for visual documents (images and PDFs). */ + suspend fun loadCardBitmap(document: TravelDocument): Bitmap? { + if (!travelDocumentManager.isVisualDocument(document)) return null + return withContext(Dispatchers.IO) { + try { + travelDocumentManager.loadDocumentBitmap(document, maxDimension = 768) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + } + } + + /** Selects a document to inspect its visual bitmap (image/PDF) and text content in a dialog. */ + fun selectDocumentForPreview(document: TravelDocument?) { + _selectedDocument.value = document + if (document == null) { + _previewContent.value = null + _previewBitmap.value = null + return + } + + viewModelScope.launch { + _previewBitmap.value = + if (travelDocumentManager.isVisualDocument(document)) { + withContext(Dispatchers.IO) { + try { + travelDocumentManager.loadDocumentBitmap(document, maxDimension = 1280) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + } + } else { + null + } + try { + val content = withContext(Dispatchers.IO) { + travelDocumentManager.readDocumentContent(document) + } + _previewContent.value = content + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Could not read content for ${document.documentId}", e) + _previewContent.value = "Unable to read document contents." + } + } + } + + /** Resets and re-seeds the diverse multimodal demo documents (PDF, Email, Image, Text). */ + fun seedDefaultDocuments() { + viewModelScope.launch { + _isProcessing.value = true + try { + clearVaultSearch() + travelDocumentManager.resetDefaultDocuments() + syncVaultIndex() + _userMessage.value = "Loaded 8 diverse demo documents (PDF, Email, Image, Text)" + } finally { + _isProcessing.value = false + } + } + } + + fun clearUserMessage() { + _userMessage.value = null + } + + companion object { + private const val TAG = "DocumentsViewModel" + + /** The vault is a small personal corpus, so a short ranked list is the useful answer. */ + private const val VAULT_SEARCH_MAX_RESULTS = 5 + } +} 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..539bcdea --- /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 = 17.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 = MaterialTheme.colorScheme.primary, + 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.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + shape = RoundedCornerShape(24.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..8568c978 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/model/TravelAgentUiState.kt @@ -0,0 +1,185 @@ +/* + * 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.data.itinerary.TravelDocument +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 a travel document (e.g. passport or visa) with its checked validity status. */ + data class TravelDocumentHighlight( + val document: TravelDocument, + val validityStatus: String, + val validityExplanation: String, + ) : 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..18960e22 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt @@ -0,0 +1,1601 @@ +/* + * 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.graphics.BitmapFactory +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.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager +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.example.jetpacker.feature.assistant.documents.DocumentSemanticSearch +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.io.File +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 retrieve stored travel documents (passports, visas, IDs) from the Room database. */ +class GetTravelDocumentTool( + private val travelDocumentDao: TravelDocumentDao, + private val documentSemanticSearch: DocumentSemanticSearch? = null, +) { + val name = "get_travel_document" + val description = "Retrieves stored travel documents, hotel welcome cards, Wi-Fi networks and passwords, flight vouchers, tickets, IDs, driver's licenses, baggage claim tags, parking signs, and travel notes. Call this whenever the user asks about Wi-Fi passwords, lodging details, reservations, tickets, or travel documents." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "documentType" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Optional document type filter: 'WIFI', 'HOTEL', 'PASSPORT', 'VISA', 'TICKET', 'DRIVER_LICENSE', 'BAGGAGE', 'PARKING', 'TRANSIT', 'MUSEUM', 'NOTE', or 'OTHER'.", + ), + "query" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Natural language search query to locate relevant documents, Wi-Fi cards, hotel info, tickets, or notes (e.g. 'wifi password', 'hotel', 'passport', 'parking', 'baggage').", + ), + "documentId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional unique identifier of a specific document.", + ), + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional trip ID to filter documents for a specific trip.", + ), + "eventId" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Optional timeline event ID to filter documents linked to a specific event.", + ), + ), + ) + + suspend fun execute(args: Map): Map { + val documentId = args["documentId"]?.toString() + val documentType = args["documentType"]?.toString() + val query = args["query"]?.toString() + val tripId = args["tripId"]?.toString() + val eventId = args["eventId"]?.toString() + + val documents: List = + if (!documentId.isNullOrBlank()) { + val doc = travelDocumentDao.getDocumentById(documentId) + if (doc != null) listOf(doc) else emptyList() + } else if (!query.isNullOrBlank() && documentSemanticSearch != null) { + documentSemanticSearch.findRelevantDocuments(query = query, tripId = tripId) + } else if (!eventId.isNullOrBlank()) { + travelDocumentDao.getDocumentsForEvent(eventId).first() + } else if (!tripId.isNullOrBlank()) { + travelDocumentDao.getDocumentsForTrip(tripId).first() + } else if (!documentType.isNullOrBlank()) { + travelDocumentDao.getDocumentsByType(documentType).first().ifEmpty { + travelDocumentDao.getDocumentsByType(documentType.uppercase()).first() + } + } else { + travelDocumentDao.getAllDocuments().first() + } + + val docList = documents.map { doc -> + val expiryDateString = + if (doc.expiryDate > 0L) { + try { + Instant.ofEpochMilli(doc.expiryDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .format(DateTimeFormatter.ISO_LOCAL_DATE) + } catch (e: Exception) { + "" + } + } else { + "" + } + mapOf( + "id" to doc.id, + "documentId" to doc.documentId, + "title" to doc.title, + "documentType" to doc.documentType, + "issuingCountry" to doc.issuingCountry, + "expiryDate" to expiryDateString, + "expiryDateLong" to doc.expiryDate, + "documentNumberMasked" to doc.documentNumberMasked, + "notes" to doc.notes, + "tripId" to (doc.tripId ?: ""), + "eventId" to (doc.eventId ?: ""), + "hasFile" to doc.filePath.isNotBlank(), + ) + } + + return mapOf("documents" to docList, "count" to docList.size) + } +} + +/** Tool to check passport or visa validity for travel against the 6-month validity rule. */ +class CheckDocumentValidityTool( + private val travelDocumentDao: TravelDocumentDao, + private val tripDao: TripDao, +) { + val name = "check_document_validity" + val description = "Checks whether a travel document (e.g., passport or visa) is valid for travel relative to departure dates, enforcing the 6-month validity rule." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "documentType" to + FunctionSchema( + type = FunctionType.STRING, + description = "The document type to check: 'passport' (default) or 'visa'.", + ), + "travelStartDate" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional travel start date in ISO YYYY-MM-DD format.", + ), + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional trip identifier to look up travel dates from.", + ), + ), + ) + + suspend fun execute(args: Map): Map { + val docType = args["documentType"]?.toString()?.takeIf { it.isNotBlank() } ?: "passport" + val travelDateStr = args["travelStartDate"]?.toString() + val tripId = args["tripId"]?.toString() + + val referenceDate: LocalDate = + if (!travelDateStr.isNullOrBlank()) { + try { + LocalDate.parse(travelDateStr, DateTimeFormatter.ISO_LOCAL_DATE) + } catch (e: Exception) { + LocalDate.now() + } + } else if (!tripId.isNullOrBlank()) { + val trip = tripDao.getTripById(tripId).first() + if (trip != null && trip.startDate > 0L) { + try { + Instant.ofEpochMilli(trip.startDate).atZone(ZoneId.systemDefault()).toLocalDate() + } catch (e: Exception) { + LocalDate.now() + } + } else { + LocalDate.now() + } + } else { + val allTrips = tripDao.getAllTrips().first() + val now = System.currentTimeMillis() + val upcoming = + allTrips.filter { it.startDate >= now }.minByOrNull { it.startDate } + ?: allTrips.firstOrNull() + if (upcoming != null && upcoming.startDate > 0L) { + try { + Instant.ofEpochMilli(upcoming.startDate).atZone(ZoneId.systemDefault()).toLocalDate() + } catch (e: Exception) { + LocalDate.now() + } + } else { + LocalDate.now() + } + } + + val docs = + travelDocumentDao.getDocumentsByType(docType).first().ifEmpty { + travelDocumentDao.getDocumentsByType(docType.uppercase()).first() + } + if (docs.isEmpty()) { + return mapOf( + "status" to "NOT_FOUND", + "documentType" to docType, + "validForTravel" to false, + "explanation" to "No $docType document found in your records. Please add your $docType.", + ) + } + + val primaryDoc = docs.first() + val expiryDate: LocalDate = + if (primaryDoc.expiryDate > 0L) { + try { + Instant.ofEpochMilli(primaryDoc.expiryDate).atZone(ZoneId.systemDefault()).toLocalDate() + } catch (e: Exception) { + return mapOf( + "status" to "UNKNOWN", + "documentType" to docType, + "validForTravel" to false, + "explanation" to + "Could not parse document expiry date timestamp: ${primaryDoc.expiryDate}", + ) + } + } else { + return mapOf( + "status" to "UNKNOWN", + "documentType" to docType, + "validForTravel" to false, + "explanation" to "Document expiry date is missing or invalid.", + ) + } + + val expiryDateStr = expiryDate.format(DateTimeFormatter.ISO_LOCAL_DATE) + val daysUntilExpiry = ChronoUnit.DAYS.between(referenceDate, expiryDate) + val monthsUntilExpiry = ChronoUnit.MONTHS.between(referenceDate, expiryDate) + + val (status, explanation, validForTravel) = + when { + daysUntilExpiry < 0 -> { + Triple( + "EXPIRED", + "Your $docType expired on $expiryDateStr and cannot be used for travel.", + false, + ) + } + daysUntilExpiry < 180 -> { + Triple( + "EXPIRING_SOON", + "Your $docType expires on $expiryDateStr ($monthsUntilExpiry months / $daysUntilExpiry days from travel date). Most countries require at least 6 months validity from entry date. Renewal is strongly recommended.", + false, + ) + } + else -> { + Triple( + "VALID", + "Your $docType is valid until $expiryDateStr ($monthsUntilExpiry months remaining). It meets international travel validity requirements.", + true, + ) + } + } + + return mapOf( + "status" to status, + "documentType" to primaryDoc.documentType, + "documentId" to primaryDoc.documentId, + "issuingCountry" to primaryDoc.issuingCountry, + "expiryDate" to expiryDateStr, + "expiryDateLong" to primaryDoc.expiryDate, + "documentNumberMasked" to primaryDoc.documentNumberMasked, + "validForTravel" to validForTravel, + "daysUntilExpiry" to daysUntilExpiry, + "explanation" to explanation, + ) + } +} + +/** + * Tool to read full text content of an attached travel document (flight voucher, ticket, passport + * scan). + */ +class ReadDocumentContentTool( + private val travelDocumentManager: TravelDocumentManager, + private val travelDocumentDao: TravelDocumentDao, + private val documentSemanticSearch: DocumentSemanticSearch? = null, +) { + val name = "read_document_content" + val description = "Reads the full text, Wi-Fi details, or notes of a saved travel document, hotel welcome card, ticket, or voucher from the on-device document store." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "documentId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Unique ID of the document to read (e.g., from get_travel_document).", + ), + "query" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Keyword to search for in document title or notes (e.g., 'flight', 'passport', 'esta').", + ), + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional trip ID to scope document search to a specific trip.", + ), + ), + ) + + suspend fun execute(args: Map): Map { + val docId = args["documentId"]?.toString() + val query = args["query"]?.toString() + val tripId = args["tripId"]?.toString() + + val allDocs = travelDocumentDao.getAllDocuments().first() + val targetDoc = + if (!docId.isNullOrBlank()) { + allDocs.find { it.documentId == docId || it.id == docId } + } else if (!query.isNullOrBlank() && documentSemanticSearch != null) { + val matches = + documentSemanticSearch.findRelevantDocuments( + query = query, + tripId = tripId, + maxResults = 1, + ) + matches.firstOrNull() + ?: allDocs.find { + it.title.contains(query, ignoreCase = true) || + it.documentType.contains(query, ignoreCase = true) || + it.notes.contains(query, ignoreCase = true) + } + } else if (!query.isNullOrBlank()) { + allDocs.find { + it.title.contains(query, ignoreCase = true) || + it.documentType.contains(query, ignoreCase = true) || + it.notes.contains(query, ignoreCase = true) + } + } else { + allDocs.firstOrNull() + } + + if (targetDoc == null) { + return mapOf("error" to "Document not found", "status" to "NOT_FOUND") + } + + val isImage = + targetDoc.filePath.isNotBlank() && + targetDoc.filePath.substringAfterLast('.', "").lowercase() in + listOf("png", "jpg", "jpeg", "webp") + val extractedText = + if (isImage && targetDoc.notes.isBlank()) { + val extracted = extractTextFromImage(targetDoc.filePath) + if (extracted != null) { + try { + travelDocumentDao.insertDocument(targetDoc.copy(notes = extracted)) + } catch (e: Exception) { + Log.w("ReadDocContentTool", "Could not cache extracted text in document", e) + } + } + extracted + } else { + null + } + + val content = travelDocumentManager.readDocumentContent(targetDoc) + val finalContent = + if (!extractedText.isNullOrBlank()) { + "$content\n\nExtracted Image Text:\n$extractedText" + } else { + content + } + + return mapOf( + "documentId" to targetDoc.documentId, + "title" to targetDoc.title, + "documentType" to targetDoc.documentType, + "content" to finalContent, + "status" to "SUCCESS", + ) + } + + private suspend fun extractTextFromImage(filePath: String): String? = + withContext(Dispatchers.IO) { + try { + val file = File(filePath) + if (!file.exists() || !file.isFile) return@withContext null + val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return@withContext null + val generativeModel = Generation.getClient() + val prompt = + "You are an on-device document extractor. Extract all key text, names, dates (expiration, departure, arrival), document numbers, country, and travel details from this image. Format concisely as structured bullet points." + val request = generateContentRequest(ImagePart(bitmap), TextPart(prompt)) {} + val response = generativeModel.generateContent(request) + response.candidates.firstOrNull()?.text?.takeIf { it.isNotBlank() } + } catch (e: Exception) { + Log.w("ReadDocContentTool", "Could not extract text from document image", e) + null + } + } +} + +/** Tool to save a new travel document or note into the on-device document store. */ +class AddTravelDocumentTool(private val travelDocumentManager: TravelDocumentManager) { + val name = "add_travel_document" + val description = "Saves a new travel document, passport details, visa waiver, or travel voucher into the user's travel documents store." + val parametersSchema = + FunctionSchema( + type = FunctionType.OBJECT, + properties = + mapOf( + "title" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Title or description of the document (e.g. 'Dutch Passport', 'Flight Voucher').", + ), + "documentType" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Document type: 'PASSPORT', 'VISA', 'TICKET', 'NATIONAL_ID', or 'OTHER'.", + ), + "issuingCountry" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Country that issued the document (e.g. 'Netherlands', 'United States').", + ), + "expiryDate" to + FunctionSchema( + type = FunctionType.STRING, + description = "Expiration date in ISO YYYY-MM-DD format (if applicable).", + ), + "documentNumberMasked" to + FunctionSchema( + type = FunctionType.STRING, + description = "Masked number (e.g. '••••4821').", + ), + "notes" to + FunctionSchema( + type = FunctionType.STRING, + description = "Additional travel notes, restrictions, or instructions.", + ), + "content" to + FunctionSchema( + type = FunctionType.STRING, + description = "Full text body or itinerary notes to save.", + ), + "tripId" to + FunctionSchema( + type = FunctionType.STRING, + description = "Optional trip ID to link this document to a specific trip.", + ), + "eventId" to + FunctionSchema( + type = FunctionType.STRING, + description = + "Optional timeline event ID to link this document to a specific event (e.g., flight confirmation).", + ), + ), + required = listOf("title"), + ) + + suspend fun execute(args: Map): Map { + val title = args["title"]?.toString() ?: "Untitled Document" + val docType = args["documentType"]?.toString()?.uppercase() ?: "OTHER" + val country = args["issuingCountry"]?.toString() ?: "" + val expiryStr = args["expiryDate"]?.toString() + val maskedNum = args["documentNumberMasked"]?.toString() ?: "" + val notes = args["notes"]?.toString() ?: "" + val content = args["content"]?.toString() ?: notes + val tripId = args["tripId"]?.toString() + val eventId = args["eventId"]?.toString() + + val expiryEpoch = + if (!expiryStr.isNullOrBlank()) { + try { + val date = LocalDate.parse(expiryStr, DateTimeFormatter.ISO_LOCAL_DATE) + date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + } catch (e: Exception) { + 0L + } + } else { + 0L + } + + val savedDoc = + travelDocumentManager.createTextDocument( + title = title, + content = content, + documentType = docType, + issuingCountry = country, + expiryDate = expiryEpoch, + documentNumberMasked = maskedNum, + notes = notes, + tripId = tripId, + eventId = eventId, + ) + + return mapOf( + "status" to "SUCCESS", + "documentId" to savedDoc.documentId, + "title" to savedDoc.title, + "message" to "Saved $title successfully to travel documents.", + ) + } +} + +/** 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 getTravelDocument: GetTravelDocumentTool, + val checkDocumentValidity: CheckDocumentValidityTool, + val readDocumentContent: ReadDocumentContentTool? = null, + val addTravelDocument: AddTravelDocumentTool? = null, + 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, + travelDocumentDao: TravelDocumentDao, + expenseDao: ExpenseDao? = null, + travelDocumentManager: TravelDocumentManager? = null, + documentSemanticSearch: DocumentSemanticSearch? = null, + tourDetailDao: TourDetailDao? = null, + activeTripIdProvider: () -> String? = { null }, + ) : this( + getFlightItinerary = GetFlightItineraryTool(eventDao, tripDao, activeTripIdProvider), + getTravelDocument = GetTravelDocumentTool(travelDocumentDao, documentSemanticSearch), + checkDocumentValidity = CheckDocumentValidityTool(travelDocumentDao, tripDao), + readDocumentContent = + travelDocumentManager?.let { + ReadDocumentContentTool(it, travelDocumentDao, documentSemanticSearch) + }, + addTravelDocument = travelDocumentManager?.let { AddTravelDocumentTool(it) }, + 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) + add(getTravelDocument) + add(checkDocumentValidity) + readDocumentContent?.let { add(it) } + addTravelDocument?.let { add(it) } + 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..8b49ac2d --- /dev/null +++ b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/ui/TravelAssistantCard.kt @@ -0,0 +1,1688 @@ +/* + * 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.core.ui.AdsBorderBrush +import com.example.jetpacker.core.ui.AdsLoadingBorderBrush +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.TravelDocumentHighlight +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 + } + } + } + + val isAssistantLoading = uiState is Thinking || uiState is ExecutingTool || voiceState.isListening + + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = + BorderStroke( + if (isAssistantLoading) 1.5.dp else 1.dp, + if (isAssistantLoading) AdsLoadingBorderBrush else AdsBorderBrush, + ), + ) { + 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 TravelDocumentHighlight -> { + DocumentHighlightCard(highlight = highlight) + Spacer(modifier = Modifier.height(8.dp)) + } + 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 travel document validity details. */ +@Composable +private fun DocumentHighlightCard( + highlight: TravelDocumentHighlight, + modifier: Modifier = Modifier, +) { + val (badgeColor, textColor, icon) = + when (highlight.validityStatus) { + "VALID" -> Triple(Color(0xFF01AF57), Color(0xFF061B0E), Icons.Default.CheckCircle) + "EXPIRING_SOON" -> Triple(Color(0xFFE65100), Color.White, Icons.Default.Warning) + "EXPIRED" -> Triple(MaterialTheme.colorScheme.error, Color.White, Icons.Default.ErrorOutline) + else -> + Triple( + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.onSurfaceVariant, + Icons.Default.Warning, + ) + } + + 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, + ) { + Text( + text = + "${highlight.document.issuingCountry} ${highlight.document.documentType.replaceFirstChar { it.uppercase() }}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Surface(shape = MaterialTheme.shapes.small, color = badgeColor) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = textColor, + modifier = Modifier.size(14.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = highlight.validityStatus.replace('_', ' '), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = textColor, + ) + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + val formattedExpiry = + if (highlight.document.expiryDate > 0L) { + try { + Instant.ofEpochMilli(highlight.document.expiryDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() + } catch (e: Exception) { + highlight.document.expiryDate.toString() + } + } else { + "Unknown" + } + + Text( + text = "Number: ${highlight.document.documentNumberMasked} • Expires: $formattedExpiry", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (highlight.validityExplanation.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = highlight.validityExplanation, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +/** 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(0xFF01AF57), + modifier = Modifier.size(14.dp), + ) + } + } + } + } + } +} + +private fun getHumanizedToolInfo( + toolName: String, + isCompleted: Boolean, +): Pair { + val cleanName = + toolName.removePrefix("BaseJetPackerAppFunctionService_").substringAfterLast('.').substringAfterLast('#') + return when (cleanName) { + "get_travel_document" -> + (if (isCompleted) "Checked travel documents" else "Checking travel documents…") to + Icons.Default.Search + "read_document_content" -> + (if (isCompleted) "Read document details" else "Reading document details…") to + Icons.Default.DocumentScanner + "check_document_validity" -> + (if (isCompleted) "Verified document expiry" else "Verifying document expiry…") to + Icons.Default.Verified + "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 isSheetLoading = + displayOverlayState is AssistantOverlayState.PerformingActions || + displayOverlayState is AssistantOverlayState.Listening || + uiState is Thinking || + uiState is ExecutingTool || + voiceState.isListening + + 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( + if (isSheetLoading) 1.5.dp else 1.dp, + if (isSheetLoading) AdsLoadingBorderBrush else AdsBorderBrush, + ), + ) { + 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(0xFF01AF57) 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(0xFF01AF57).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(0xFF01AF57) + 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(0xFF01AF57), + 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..5daf3e59 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/ChatHistoryViewModelTest.kt @@ -0,0 +1,188 @@ +/* + * 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 } + } + + override suspend fun deleteAllConversations() { + conversations.value = emptyMap() + } +} 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/DocumentAppSearchManagerTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentAppSearchManagerTest.kt new file mode 100644 index 00000000..75b48e99 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentAppSearchManagerTest.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.assistant + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.feature.assistant.documents.DocumentAppSearchManager +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +class DocumentAppSearchManagerTest { + + private lateinit var context: Context + private var _manager: DocumentAppSearchManager? = null + private val manager: DocumentAppSearchManager get() = checkNotNull(_manager) + + @Before + fun setUp() { + try { + System.loadLibrary("icing") + } catch (t: Throwable) { + Assume.assumeNoException( + "Icing native library requires an Android runtime; skipping on host JVM", + t, + ) + } + context = ApplicationProvider.getApplicationContext() + _manager = DocumentAppSearchManager(context) + } + + @After fun tearDown() = runTest { _manager?.clearAll() } + + @Test + fun testIndexAndSearchDocument_byTextPrefix() = runTest { + val doc = + TravelDocument( + id = "doc-passport-1", + title = "US Passport", + documentType = "passport", + issuingCountry = "United States", + notes = "Issued in 2022, valid for 10 years", + ) + + manager.indexDocument(doc, textContent = "US Passport passport United States Issued in 2022") + + val results = manager.search(query = "Passport", maxResults = 5) + assertThat(results).contains("doc-passport-1") + } + + @Test + fun testRemoveDocument_removesFromIndex() = runTest { + val doc = + TravelDocument( + id = "doc-license-1", + title = "Driver's License", + documentType = "driver_license", + issuingCountry = "Netherlands", + notes = "Category B", + ) + + manager.indexDocument( + doc, + textContent = "Driver's License driver_license Netherlands Category B", + ) + + val beforeResults = manager.search(query = "License", maxResults = 5) + assertThat(beforeResults).contains("doc-license-1") + + manager.removeDocument("doc-license-1") + + val afterResults = manager.search(query = "License", maxResults = 5) + assertThat(afterResults).doesNotContain("doc-license-1") + } + + @Test + fun testModelSignature_recordsAndRetrieves() = runTest { + val signature = "embeddinggemma_v2_cpu_float32" + manager.setStoredModelSignature(signature) + + val stored = manager.getStoredModelSignature() + assertThat(stored).isEqualTo(signature) + } + + @Test + fun testIndexAndSearchDocument_withEmbeddingVector() = runTest { + val signature = "gemma_embed_v2_test" + val doc1 = + TravelDocument( + id = "doc-tokyo-hotel", + title = "Tokyo Grand Hotel Reservation", + documentType = "hotel_reservation", + issuingCountry = "Japan", + notes = "Confirmation code 98765, check in at 3pm", + ) + val doc2 = + TravelDocument( + id = "doc-berlin-museum", + title = "Berlin Pergamon Pass", + documentType = "museum_ticket", + issuingCountry = "Germany", + notes = "Valid on weekends", + ) + + // Vector 1 points along dimension 0, vector 2 points along dimension 1 + val vec1 = floatArrayOf(1.0f, 0.0f, 0.0f, 0.0f) + val vec2 = floatArrayOf(0.0f, 1.0f, 0.0f, 0.0f) + + manager.indexDocument( + doc1, + textContent = "Tokyo Hotel Reservation", + vector = vec1, + modelSignature = signature, + ) + manager.indexDocument( + doc2, + textContent = "Berlin Museum Pass", + vector = vec2, + modelSignature = signature, + ) + + // Query vector matches vec1 + val queryVec = floatArrayOf(0.95f, 0.05f, 0.0f, 0.0f) + val results = + manager.search( + query = "hotel in Tokyo", + queryVector = queryVec, + modelSignature = signature, + maxResults = 1, + ) + + assertThat(results).containsExactly("doc-tokyo-hotel") + } + + @Test + fun testSearch_scopedToTrip_includesGlobalDocumentsAndExcludesOtherTrips() = runTest { + val tripDoc = + TravelDocument( + id = "doc-paris-hotel", + title = "Hotel Lumiere Reservation", + documentType = "hotel_reservation", + notes = "Wifi network LUMIERE-GUEST", + tripId = "2026-3", + ) + val globalDoc = + TravelDocument( + id = "doc-global-passport", + title = "Dutch Passport", + documentType = "passport", + notes = "Valid until 2031", + ) + val otherTripDoc = + TravelDocument( + id = "doc-tokyo-hotel-2", + title = "Tokyo Capsule Reservation", + documentType = "hotel_reservation", + notes = "Wifi network TOKYO-GUEST", + tripId = "2026-9", + ) + + manager.indexDocument(tripDoc, textContent = "Hotel Lumiere Reservation wifi passport") + manager.indexDocument(globalDoc, textContent = "Dutch Passport wifi passport") + manager.indexDocument(otherTripDoc, textContent = "Tokyo Capsule Reservation wifi passport") + + val results = manager.search(query = "passport OR wifi", tripId = "2026-3", maxResults = 10) + + assertThat(results).containsAtLeast("doc-paris-hotel", "doc-global-passport") + assertThat(results).doesNotContain("doc-tokyo-hotel-2") + } + + @Test + fun testGetIndexedDocumentIds_returnsEveryIndexedDocument() = runTest { + manager.indexDocument( + TravelDocument(id = "doc-a", title = "Alpha", documentType = "note"), + textContent = "Alpha", + ) + manager.indexDocument( + TravelDocument(id = "doc-b", title = "Beta", documentType = "note"), + textContent = "Beta", + ) + + assertThat(manager.getIndexedDocumentIds()).containsAtLeast("doc-a", "doc-b") + + manager.removeDocument("doc-a") + + assertThat(manager.getIndexedDocumentIds()).doesNotContain("doc-a") + } +} diff --git a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentSemanticSearchTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentSemanticSearchTest.kt new file mode 100644 index 00000000..e2521ac5 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentSemanticSearchTest.kt @@ -0,0 +1,431 @@ +/* + * 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.content.Context +import android.graphics.Bitmap +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.jetpacker.data.itinerary.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager +import com.example.jetpacker.feature.assistant.documents.DocumentAppSearchManager +import com.example.jetpacker.feature.assistant.documents.DocumentSemanticSearch +import com.google.common.truth.Truth.assertThat +import com.google.mlkit.genai.common.DownloadStatus +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.embedding.EmbeddingMetadata +import com.google.mlkit.genai.embedding.EmbeddingModel +import com.google.mlkit.genai.embedding.EmbeddingRequest +import com.google.mlkit.genai.embedding.EmbeddingResult +import com.google.mlkit.genai.embedding.EmbeddingVector +import com.google.mlkit.genai.embedding.ImagePart +import com.google.mlkit.genai.embedding.Modality +import com.google.mlkit.genai.embedding.TextPart +import java.io.File +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * Covers the vault indexing contract behind Demo 1.B: documents must become retrievable as soon as + * they are added (not only on the very first query), and must disappear from the index the moment + * they are deleted. Also verifies multimodal `ImagePart`, PDF, and email (`.eml`) support. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +class DocumentSemanticSearchTest { + + private lateinit var context: Context + private lateinit var documentDao: FakeSemanticSearchDocumentDao + private lateinit var documentManager: TravelDocumentManager + private var _appSearchManager: DocumentAppSearchManager? = null + private val appSearchManager: DocumentAppSearchManager get() = checkNotNull(_appSearchManager) + private lateinit var semanticSearch: DocumentSemanticSearch + + private fun assumeAppSearchAvailable() { + Assume.assumeTrue( + "AppSearch LocalStorage requires native libicing runtime; skipping on host JVM", + _appSearchManager != null, + ) + } + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + documentDao = FakeSemanticSearchDocumentDao() + documentManager = TravelDocumentManager(context, documentDao) + val manager = + try { + System.loadLibrary("icing") + DocumentAppSearchManager(context) + } catch (_: Throwable) { + null + } + _appSearchManager = manager + semanticSearch = + DocumentSemanticSearch( + travelDocumentDao = documentDao, + travelDocumentManager = documentManager, + appSearchManager = manager, + ) + } + + @After fun tearDown() = runTest { _appSearchManager?.clearAll() } + + @Test + fun ensureIndexUpToDate_indexesEveryStoredDocument() = runTest { + assumeAppSearchAvailable() + documentDao.documents += passport() + documentDao.documents += hotelWifiNote() + + semanticSearch.ensureIndexUpToDate() + + assertThat(appSearchManager.getIndexedDocumentIds()) + .containsAtLeast("doc-passport", "doc-hotel-wifi") + } + + @Test + fun ensureIndexUpToDate_picksUpDocumentAddedAfterFirstQuery() = runTest { + assumeAppSearchAvailable() + documentDao.documents += passport() + semanticSearch.findRelevantDocuments(query = "passport") + + // Simulates scanning a document live, after the index has already been warmed once. + documentDao.documents += hotelWifiNote() + semanticSearch.ensureIndexUpToDate() + + assertThat(appSearchManager.getIndexedDocumentIds()).contains("doc-hotel-wifi") + } + + @Test + fun indexDocument_makesSingleDocumentRetrievableImmediately() = runTest { + assumeAppSearchAvailable() + val scanned = hotelWifiNote() + documentDao.documents += scanned + + semanticSearch.indexDocument(scanned) + + assertThat(appSearchManager.getIndexedDocumentIds()).contains("doc-hotel-wifi") + val matches = semanticSearch.findRelevantDocuments(query = "wifi password") + assertThat(matches.map { it.id }).contains("doc-hotel-wifi") + } + + @Test + fun removeFromIndex_dropsDeletedDocument() = runTest { + assumeAppSearchAvailable() + documentDao.documents += passport() + documentDao.documents += hotelWifiNote() + semanticSearch.ensureIndexUpToDate() + + documentDao.documents.removeAll { it.id == "doc-hotel-wifi" } + semanticSearch.removeFromIndex("doc-hotel-wifi") + + val indexed = appSearchManager.getIndexedDocumentIds() + assertThat(indexed).contains("doc-passport") + assertThat(indexed).doesNotContain("doc-hotel-wifi") + } + + @Test + fun ensureIndexUpToDate_prunesDocumentsDeletedOutsideTheViewModel() = runTest { + assumeAppSearchAvailable() + documentDao.documents += passport() + documentDao.documents += hotelWifiNote() + semanticSearch.ensureIndexUpToDate() + + documentDao.documents.removeAll { it.id == "doc-passport" } + semanticSearch.ensureIndexUpToDate() + + assertThat(appSearchManager.getIndexedDocumentIds()).doesNotContain("doc-passport") + } + + @Test + fun findRelevantDocuments_excludesOtherTrips() = runTest { + documentDao.documents += hotelWifiNote(tripId = "trip-paris") + documentDao.documents += + TravelDocument( + id = "doc-tokyo-wifi", + documentId = "doc-tokyo-wifi", + title = "Tokyo Capsule Hotel Wifi", + documentType = "note", + notes = "Network TOKYO-GUEST, password sakura2026", + tripId = "trip-tokyo", + ) + documentDao.documents += passport() + + val matches = + semanticSearch.findRelevantDocuments(query = "wifi password", tripId = "trip-paris") + + assertThat(matches.map { it.id }).doesNotContain("doc-tokyo-wifi") + } + + @Test + fun findRelevantDocuments_returnsEmptyForBlankQuery() = runTest { + documentDao.documents += passport() + + assertThat(semanticSearch.findRelevantDocuments(query = " ")).isEmpty() + } + + @Test + fun generateDocumentEmbedding_includesImagePartWhenModelSupportsImageModality() = runTest { + val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + val savedImageDoc = + documentManager.saveDocumentBitmap( + bitmap = bitmap, + title = "Boarding Pass Photo", + documentType = "TICKET", + notes = "Gate K42 Seat 14A", + ) + val multimodalModel = + FakeEmbeddingModel(supportedModalities = listOf(Modality.TEXT, Modality.IMAGE)) + + val vector = semanticSearch.generateDocumentEmbedding(savedImageDoc, multimodalModel) + + assertThat(vector).isNotNull() + val parts = multimodalModel.lastRequest?.content?.parts.orEmpty() + assertThat(parts.any { it is TextPart }).isTrue() + assertThat(parts.any { it is ImagePart }).isTrue() + } + + @Test + fun generateDocumentEmbedding_omitsImagePartWhenModelIsTextOnly() = runTest { + val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + val savedImageDoc = + documentManager.saveDocumentBitmap( + bitmap = bitmap, + title = "Boarding Pass Photo", + documentType = "TICKET", + notes = "Gate K42 Seat 14A", + ) + val textOnlyModel = FakeEmbeddingModel(supportedModalities = listOf(Modality.TEXT)) + + val vector = semanticSearch.generateDocumentEmbedding(savedImageDoc, textOnlyModel) + + assertThat(vector).isNotNull() + val parts = textOnlyModel.lastRequest?.content?.parts.orEmpty() + assertThat(parts).hasSize(1) + assertThat(parts.single()).isInstanceOf(TextPart::class.java) + } + + @Test + fun importEmailDocument_parsesHeadersAndBodyAndIsSearchable() = runTest { + val emlFile = File(context.cacheDir, "eurostar_booking.eml") + emlFile.writeText( + """ + From: bookings@eurostar.com + To: alex.chen@example.com + Subject: Eurostar Booking Confirmation #ES9921 + Date: Mon, 5 Oct 2026 09:00:00 +0000 + Content-Type: text/plain; charset="UTF-8" + + Your train from Paris Gare du Nord to London St Pancras is confirmed. + Coach 08, Seat 61. PNR: ZX88Q1. + """ + .trimIndent() + ) + + val imported = documentManager.importDocumentFromUri(Uri.fromFile(emlFile)) + semanticSearch.indexDocument(imported) + + assertThat(imported.title).isEqualTo("Eurostar Booking Confirmation #ES9921") + assertThat(imported.documentType).isEqualTo("TICKET") + val matches = semanticSearch.findRelevantDocuments(query = "St Pancras Coach 08") + assertThat(matches.map { it.id }).contains(imported.id) + } + + @Test + fun importPdfDocument_extractsTextStreamAndIsSearchable() = runTest { + val pdfFile = File(context.cacheDir, "flight_receipt.pdf") + pdfFile.writeText( + """ + %PDF-1.4 + 1 0 obj << /Type /Page >> endobj + 2 0 obj << /Length 88 >> + stream + BT /F1 12 Tf 72 712 Td (Lufthansa Flight LH454 Boarding Gate Z50 Confirmation MUC2026) Tj ET + endstream + endobj + %%EOF + """ + .trimIndent() + ) + + val imported = documentManager.importDocumentFromUri(Uri.fromFile(pdfFile)) + semanticSearch.indexDocument(imported) + + assertThat(imported.documentType).isEqualTo("TICKET") + assertThat(imported.notes).contains("Lufthansa Flight LH454") + val matches = semanticSearch.findRelevantDocuments(query = "Lufthansa Gate Z50") + assertThat(matches.map { it.id }).contains(imported.id) + } + + @Test + fun seedDefaultDocumentsIfEmpty_populatesAndIndexesDiverseFormatsAcrossModalities() = runTest { + documentManager.seedDefaultDocumentsIfEmpty() + semanticSearch.ensureIndexUpToDate() + + val seeded = documentDao.documents.toList() + assertThat(seeded).hasSize(8) + assertThat(seeded.map { it.mimeType }.toSet()) + .containsExactly("text/plain", "application/pdf", "message/rfc822", "image/jpeg") + + // Verify PDF text extraction works on the seeded PDF files + val pdfDoc = seeded.first { it.title.contains("Zephyr Wheels") } + val extractedPdfText = documentManager.extractPdfText(File(pdfDoc.filePath)) + assertThat(extractedPdfText).contains("Polestar 2 Long Range Dual Motor") + + // Verify searching across Email, PDF, Image, and Text seeded docs + val emailMatches = semanticSearch.findRelevantDocuments(query = "SEQUOIA2026") + assertThat(emailMatches.map { it.title }).contains("The Redwood Grove Hotel — Booking Email") + + val pdfMatches = semanticSearch.findRelevantDocuments(query = "FasTrak") + assertThat(pdfMatches.map { it.title }).contains("Zephyr Wheels Confirmation") + + val imageMatches = semanticSearch.findRelevantDocuments(query = "SFMOMA") + assertThat(imageMatches.map { it.title }).contains("SFMOMA VIP Museum & Exhibition Pass") + } + + @Test + fun syncIndex_whenVectorIsNull_doesNotCacheHashUntilEmbeddingModelAvailable() = runTest { + assumeAppSearchAvailable() + val doc = passport() + documentDao.documents += doc + + // 1. Model is null (e.g. not loaded or unavailable) + semanticSearch.setEmbeddingModelForTesting(null) + semanticSearch.ensureIndexUpToDate() + + // Comment #4: Hash must not be cached when vector is null + assertThat(semanticSearch.isDocumentHashIndexed(doc.id)).isFalse() + + // 2. Model becomes available later + val model = FakeEmbeddingModel(supportedModalities = listOf(Modality.TEXT)) + semanticSearch.setEmbeddingModelForTesting(model) + semanticSearch.ensureIndexUpToDate() + + // Now vector was generated, so hash is cached and embedding request occurred + assertThat(semanticSearch.isDocumentHashIndexed(doc.id)).isTrue() + assertThat(model.lastRequest).isNotNull() + } + + private fun passport() = + TravelDocument( + id = "doc-passport", + documentId = "doc-passport", + title = "Dutch Passport", + documentType = "passport", + issuingCountry = "Netherlands", + notes = "Issued 2021, valid ten years", + ) + + private fun hotelWifiNote(tripId: String? = null) = + TravelDocument( + id = "doc-hotel-wifi", + documentId = "doc-hotel-wifi", + title = "Hotel Lumiere Wifi", + documentType = "note", + notes = "Network LUMIERE-GUEST, password bonjour2026", + tripId = tripId, + ) +} + +private class FakeEmbeddingModel(private val supportedModalities: List) : EmbeddingModel { + var lastRequest: EmbeddingRequest? = null + + override suspend fun checkStatus(): Int = FeatureStatus.AVAILABLE + + override fun download(): Flow = flowOf(DownloadStatus.DownloadCompleted) + + override suspend fun warmup() {} + + override suspend fun getTokenLimit(): Int = 256 + + override suspend fun generateEmbedding(request: EmbeddingRequest): EmbeddingResult { + lastRequest = request + return EmbeddingResult( + embedding = EmbeddingVector(floatArrayOf(0.5f, 0.5f, 0.5f)), + modelSignature = "fake-embedding-gemma-v2", + ) + } + + override suspend fun getModelSignature(): String = "fake-embedding-gemma-v2" + + override suspend fun getMetadata(): EmbeddingMetadata = + EmbeddingMetadata(dimension = 3, supportedModalities = supportedModalities) + + override suspend fun isDeprecated(): Boolean = false + + override fun close() {} +} + +/** Minimal in-memory [TravelDocumentDao] so the tests never touch Room. */ +private class FakeSemanticSearchDocumentDao : TravelDocumentDao { + val documents = mutableListOf() + + override fun getAllDocuments(): Flow> = flowOf(documents.toList()) + + override fun getDocumentsByType(documentType: String): Flow> = + flowOf(documents.filter { it.documentType.equals(documentType, ignoreCase = true) }) + + override fun getDocumentsForTrip(tripId: String): Flow> = + flowOf(documents.filter { it.tripId == tripId }) + + override fun getDocumentsForEvent(eventId: String): Flow> = + flowOf(documents.filter { it.eventId == eventId }) + + override suspend fun getDocumentById(documentId: String): TravelDocument? = documents.find { + it.documentId == documentId || it.id == documentId + } + + override suspend fun insertDocument(document: TravelDocument) { + documents.add(document) + } + + override suspend fun linkDocumentToEvent(documentId: String, eventId: String?, tripId: String?) { + val index = documents.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (index != -1) { + val existing = documents[index] + documents[index] = existing.copy(eventId = eventId, tripId = tripId ?: existing.tripId) + } + } + + override suspend fun linkDocumentToTrip(documentId: String, tripId: String?) { + val index = documents.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (index != -1) { + documents[index] = documents[index].copy(tripId = tripId) + } + } + + override suspend fun deleteDocument(document: TravelDocument) { + documents.remove(document) + } + + override suspend fun deleteDocumentById(id: String) { + documents.removeAll { it.id == id } + } + + override suspend fun deleteAllDocuments() { + documents.clear() + } +} diff --git a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentsViewModelTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentsViewModelTest.kt new file mode 100644 index 00000000..963c2b32 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/DocumentsViewModelTest.kt @@ -0,0 +1,367 @@ +/* + * 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.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.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.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +import com.example.jetpacker.data.itinerary.TravelDocumentManager +import com.example.jetpacker.data.itinerary.TripSessionIdentifier +import com.example.jetpacker.data.itinerary.VoiceNoteEntity +import com.example.jetpacker.feature.assistant.documents.DocumentAppSearchManager +import com.example.jetpacker.feature.assistant.documents.DocumentSemanticSearch +import com.example.jetpacker.feature.assistant.documents.DocumentVisionScanner +import com.example.jetpacker.feature.assistant.documents.DocumentsViewModel +import com.google.common.truth.Truth.assertThat +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +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.Assume +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * Covers the write-through indexing hooks that make Demo 1.B's "save it now, ask about it now" + * story work. + * + * [DocumentSemanticSearchTest] proves the index itself behaves; these tests prove the ViewModel + * actually calls into it on every mutation. Without them a dropped `indexInVault` call would leave + * every unit test green while the live demo silently failed. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +class DocumentsViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var context: Context + private lateinit var documentDao: FakeVaultDocumentDao + private lateinit var appSearchManager: DocumentAppSearchManager + private lateinit var semanticSearch: DocumentSemanticSearch + private lateinit var viewModel: DocumentsViewModel + + @Before + fun setUp() { + try { + System.loadLibrary("icing") + } catch (t: Throwable) { + Assume.assumeNoException( + "Icing native library requires an Android runtime; skipping on host JVM", + t, + ) + } + Dispatchers.setMain(testDispatcher) + context = ApplicationProvider.getApplicationContext() + documentDao = FakeVaultDocumentDao() + appSearchManager = DocumentAppSearchManager(context) + val manager = TravelDocumentManager(context, documentDao) + semanticSearch = + DocumentSemanticSearch( + travelDocumentDao = documentDao, + travelDocumentManager = manager, + appSearchManager = appSearchManager, + ) + viewModel = + DocumentsViewModel( + travelDocumentManager = manager, + eventDao = FakeVaultEventDao(), + documentVisionScanner = DocumentVisionScanner(), + documentSemanticSearch = semanticSearch, + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun createTextDocument_isIndexedWithoutWaitingForASearch() = runTest { + settleUntil { documentDao.snapshot().isNotEmpty() } + + viewModel.createTextDocument( + title = "Hotel Lumiere Booking", + content = "Wifi network LUMIERE-GUEST, password bonjour2026", + documentType = "note", + tripId = TRIP_ID, + ) + settleUntil { documentDao.snapshot().any { it.title == "Hotel Lumiere Booking" } } + val created = documentDao.snapshot().single { it.title == "Hotel Lumiere Booking" } + + // The write-through hook is the only thing that can put this id in the index; nothing else + // re-syncs after a create. + settleUntil { appSearchManager.getIndexedDocumentIds().contains(created.id) } + + assertThat(appSearchManager.getIndexedDocumentIds()).contains(created.id) + } + + @Test + fun searchVault_findsADocumentCreatedMomentsEarlier() = runTest { + createLumiereNote() + + viewModel.onVaultQueryChange("lumiere wifi") + viewModel.searchVault(TRIP_ID) + settleUntil { viewModel.vaultResults.value != null } + + assertThat(viewModel.vaultResults.value?.map { it.title }).contains("Hotel Lumiere Booking") + } + + @Test + fun searchVault_reportsMatchCountAndOnDeviceProvenance() = runTest { + createLumiereNote() + + viewModel.onVaultQueryChange("lumiere wifi") + viewModel.searchVault(TRIP_ID) + settleUntil { viewModel.vaultSearchStatus.value != null } + + assertThat(viewModel.vaultSearchStatus.value).contains("100% on-device") + assertThat(viewModel.vaultSearchStatus.value).contains("ms") + } + + @Test + fun deleteDocument_dropsItFromTheIndexAndFromLiveResults() = runTest { + val created = createLumiereNote() + + viewModel.onVaultQueryChange("lumiere wifi") + viewModel.searchVault(TRIP_ID) + settleUntil { viewModel.vaultResults.value != null } + + viewModel.deleteDocument(created) + settleUntil { !appSearchManager.getIndexedDocumentIds().contains(created.id) } + + assertThat(appSearchManager.getIndexedDocumentIds()).doesNotContain(created.id) + assertThat(viewModel.vaultResults.value?.map { it.id } ?: emptyList()) + .doesNotContain(created.id) + } + + @Test + fun clearVaultSearch_restoresTheBrowsingList() = runTest { + advanceUntilIdle() + + viewModel.onVaultQueryChange("lumiere wifi") + viewModel.searchVault(TRIP_ID) + advanceUntilIdle() + + viewModel.clearVaultSearch() + + assertThat(viewModel.vaultQuery.value).isEmpty() + assertThat(viewModel.vaultResults.value).isNull() + assertThat(viewModel.vaultSearchStatus.value).isNull() + } + + @Test + fun importDocument_indexesEmailAndFindsItInVaultSearch() = runTest { + settleUntil { documentDao.snapshot().isNotEmpty() } + val emlFile = File(context.cacheDir, "shinkansen_ticket.eml") + emlFile.writeText( + """ + From: reserve@jr-central.co.jp + Subject: Shinkansen Nozomi 204 E-Ticket + Content-Type: text/html; charset="UTF-8" + +

Car 7 Seat 12E

QR Pickup Code: JR994021

+ """ + .trimIndent() + ) + + viewModel.importDocument(uri = Uri.fromFile(emlFile), tripId = TRIP_ID) + settleUntil { documentDao.snapshot().any { it.title == "Shinkansen Nozomi 204 E-Ticket" } } + val imported = documentDao.snapshot().single { it.title == "Shinkansen Nozomi 204 E-Ticket" } + settleUntil { appSearchManager.getIndexedDocumentIds().contains(imported.id) } + + viewModel.onVaultQueryChange("JR994021") + viewModel.searchVault(TRIP_ID) + settleUntil { viewModel.vaultResults.value != null } + + assertThat(viewModel.vaultResults.value?.map { it.title }) + .contains("Shinkansen Nozomi 204 E-Ticket") + } + + /** Creates the demo note and waits until the write-through hook has indexed it. */ + private suspend fun TestScope.createLumiereNote(): TravelDocument { + settleUntil { documentDao.snapshot().isNotEmpty() } + viewModel.createTextDocument( + title = "Hotel Lumiere Booking", + content = "Wifi network LUMIERE-GUEST, password bonjour2026", + documentType = "note", + tripId = TRIP_ID, + ) + settleUntil { documentDao.snapshot().any { it.title == "Hotel Lumiere Booking" } } + val created = documentDao.snapshot().single { it.title == "Hotel Lumiere Booking" } + settleUntil { appSearchManager.getIndexedDocumentIds().contains(created.id) } + return created + } + + /** + * Drains both clocks until [condition] holds. + * + * The ViewModel runs on the virtual test scheduler but the indexing and search paths hop to the + * IO dispatcher, which that scheduler does not own. `advanceUntilIdle()` alone therefore returns + * while real work is still in flight, so each pass advances the scheduler and then parks this + * thread to give the IO side real wall-clock time to catch up. + */ + private suspend fun TestScope.settleUntil(condition: suspend () -> Boolean) { + repeat(SETTLE_ATTEMPTS) { + advanceUntilIdle() + if (condition()) return + Thread.sleep(SETTLE_STEP_MS) + } + advanceUntilIdle() + } + + private companion object { + const val TRIP_ID = "2026-3" + const val SETTLE_ATTEMPTS = 100 + const val SETTLE_STEP_MS = 50L + } +} + +/** + * Minimal in-memory [TravelDocumentDao] so the tests never touch Room. + * + * Every access is synchronized: the ViewModel writes from IO threads while the test thread reads, + * and an unguarded [ArrayList] throws [java.util.ConcurrentModificationException] under that race. + */ +private class FakeVaultDocumentDao : TravelDocumentDao { + private val backing = mutableListOf() + + /** Immutable copy, safe to iterate from the test thread. */ + fun snapshot(): List = synchronized(backing) { backing.toList() } + + override fun getAllDocuments(): Flow> = flowOf(snapshot()) + + override fun getDocumentsByType(documentType: String): Flow> = + flowOf(snapshot().filter { it.documentType.equals(documentType, ignoreCase = true) }) + + override fun getDocumentsForTrip(tripId: String): Flow> = + flowOf(snapshot().filter { it.tripId == tripId }) + + override fun getDocumentsForEvent(eventId: String): Flow> = + flowOf(snapshot().filter { it.eventId == eventId }) + + override suspend fun getDocumentById(documentId: String): TravelDocument? = + snapshot().find { it.documentId == documentId || it.id == documentId } + + override suspend fun insertDocument(document: TravelDocument) { + synchronized(backing) { backing.add(document) } + } + + override suspend fun linkDocumentToEvent(documentId: String, eventId: String?, tripId: String?) { + synchronized(backing) { + val index = backing.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (index != -1) { + val existing = backing[index] + backing[index] = existing.copy(eventId = eventId, tripId = tripId ?: existing.tripId) + } + } + } + + override suspend fun linkDocumentToTrip(documentId: String, tripId: String?) { + synchronized(backing) { + val index = backing.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (index != -1) { + backing[index] = backing[index].copy(tripId = tripId) + } + } + } + + override suspend fun deleteDocument(document: TravelDocument) { + synchronized(backing) { backing.removeAll { it.id == document.id } } + } + + override suspend fun deleteDocumentById(id: String) { + synchronized(backing) { backing.removeAll { it.id == id } } + } + + override suspend fun deleteAllDocuments() { + synchronized(backing) { backing.clear() } + } +} + +/** [EventDao] stub; this suite only needs the document side of the ViewModel. */ +private class FakeVaultEventDao : EventDao { + override fun getAllSessionIds(): Flow> = flowOf(emptyList()) + + override fun getEventsForTrip(tripId: String): Flow> = flowOf(emptyList()) + + override suspend fun insertEvent(event: TimelineEvent) {} + + override suspend fun insertEvents(events: List) {} + + override suspend fun deleteEventsForTrip(tripId: String) {} + + override suspend fun deleteEvent(event: TimelineEvent) {} + + override suspend fun insertFlightDetail(detail: FlightDetail) {} + + override suspend fun insertHotelDetail(detail: HotelDetail) {} + + override suspend fun insertDiningDetail(detail: DiningDetail) {} + + override suspend fun insertActivityDetail(detail: ActivityDetail) {} + + override suspend fun insertMuseumDetail(detail: MuseumDetail) {} + + override fun getFlightDetail(eventId: String): Flow = flowOf(null) + + override fun getHotelDetail(eventId: String): Flow = flowOf(null) + + override fun getDiningDetail(eventId: String): Flow = flowOf(null) + + override fun getMuseumDetail(eventId: String): Flow = flowOf(null) + + override fun getEventById(eventId: String): Flow = flowOf(null) + + override suspend fun deleteAllEvents() {} + + override suspend fun deleteAllFlightDetails() {} + + override suspend fun deleteAllHotelDetails() {} + + override suspend fun deleteAllDiningDetails() {} + + override suspend fun deleteAllActivityDetails() {} + + override fun getVoiceNotesForTrip(tripId: String): Flow> = + flowOf(emptyList()) + + override suspend fun insertVoiceNote(note: VoiceNoteEntity) {} + + override suspend fun deleteVoiceNoteById(id: String) {} +} 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..8b011cc6 --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelDaoToolsTest.kt @@ -0,0 +1,577 @@ +/* + * 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.TravelDocument +import com.example.jetpacker.data.itinerary.TravelDocumentDao +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.CheckDocumentValidityTool +import com.example.jetpacker.feature.assistant.tools.GetEventDetailsTool +import com.example.jetpacker.feature.assistant.tools.GetFlightItineraryTool +import com.example.jetpacker.feature.assistant.tools.GetTravelDocumentTool +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 fakeDocumentDao: FakeTravelDocumentDao + private lateinit var fakeExpenseDao: FakeExpenseDao + + private lateinit var flightTool: GetFlightItineraryTool + private lateinit var documentTool: GetTravelDocumentTool + private lateinit var validityTool: CheckDocumentValidityTool + 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() + fakeDocumentDao = FakeTravelDocumentDao() + fakeExpenseDao = FakeExpenseDao() + + flightTool = GetFlightItineraryTool(fakeEventDao, fakeTripDao) + documentTool = GetTravelDocumentTool(fakeDocumentDao) + validityTool = CheckDocumentValidityTool(fakeDocumentDao, 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 getTravelDocument_returnsDocumentByType() = runTest { + val doc = + TravelDocument( + documentId = "pass-1", + documentType = "passport", + issuingCountry = "United States", + expiryDate = 1899849600000L, + documentNumberMasked = "•••• 4321", + ) + fakeDocumentDao.documents.add(doc) + + val result = documentTool.execute(mapOf("documentType" to "passport")) + + assertThat(result["count"]).isEqualTo(1) + val docs = result["documents"] as List<*> + val firstDoc = docs.first() as Map<*, *> + assertThat(firstDoc["documentId"]).isEqualTo("pass-1") + assertThat(firstDoc["issuingCountry"]).isEqualTo("United States") + assertThat(firstDoc["documentNumberMasked"]).isEqualTo("•••• 4321") + } + + @Test + fun checkDocumentValidity_validWhenOverSixMonths() = runTest { + val futureExpiry = + LocalDate.now().plusMonths(12).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + val doc = + TravelDocument( + documentId = "pass-valid", + documentType = "passport", + issuingCountry = "Netherlands", + expiryDate = futureExpiry, + documentNumberMasked = "•••• 9999", + ) + fakeDocumentDao.documents.add(doc) + + val result = validityTool.execute(mapOf("documentType" to "passport")) + + assertThat(result["status"]).isEqualTo("VALID") + assertThat(result["validForTravel"]).isEqualTo(true) + assertThat(result["issuingCountry"]).isEqualTo("Netherlands") + } + + @Test + fun checkDocumentValidity_expiringSoonWhenUnderSixMonths() = runTest { + val soonExpiry = + LocalDate.now().plusMonths(3).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + val doc = + TravelDocument( + documentId = "pass-soon", + documentType = "passport", + issuingCountry = "United States", + expiryDate = soonExpiry, + documentNumberMasked = "•••• 1111", + ) + fakeDocumentDao.documents.add(doc) + + val result = validityTool.execute(mapOf("documentType" to "passport")) + + assertThat(result["status"]).isEqualTo("EXPIRING_SOON") + assertThat(result["validForTravel"]).isEqualTo(false) + } + + @Test + fun checkDocumentValidity_expiredWhenPastDate() = runTest { + val pastExpiry = + LocalDate.now().minusMonths(2).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + val doc = + TravelDocument( + documentId = "pass-expired", + documentType = "passport", + issuingCountry = "United States", + expiryDate = pastExpiry, + documentNumberMasked = "•••• 0000", + ) + fakeDocumentDao.documents.add(doc) + + val result = validityTool.execute(mapOf("documentType" to "passport")) + + assertThat(result["status"]).isEqualTo("EXPIRED") + assertThat(result["validForTravel"]).isEqualTo(false) + } + + @Test + fun checkDocumentValidity_notFoundWhenMissing() = runTest { + val result = validityTool.execute(mapOf("documentType" to "visa")) + + assertThat(result["status"]).isEqualTo("NOT_FOUND") + assertThat(result["validForTravel"]).isEqualTo(false) + } + + @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 FakeTravelDocumentDao : TravelDocumentDao { + val documents = mutableListOf() + + override fun getAllDocuments(): Flow> = flowOf(documents.toList()) + + override fun getDocumentsByType(documentType: String): Flow> = + flowOf(documents.filter { it.documentType.equals(documentType, ignoreCase = true) }) + + override fun getDocumentsForTrip(tripId: String): Flow> = + flowOf(documents.filter { it.tripId == tripId }) + + override fun getDocumentsForEvent(eventId: String): Flow> = + flowOf(documents.filter { it.eventId == eventId }) + + override suspend fun getDocumentById(documentId: String): TravelDocument? = documents.find { + it.documentId == documentId || it.id == documentId + } + + override suspend fun insertDocument(document: TravelDocument) { + documents.add(document) + } + + override suspend fun linkDocumentToEvent(documentId: String, eventId: String?, tripId: String?) { + val idx = documents.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (idx != -1) { + val existing = documents[idx] + documents[idx] = existing.copy(eventId = eventId, tripId = tripId ?: existing.tripId) + } + } + + override suspend fun linkDocumentToTrip(documentId: String, tripId: String?) { + val idx = documents.indexOfFirst { it.documentId == documentId || it.id == documentId } + if (idx != -1) { + val existing = documents[idx] + documents[idx] = existing.copy(tripId = tripId) + } + } + + override suspend fun deleteDocument(document: TravelDocument) { + documents.remove(document) + } + + override suspend fun deleteDocumentById(id: String) { + documents.removeAll { it.id == id } + } + + override suspend fun deleteAllDocuments() { + documents.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/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripPanel.kt b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripPanel.kt index f8c01607..fa042f16 100644 --- a/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripPanel.kt +++ b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripPanel.kt @@ -156,7 +156,7 @@ fun CreateTripPanelContent( fontSize = 22.sp, fontFamily = SekuyaFontFamily, ), - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.onSurface, ) } }, diff --git a/jetpacker/android/feature/detail/hotel_chat/src/main/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChat.kt b/jetpacker/android/feature/detail/hotel_chat/src/main/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChat.kt index 6eb061a1..c3136094 100644 --- a/jetpacker/android/feature/detail/hotel_chat/src/main/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChat.kt +++ b/jetpacker/android/feature/detail/hotel_chat/src/main/kotlin/com/example/jetpacker/feature/detail/hotel_chat/HotelSupportChat.kt @@ -253,7 +253,11 @@ fun UserMessageRowFixed(message: SupportChatMessage) { .background(UserBubble) .padding(16.dp) ) { - Text(text = message.text, color = Color.White, style = MaterialTheme.typography.bodyMedium) + Text( + text = message.text, + color = Color(0xFF061B0E), + style = MaterialTheme.typography.bodyMedium, + ) } Spacer(modifier = Modifier.width(8.dp)) Icon( @@ -276,9 +280,9 @@ fun BotMessageRowFixed( Icon( Icons.Filled.Face, contentDescription = "Other", - tint = TextSecondary, + tint = Color(0xFF131314), modifier = - Modifier.size(32.dp).clip(CircleShape).background(Color(0xFFE2E8F0)).padding(4.dp), + Modifier.size(32.dp).clip(CircleShape).background(Color(0xFFEEEEEE)).padding(4.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text( @@ -316,11 +320,11 @@ fun BotMessageRowFixed( } } -val PrimaryBlue = Color(0xFF0F172A) -val UserBubble = Color(0xFF0F172A) -val BotBubble = Color(0xFFF1F5F9) -val TextPrimary = Color(0xFF1E293B) -val TextSecondary = Color(0xFF64748B) +val PrimaryBlue = Color(0xFF01AF57) +val UserBubble = Color(0xFF01AF57) +val BotBubble = Color(0xFF1E2024) +val TextPrimary = Color(0xFFF1F3F4) +val TextSecondary = Color(0xFF9AA0A6) @Composable fun InputBar( @@ -334,7 +338,7 @@ fun InputBar( modifier = Modifier.padding(16.dp) .clip(RoundedCornerShape(32.dp)) - .background(Color(0xFFF1F5F9)) + .background(Color(0xFF1E2024)) .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/jetpacker/android/feature/detail/museum_assistant/src/main/kotlin/com/example/jetpacker/feature/detail/museum_assistant/ChatbotScreen.kt b/jetpacker/android/feature/detail/museum_assistant/src/main/kotlin/com/example/jetpacker/feature/detail/museum_assistant/ChatbotScreen.kt index 043cb81e..5ffdc9e3 100644 --- a/jetpacker/android/feature/detail/museum_assistant/src/main/kotlin/com/example/jetpacker/feature/detail/museum_assistant/ChatbotScreen.kt +++ b/jetpacker/android/feature/detail/museum_assistant/src/main/kotlin/com/example/jetpacker/feature/detail/museum_assistant/ChatbotScreen.kt @@ -69,11 +69,11 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -val PrimaryBlue = Color(0xFF0F172A) -val UserBubble = Color(0xFF0F172A) -val BotBubble = Color(0xFFF1F5F9) -val TextPrimary = Color(0xFF1E293B) -val TextSecondary = Color(0xFF64748B) +val PrimaryBlue = Color(0xFF01AF57) +val UserBubble = Color(0xFF01AF57) +val BotBubble = Color(0xFF1E2024) +val TextPrimary = Color(0xFFF1F3F4) +val TextSecondary = Color(0xFF9AA0A6) data class ChatMessage(val id: Int, val text: String, val isUser: Boolean, val sender: String) @@ -180,7 +180,11 @@ fun UserMessageRow(message: ChatMessage) { .background(UserBubble) .padding(16.dp) ) { - Text(text = message.text, color = Color.White, style = MaterialTheme.typography.bodyMedium) + Text( + text = message.text, + color = Color(0xFF061B0E), + style = MaterialTheme.typography.bodyMedium, + ) } Spacer(modifier = Modifier.width(8.dp)) Icon(Icons.Filled.AccountCircle, contentDescription = "User", modifier = Modifier.size(32.dp)) @@ -234,7 +238,7 @@ fun InputBar( modifier = Modifier.padding(16.dp) .clip(RoundedCornerShape(32.dp)) - .background(Color(0xFFF1F5F9)) + .background(Color(0xFF1E2024)) .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailScreen.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailScreen.kt index 5e79c190..7a7fb408 100644 --- a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailScreen.kt +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/MuseumDetailScreen.kt @@ -315,13 +315,13 @@ fun MuseumDetailScreen( Modifier.fillMaxWidth() .height(120.dp) .clip(RoundedCornerShape(16.dp)) - .background(Color(0xFFE0E0E0)), + .background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center, ) { Icon( Icons.Rounded.Map, contentDescription = "Map Placeholder", - tint = Color.Gray, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(48.dp), ) } @@ -347,7 +347,7 @@ fun MuseumDetailScreen( modifier = Modifier.height(56.dp).fillMaxWidth(), colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, + containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, ), ) { diff --git a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailScreen.kt b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailScreen.kt index 91a1996b..ac49e2b0 100644 --- a/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailScreen.kt +++ b/jetpacker/android/feature/detail/src/main/kotlin/com/example/jetpacker/feature/detail/TourDetailScreen.kt @@ -270,13 +270,13 @@ fun TourDetailScreen( Modifier.fillMaxWidth() .height(120.dp) .clip(RoundedCornerShape(16.dp)) - .background(Color(0xFFE0E0E0)), + .background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center, ) { Icon( Icons.Rounded.Map, contentDescription = "Map Placeholder", - tint = Color.Gray, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(48.dp), ) } diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/SectionHeader.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/SectionHeader.kt index 9b4ce4bd..44e3875b 100644 --- a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/SectionHeader.kt +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/SectionHeader.kt @@ -21,6 +21,7 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -54,6 +55,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.ui.AdsBorderBrush import com.example.jetpacker.core.ui.SekuyaFontFamily @Composable @@ -119,7 +121,9 @@ fun SectionHeader( if (isExpandable) { Box( modifier = - Modifier.size(40.dp).border(2.dp, MaterialTheme.colorScheme.outline, CircleShape), + Modifier.size(40.dp) + .background(MaterialTheme.colorScheme.surfaceContainerLow, CircleShape) + .border(1.5.dp, AdsBorderBrush, CircleShape), contentAlignment = Alignment.Center, ) { AnimatedContent( diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/components/TripCard.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/components/TripCard.kt index ad37eacb..59711a9b 100644 --- a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/components/TripCard.kt +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/components/TripCard.kt @@ -73,6 +73,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import com.example.jetpacker.core.ui.AdsBorderBrush +import com.example.jetpacker.core.ui.AdsIconBadgeDark +import com.example.jetpacker.core.ui.AdsIconBadgeLight import com.example.jetpacker.core.ui.JetPackerTheme import com.example.jetpacker.core.ui.R as CoreUiR import com.example.jetpacker.core.ui.SekuyaFontFamily @@ -163,8 +166,8 @@ fun TripCard( Modifier.fillMaxWidth() .clip(RoundedCornerShape(40.dp)) .then(modifier) - .background(MaterialTheme.colorScheme.surface) - .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(40.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .border(1.5.dp, AdsBorderBrush, RoundedCornerShape(40.dp)) .clickable(onClick = onClick) .padding(16.dp), config = { @@ -275,19 +278,19 @@ private fun TripInfoItem(icon: ImageVector, text: String, modifier: Modifier = M Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Surface( modifier = Modifier.size(32.dp), - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(12.dp), + color = AdsIconBadgeLight, + shape = CircleShape, ) { Box(contentAlignment = Alignment.Center) { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + tint = AdsIconBadgeDark, ) } } diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt index 643ff55c..6b979ab4 100644 --- a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt @@ -65,7 +65,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.text.TextAutoSize 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 @@ -100,27 +102,32 @@ fun BookingAssistantScreen( Scaffold( modifier = modifier.fillMaxSize(), - containerColor = MaterialTheme.colorScheme.primary, + containerColor = MaterialTheme.colorScheme.background, topBar = { - Column(modifier = Modifier.background(MaterialTheme.colorScheme.primary)) { + Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { TopAppBar( title = { Column { Text( - text = uiState.trip?.title?.uppercase() ?: "ITINERARY", + text = "Trip Concierge", style = MaterialTheme.typography.titleLarge.copy( fontFamily = SekuyaFontFamily, + fontSize = 17.sp, ), - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, - overflow = TextOverflow.Ellipsis, + autoSize = + TextAutoSize.StepBased( + minFontSize = 12.sp, + maxFontSize = 17.sp, + ), ) uiState.trip?.location?.let { location -> Text( text = location, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -130,7 +137,7 @@ fun BookingAssistantScreen( Icon( imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back", - tint = MaterialTheme.colorScheme.onPrimary, + tint = MaterialTheme.colorScheme.onSurface, ) } }, @@ -139,17 +146,17 @@ fun BookingAssistantScreen( Icon( imageVector = Icons.Rounded.Refresh, contentDescription = "Reset Chat", - tint = MaterialTheme.colorScheme.onPrimary, + tint = MaterialTheme.colorScheme.onSurface, ) } }, colors = - TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primary), + TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background), ) if (uiState.isStreaming) { LinearProgressIndicator( modifier = Modifier.fillMaxWidth().height(3.dp), - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.primary, trackColor = Color.Transparent, ) } 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 47c84c76..13ad5db5 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 @@ -28,6 +28,7 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -112,6 +113,10 @@ import androidx.compose.ui.unit.sp import androidx.graphics.shapes.toPath import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.AdsBorderBrush +import com.example.jetpacker.core.ui.AdsIconBadgeDark +import com.example.jetpacker.core.ui.AdsIconBadgeLight +import com.example.jetpacker.core.ui.AdsLoadingBorderBrush import com.example.jetpacker.core.ui.EventColors import com.example.jetpacker.core.ui.SekuyaFontFamily import com.example.jetpacker.core.ui.components.JetPackerFabConfig @@ -267,7 +272,7 @@ fun ManageExpensesScreen( title = { Text( "Expenses", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleLarge.copy(fontSize = 17.sp), color = MaterialTheme.colorScheme.onSurface, fontFamily = SekuyaFontFamily, ) @@ -305,6 +310,7 @@ fun ManageExpensesScreen( TotalSpentDisplay( totalSpent = totalSpent, budget = budget, + isProcessing = isProcessing.value || isAssistantProcessingExpense, onBudgetClick = { isBudgetSheetVisible = true }, ) @@ -323,11 +329,11 @@ fun ManageExpensesScreen( onClick = onScanReceiptClick, shape = RoundedCornerShape(24.dp), color = - if (!isScanDisabled) MaterialTheme.colorScheme.secondary - else MaterialTheme.colorScheme.secondary.copy(alpha = 0.38f), + if (!isScanDisabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.primary.copy(alpha = 0.38f), contentColor = - if (!isScanDisabled) MaterialTheme.colorScheme.onSecondary - else MaterialTheme.colorScheme.onSecondary.copy(alpha = 0.38f), + if (!isScanDisabled) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.38f), enabled = !isScanDisabled, ) { Row( @@ -433,6 +439,7 @@ private fun TotalSpentDisplay( budget: Double, onBudgetClick: () -> Unit, modifier: Modifier = Modifier, + isProcessing: Boolean = false, ) { val animatedTotalSpent by animateFloatAsState( @@ -446,7 +453,16 @@ private fun TotalSpentDisplay( modifier = modifier.fillMaxWidth().padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Surface(shape = RoundedCornerShape(30.dp), color = MaterialTheme.colorScheme.surface) { + Surface( + shape = RoundedCornerShape(30.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = + Modifier.border( + 1.5.dp, + if (isProcessing) AdsLoadingBorderBrush else AdsBorderBrush, + RoundedCornerShape(30.dp), + ), + ) { Text( text = totalText, style = @@ -475,14 +491,14 @@ private fun TotalSpentDisplay( Spacer(modifier = Modifier.width(8.dp)) Surface( shape = RoundedCornerShape(30.dp), - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable { onBudgetClick() }, ) { Text( text = "$${budget.toInt()}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) } @@ -520,7 +536,8 @@ fun ShimmerExpenseItem(modifier: Modifier = Modifier) { ) Card( - modifier = modifier.fillMaxWidth(), + modifier = + modifier.fillMaxWidth().border(1.5.dp, AdsLoadingBorderBrush, RoundedCornerShape(12.dp)), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), ) { @@ -634,29 +651,21 @@ fun ExpenseItem( }, ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().border(1.dp, AdsBorderBrush, RoundedCornerShape(12.dp)), shape = RoundedCornerShape(12.dp), onClick = onClick, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), ) { Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Box(modifier = Modifier.size(50.dp), contentAlignment = Alignment.Center) { - val shape = MaterialShapes.SoftBoom - Box( - modifier = - Modifier.fillMaxSize().drawWithCache { - val path = shape.toPath().asComposePath() - val matrix = Matrix() - matrix.scale(size.width, size.height) - path.transform(matrix) - onDrawBehind { drawPath(path, color = color.content) } - } - ) + Box( + modifier = Modifier.size(48.dp).background(AdsIconBadgeLight, CircleShape), + contentAlignment = Alignment.Center, + ) { Icon( imageVector = icon, contentDescription = null, - tint = MaterialTheme.colorScheme.surface, - modifier = Modifier.size(16.dp), + tint = AdsIconBadgeDark, + modifier = Modifier.size(22.dp), ) } diff --git a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsCard.kt b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsCard.kt index 39a69c03..b44a3872 100644 --- a/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsCard.kt +++ b/jetpacker/android/feature/trip/itinerary/enrichment/src/main/kotlin/com/example/jetpacker/feature/itinerary_enrichment/TripSummaryAndTipsCard.kt @@ -80,6 +80,9 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import com.example.jetpacker.core.ui.AdsBorderBrush +import com.example.jetpacker.core.ui.AdsGlowColors +import com.example.jetpacker.core.ui.AdsLoadingBorderBrush import com.example.jetpacker.data.trips.DummyData import com.example.jetpacker.data.trips.Trip import java.time.ZoneId @@ -118,12 +121,7 @@ fun TripSummaryAndTipsCard(modifier: Modifier = Modifier, isLoading: Boolean, su val xOffset = width * (rotation / 360f) Brush.linearGradient( - colors = - listOf( - Color(0xFF9CEFFF), // Cyan - Color(0xFFD2E4FF), // Blue - Color(0xFFFFB59F), // Pink - ), + colors = AdsGlowColors, start = Offset(xOffset - width, 0f), end = Offset(xOffset, 0f), tileMode = TileMode.Mirror, @@ -140,13 +138,13 @@ fun TripSummaryAndTipsCard(modifier: Modifier = Modifier, isLoading: Boolean, su if (isLoaded) { brush = glowBrush radius = 24.dp.toPx() - offset = Offset(x = 0f, y = 16.dp.toPx()) + offset = Offset(x = 0f, y = 12.dp.toPx()) } } - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(24.dp)) .border( - 2.dp, - if (isLoaded) Color.Transparent else Color.Black.copy(alpha = 0.45f), + 1.5.dp, + if (isLoading) AdsLoadingBorderBrush else AdsBorderBrush, RoundedCornerShape(24.dp), ) .clip(RoundedCornerShape(24.dp)) @@ -245,7 +243,7 @@ fun TripSummaryAndTipsCard(modifier: Modifier = Modifier, isLoading: Boolean, su .height(40.dp) .background( Brush.verticalGradient( - listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceBright) + listOf(Color.Transparent, MaterialTheme.colorScheme.surfaceContainer) ) ) ) diff --git a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryScreen.kt b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryScreen.kt index 21792c1c..121be0f0 100644 --- a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryScreen.kt +++ b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryScreen.kt @@ -155,6 +155,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.example.jetpacker.core.flags.FeatureFlags +import com.example.jetpacker.core.ui.AdsBorderBrush +import com.example.jetpacker.core.ui.AdsIconBadgeDark +import com.example.jetpacker.core.ui.AdsIconBadgeLight +import com.example.jetpacker.core.ui.AdsLoadingBorderBrush import com.example.jetpacker.core.ui.EventColors import com.example.jetpacker.core.ui.JetPackerTheme import com.example.jetpacker.core.ui.R @@ -199,9 +203,6 @@ fun ItineraryScreen( LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val fabContainerColor = MaterialTheme.colorScheme.secondary - val fabContentColor = MaterialTheme.colorScheme.onSecondary - var eventToDelete by remember { mutableStateOf(null) } LaunchedEffect(tripId) { @@ -210,17 +211,7 @@ fun ItineraryScreen( } } - LaunchedEffect(Unit) { - onFabConfigChange( - JetPackerFabConfig( - icon = Icons.Rounded.Add, - onClick = { viewModel.showAddEvent() }, - contentDescription = "Add Event", - containerColor = fabContainerColor, - contentColor = fabContentColor, - ) - ) - } + LaunchedEffect(Unit) { onFabConfigChange(null) } if (uiState.showAddEventDialog) { AddEventDialog( @@ -243,19 +234,19 @@ fun ItineraryScreen( uiState.trip?.title?.uppercase() ?: "ITINERARY", style = MaterialTheme.typography.titleLarge.copy( - fontSize = 22.sp, + fontSize = 17.sp, fontFamily = SekuyaFontFamily, ), - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, - autoSize = TextAutoSize.StepBased(minFontSize = 12.sp, maxFontSize = 22.sp), + autoSize = TextAutoSize.StepBased(minFontSize = 12.sp, maxFontSize = 17.sp), ) uiState.trip?.location?.let { location -> Text( location, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -266,13 +257,16 @@ fun ItineraryScreen( } }, actions = { + IconButton(onClick = { viewModel.showAddEvent() }) { + Icon(Icons.Rounded.Add, contentDescription = "Add Event") + } IconButton(onClick = onEditTripClick) { Icon(Icons.Rounded.Edit, contentDescription = "Edit Trip") } }, scrollBehavior = scrollBehavior, colors = - TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.primary), + TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), ) }, containerColor = Color.Transparent, @@ -645,7 +639,11 @@ fun TimelineItemCard( ) { Card( onClick = onClick, - modifier = modifier.fillMaxWidth().padding(bottom = 16.dp), + modifier = + modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .border(1.dp, AdsBorderBrush, RoundedCornerShape(12.dp)), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), @@ -665,23 +663,23 @@ fun TimelineItemCard( Box( modifier = - Modifier.size(50.dp) + Modifier.size(48.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondary, MaterialShapes.Burst.toShape()), + .background(AdsIconBadgeLight, CircleShape), contentAlignment = Alignment.Center, ) { Icon( imageVector = icon, contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondary, - modifier = Modifier.size(18.dp), + tint = AdsIconBadgeDark, + modifier = Modifier.size(22.dp), ) } Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { - val tagColor = MaterialTheme.colorScheme.surface + val tagColor = eventColors.container val tagTextColor = eventColors.content Text( @@ -691,7 +689,7 @@ fun TimelineItemCard( color = tagTextColor, modifier = Modifier.background(tagColor, RoundedCornerShape(16.dp)) - .border(1.dp, tagTextColor, RoundedCornerShape(16.dp)) + .border(1.dp, tagTextColor.copy(alpha = 0.6f), RoundedCornerShape(16.dp)) .padding(horizontal = 8.dp, vertical = 2.dp), ) @@ -775,7 +773,10 @@ fun TimelineShimmerItem(modifier: Modifier = Modifier) { // Content column Card( - modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + modifier = + Modifier.fillMaxWidth() + .padding(bottom = 24.dp) + .border(1.5.dp, AdsLoadingBorderBrush, MaterialTheme.shapes.medium), shape = MaterialTheme.shapes.medium, colors = CardDefaults.elevatedCardColors(), ) { @@ -841,11 +842,11 @@ fun rememberShimmerBrush(): Brush { @Composable fun DashedLine(modifier: Modifier = Modifier) { - val dashedLineColor = MaterialTheme.colorScheme.onSurface + val dashedLineColor = MaterialTheme.colorScheme.onSurfaceVariant Canvas(modifier = modifier.width(2.dp)) { - val dashLength = 12.dp.toPx() - val gapLength = 2.dp.toPx() + val dashLength = 8.dp.toPx() + val gapLength = 5.dp.toPx() drawLine( color = dashedLineColor, start = Offset(size.width / 2, 0f), 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..c1193eb1 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.rounded.AutoAwesome import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Description 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,11 @@ 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.documents.DocumentsScreen +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 @@ -69,9 +84,11 @@ import com.example.jetpacker.feature.voice_notes.VoiceNotesScreen enum class TripTab { ITINERARY, + DOCUMENTS, EXPENSES, VOICE_NOTES, BOOKING, + CHAT_HISTORY, } /** @@ -86,35 +103,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.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) + + 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, + containerColor = MaterialTheme.colorScheme.background, 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,17 +182,27 @@ fun TripScreen( onBack = onBack, onEditTripClick = onEditTripClick, onEventClick = onEventClick, - onFabConfigChange = { fabConfig = it }, + onFabConfigChange = { fabConfigs[TripTab.ITINERARY] = it }, onNavigateToDebug = onNavigateToDebug, ) } + TripTab.DOCUMENTS -> { + DocumentsScreen( + tripId = tripId, + contentPadding = innerPadding, + onBack = { selectedTab = TripTab.ITINERARY }, + onFabConfigChange = { fabConfigs[TripTab.DOCUMENTS] = it }, + ) + } + TripTab.EXPENSES -> { ManageExpensesScreen( tripId = tripId, contentPadding = innerPadding, onBack = { selectedTab = TripTab.ITINERARY }, - onFabConfigChange = { fabConfig = it }, + onFabConfigChange = { fabConfigs[TripTab.EXPENSES] = it }, + isAssistantProcessingExpense = isAssistantProcessingExpense, ) } @@ -145,10 +211,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 +221,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, @@ -168,23 +278,40 @@ fun JetPackerBottomBar( fabConfig: JetPackerFabConfig?, modifier: Modifier = Modifier, ) { + val showDocuments = FeatureFlags.ENABLE_DOCUMENTS val showExpenses = FeatureFlags.ENABLE_EXPENSE_MANAGEMENT val showVoiceNotes = FeatureFlags.ENABLE_VOICE_NOTES val showBooking = FeatureFlags.ENABLE_BOOKING_ASSISTANT + val visibleTabCount = + 1 + + (if (showDocuments) 1 else 0) + + (if (showExpenses) 1 else 0) + + (if (showVoiceNotes) 1 else 0) + + (if (showBooking) 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, @@ -192,6 +319,14 @@ fun JetPackerBottomBar( selected = selectedTab == TripTab.ITINERARY, contentDescription = "Itinerary", ) + if (showDocuments) { + JetPackerToolbarAction( + icon = Icons.Rounded.Description, + onClick = { onTabSelected(TripTab.DOCUMENTS) }, + selected = selectedTab == TripTab.DOCUMENTS, + contentDescription = "Documents", + ) + } if (showExpenses) { JetPackerToolbarAction( icon = Icons.Rounded.Wallet, @@ -209,7 +344,6 @@ fun JetPackerBottomBar( contentDescription = "Voice Notes", ) } - if (showBooking) { JetPackerToolbarAction( selected = selectedTab == TripTab.BOOKING, @@ -220,21 +354,30 @@ fun JetPackerBottomBar( } } - 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.primary, + contentColor = displayFab.contentColor ?: MaterialTheme.colorScheme.onPrimary, + ) + } + } } } } diff --git a/jetpacker/android/feature/trip/voice_notes/src/main/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesScreen.kt b/jetpacker/android/feature/trip/voice_notes/src/main/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesScreen.kt index 8133d4b3..a14026bf 100644 --- a/jetpacker/android/feature/trip/voice_notes/src/main/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesScreen.kt +++ b/jetpacker/android/feature/trip/voice_notes/src/main/kotlin/com/example/jetpacker/feature/voice_notes/VoiceNotesScreen.kt @@ -115,6 +115,8 @@ import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.AdsBorderBrush +import com.example.jetpacker.core.ui.AdsLoadingBorderBrush import com.example.jetpacker.core.ui.JetPackerTheme import com.example.jetpacker.core.ui.SekuyaFontFamily import com.example.jetpacker.data.itinerary.TimelineEvent @@ -179,17 +181,17 @@ fun VoiceNotesScreen( ) } - val secondary = MaterialTheme.colorScheme.secondary + val primary = MaterialTheme.colorScheme.primary val tertiary = MaterialTheme.colorScheme.tertiary - val onSecondary = MaterialTheme.colorScheme.onSecondary + val onPrimary = MaterialTheme.colorScheme.onPrimary val onTertiary = MaterialTheme.colorScheme.onTertiary LaunchedEffect(voiceInputState.isReady, voiceInputState.isListening, voiceInputState.statusText) { onFabConfigChange( JetPackerFabConfig( onClick = handleListenToggle, - containerColor = if (voiceInputState.isListening) tertiary else secondary, - contentColor = if (voiceInputState.isListening) onTertiary else onSecondary, + containerColor = if (voiceInputState.isListening) tertiary else primary, + contentColor = if (voiceInputState.isListening) onTertiary else onPrimary, icon = if (voiceInputState.isListening) Icons.Rounded.Stop else Icons.Rounded.Mic, contentDescription = if (voiceInputState.isListening) "Stop" else "Record", ) @@ -215,7 +217,7 @@ fun VoiceNotesScreen( style = MaterialTheme.typography.titleLarge.copy( fontFamily = SekuyaFontFamily, - fontSize = 22.sp, + fontSize = 17.sp, ), color = MaterialTheme.colorScheme.onSurface, ) @@ -326,7 +328,7 @@ fun VoiceModeOverlay( label = "animation_progress", ) - JetPackerTheme(darkTheme = false) { + JetPackerTheme { Box(modifier = Modifier.fillMaxSize().clickable { onDismiss() }) { // 60% Surface Overlay (Scrim) Box( @@ -459,14 +461,12 @@ fun ProcessingVoiceNoteItem(placeholder: VoiceNotePlaceholder) { val shape = RoundedCornerShape(12.dp) Card( modifier = - Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outline, shape).dropShadow( - shape = shape - ) { - radius = 0f - spread = 0f - offset = Offset(x = 4.dp.toPx(), y = 6.dp.toPx()) - color = Color(0xFF20290A) - }, + Modifier.fillMaxWidth() + .border( + if (placeholder.isError) 1.dp else 1.5.dp, + if (placeholder.isError) AdsBorderBrush else AdsLoadingBorderBrush, + shape, + ), shape = shape, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), ) { @@ -576,13 +576,7 @@ fun VoiceNoteItem( modifier = Modifier.fillMaxWidth() .padding(bottom = 16.dp) - .border(1.dp, MaterialTheme.colorScheme.outline, shape) - .dropShadow(shape = shape) { - radius = 0f - spread = 0f - offset = Offset(x = 4.dp.toPx(), y = 6.dp.toPx()) - color = Color(0xFF20290A) - }, + .border(1.dp, AdsBorderBrush, shape), shape = shape, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), ) { @@ -607,7 +601,7 @@ fun VoiceNoteItem( Surface( shape = CircleShape, color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, Color(0xFF299EAF)), + border = BorderStroke(1.dp, AdsBorderBrush), modifier = Modifier.padding(bottom = 8.dp), ) { Text( 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..30af6375 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" @@ -52,7 +60,7 @@ targetSdk = "37" truth = "1.4.2" versionCode = "1" versionName = "1.0" -appfunctions = "1.0.0-alpha10" +appfunctions = "1.0.0-alpha13" [libraries] androidx-a2ui-model = { group = "androidx.a2ui", name = "a2ui-model", version.ref = "a2ui" } @@ -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..2488e8d1 100644 --- a/jetpacker/android/settings.gradle.kts +++ b/jetpacker/android/settings.gradle.kts @@ -75,3 +75,5 @@ include(":feature:trip:voice_notes") include(":feature:trip:itinerary:enrichment") include(":feature:appfunctions") include(":feature:trip:booking_assistant") + +include(":feature:assistant")