From 43ff1966f1d1abfad0d0b14618fa35737d64bcbe Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Tue, 3 Nov 2026 11:30:35 +0000 Subject: [PATCH 1/3] feat(assistant): port production evolution improvements for travel assistant --- jetpacker/README.md | 36 +- .../assistant/TravelAssistantViewModel.kt | 660 ++++++------------ .../feature/assistant/tools/TravelDaoTools.kt | 311 +++++---- .../assistant/TravelAssistantViewModelTest.kt | 384 ++++++++++ .../feature/assistant/TravelDaoToolsTest.kt | 2 +- jetpacker/server/agents/orchestrator.py | 2 +- 6 files changed, 763 insertions(+), 632 deletions(-) create mode 100644 jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModelTest.kt diff --git a/jetpacker/README.md b/jetpacker/README.md index ac8383a1..1c83252e 100644 --- a/jetpacker/README.md +++ b/jetpacker/README.md @@ -130,49 +130,49 @@ We support side-by-side execution so you can run and test updates on a parallel ##### Deploying to Parallel Staging (`jetpacker-server-v2`) 1. **Build and submit the image**: ```bash - gcloud builds submit --tag gcr.io/android-devrel-ci/jetpacker-server:v2 \ - --project=android-devrel-ci server/ + gcloud builds submit --tag gcr.io/YOUR_PROJECT_ID/jetpacker-server:v2 \ + --project=YOUR_PROJECT_ID server/ ``` 2. **Deploy to Cloud Run**: ```bash gcloud run deploy jetpacker-server-v2 \ - --image gcr.io/android-devrel-ci/jetpacker-server:v2 \ + --image gcr.io/YOUR_PROJECT_ID/jetpacker-server:v2 \ --region us-central1 \ - --project android-devrel-ci \ - --set-env-vars CLOUD_RUN_AUDIENCE=https://jetpacker-server-v2-254502043090.us-central1.run.app \ + --project YOUR_PROJECT_ID \ + --set-env-vars CLOUD_RUN_AUDIENCE=https://jetpacker-server-v2-YOUR_PROJECT_HASH.us-central1.run.app \ --no-allow-unauthenticated ``` ##### Deploying to Production (`jetpacker-server`) 1. **Build and submit the image**: ```bash - gcloud builds submit --tag gcr.io/android-devrel-ci/jetpacker-server:latest \ - --project=android-devrel-ci server/ + gcloud builds submit --tag gcr.io/YOUR_PROJECT_ID/jetpacker-server:latest \ + --project=YOUR_PROJECT_ID server/ ``` 2. **Deploy to Cloud Run**: ```bash gcloud run deploy jetpacker-server \ - --image gcr.io/android-devrel-ci/jetpacker-server:latest \ + --image gcr.io/YOUR_PROJECT_ID/jetpacker-server:latest \ --region us-central1 \ - --project android-devrel-ci \ - --set-env-vars CLOUD_RUN_AUDIENCE=https://jetpacker-server-254502043090.us-central1.run.app \ + --project YOUR_PROJECT_ID \ + --set-env-vars CLOUD_RUN_AUDIENCE=https://jetpacker-server-YOUR_PROJECT_HASH.us-central1.run.app \ --no-allow-unauthenticated ``` ##### Updating the API Gateway Config To update API Gateway routes using `server/openapi.yaml`: ```bash -gcloud api-gateway api-configs create jetset-config-v \ - --api=jetset-api \ +gcloud api-gateway api-configs create jetpacker-config-v \ + --api=jetpacker-api \ --openapi-spec=server/openapi.yaml \ - --project=android-devrel-ci \ - --backend-auth-service-account=api-gateway-invoker@android-devrel-ci.iam.gserviceaccount.com + --project=YOUR_PROJECT_ID \ + --backend-auth-service-account=api-gateway-invoker@YOUR_PROJECT_ID.iam.gserviceaccount.com -gcloud api-gateway gateways update jetset-gateway \ - --api=jetset-api \ - --api-config=jetset-config-v \ +gcloud api-gateway gateways update jetpacker-gateway \ + --api=jetpacker-api \ + --api-config=jetpacker-config-v \ --location=us-central1 \ - --project=android-devrel-ci + --project=YOUR_PROJECT_ID ``` ##### Configuring the Android App Endpoint 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 index 729ad0f1..988d22d8 100644 --- 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 @@ -33,12 +33,10 @@ 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 @@ -66,6 +64,7 @@ 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.example.jetpacker.feature.assistant.tools.TravelDaoTools import com.google.mlkit.genai.agent.Agent import com.google.mlkit.genai.agent.AgentEvent.FunctionCall import com.google.mlkit.genai.agent.AgentEvent.FunctionResponse @@ -75,8 +74,12 @@ 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 com.google.mlkit.genai.prompt.GenerativeModel +import com.google.mlkit.genai.prompt.ModelPreference +import com.google.mlkit.genai.prompt.ModelReleaseStage +import com.google.mlkit.genai.prompt.generationConfig +import com.google.mlkit.genai.prompt.modelConfig 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 @@ -85,8 +88,6 @@ 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 @@ -250,95 +251,21 @@ constructor( 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) } } @@ -346,8 +273,6 @@ constructor( /** 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 @@ -399,8 +324,6 @@ constructor( /** 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 @@ -423,8 +346,16 @@ constructor( suspend fun isSupported(): Boolean = withContext(Dispatchers.IO) { try { - val status = Generation.getClient().checkStatus() - status == FeatureStatus.AVAILABLE || status == FeatureStatus.DOWNLOADABLE + val status = previewGenerativeModel.checkStatus() + if (status == FeatureStatus.DOWNLOADABLE) { + Log.i(TAG, "Nano v4 preview is DOWNLOADABLE. Starting download...") + previewGenerativeModel.download().collect { dStatus -> + Log.d(TAG, "Download progress: $dStatus") + } + previewGenerativeModel.checkStatus() == FeatureStatus.AVAILABLE + } else { + status == FeatureStatus.AVAILABLE + } } catch (e: Exception) { Log.w(TAG, "Error checking Gemini Nano support status", e) false @@ -439,7 +370,6 @@ constructor( * 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 @@ -466,8 +396,6 @@ constructor( ) init { - activeReceiverInstance = this - travelDocumentManager.semanticSearchProvider = documentSemanticSearch viewModelScope.launch { travelDocumentManager.seedDefaultDocumentsIfEmpty() } try { val hasAudioPerm = @@ -476,14 +404,12 @@ constructor( if (hasAudioPerm) { voiceInputManager?.setPermissionGranted(true) } - if (BuildConfig.DEBUG) { - ContextCompat.registerReceiver( - application, - adbPromptReceiver, - IntentFilter(ACTION_ASSISTANT_PROMPT), - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - } + ContextCompat.registerReceiver( + application, + adbPromptReceiver, + IntentFilter(ACTION_ASSISTANT_PROMPT), + ContextCompat.RECEIVER_EXPORTED, + ) } catch (e: Exception) { Log.w(TAG, "Skipping receiver/permission init in test environment", e) } @@ -515,12 +441,39 @@ constructor( } } + private val travelDaoTools by lazy { + TravelDaoTools( + eventDao = eventDao, + tripDao = tripDao, + travelDocumentDao = travelDocumentDao, + expenseDao = expenseDao, + travelDocumentManager = travelDocumentManager, + documentSemanticSearch = documentSemanticSearch, + activeTripIdProvider = { currentTripId }, + ) + } + private var runner: AgentRunner? = null private val runnerMutex = Mutex() private var sessionId: String = UUID.randomUUID().toString() - private var activePromptJob: Job? = null + private var lastSubmittedPrompt: String? = null + private var lastSubmittedTime: Long = 0L @VisibleForTesting var testDispatcher: CoroutineDispatcher? = null + @VisibleForTesting var testGenerativeModel: GenerativeModel? = null + + val previewGenerativeModel: GenerativeModel by lazy { + testGenerativeModel + ?: run { + val previewFastConfig = generationConfig { + modelConfig = modelConfig { + releaseStage = ModelReleaseStage.PREVIEW + preference = ModelPreference.FAST + } + } + Generation.getClient(previewFastConfig) + } + } @SuppressLint("VisibleForTests") private suspend fun getOrInitRunner(): AgentRunner = runnerMutex.withLock { @@ -528,22 +481,10 @@ constructor( ?: 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 + val allTrips = tripDao.getAllTrips().first() 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 { - "" - } + "Trips: " + allTrips.joinToString(", ") { "${it.title}(id:${it.id})" } + ".\n" + } else "" } catch (e: Exception) { "" } @@ -555,29 +496,8 @@ constructor( 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 { - "" - } + "Active Trip: ${trip.title} (id: ${trip.id}, location: ${trip.location}, budget $2000 USD). Default to this trip.\n" + } else "" } catch (e: Exception) { "" } @@ -592,39 +512,15 @@ constructor( .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." + "You are Jetpacker's on-device travel assistant. Help travelers manage trips, itinerary, expenses, and documents.\n" + + "Rules:\n" + + "1. Always use tools to answer questions or perform actions.\n" + + "2. When asked to log/add an expense with an amount, call addExpense with amount, currency ('USD','EUR','JPY'), title, tripId.\n" + + "3. When asked to add an expense for an activity without a price, call getItinerary(tripId) to find eventId, call getEventDetails(eventId) for price, then call addExpense.\n" + + "4. When asked about budget/spending, call ONLY getExpenses(tripId) and answer with remaining balance in native currency. If user follows up to add it, call addExpense.\n" + + "5. When asked to check passport or travel document validity, call check_document_validity or get_travel_document.\n" + + "6. Keep responses short (1-2 sentences, under 25 words). Plain text only, no markdown asterisks or internal IDs.\n" + + "7. Do not repeat tool calls with identical arguments. Finish turn immediately after tool results." ) .setMaxTurns( 6 @@ -640,18 +536,24 @@ constructor( val dispatcher = testDispatcher val newRunner = if (dispatcher != null) { - AgentRunner(context = appContext, agent = agent, coroutineDispatcher = dispatcher) + AgentRunner( + context = appContext, + agent = agent, + generativeModel = previewGenerativeModel, + coroutineDispatcher = dispatcher, + ) } else { - AgentRunner(context = appContext, agent = agent) + AgentRunner( + context = appContext, + agent = agent, + generativeModel = previewGenerativeModel, + ) } 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() @@ -660,18 +562,14 @@ constructor( val now = System.currentTimeMillis() if ( trimmedPrompt.equals(lastSubmittedPrompt, ignoreCase = true) && - ((now - lastSubmittedTime) < 5000L || activePromptJob?.isActive == true) + (now - lastSubmittedTime) < 2500L ) { - Log.w(TAG, "Ignoring duplicate prompt within 5000ms window or active job: '$trimmedPrompt'") + Log.w(TAG, "Ignoring duplicate prompt within 2500ms window: '$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...") @@ -716,7 +614,7 @@ constructor( ) } - activePromptJob = viewModelScope.launch { + viewModelScope.launch { val currentToolExecutions = mutableListOf() val userActionSteps = mutableListOf() var activeHighlightedEntity: HighlightedEntity? = null @@ -724,6 +622,18 @@ constructor( var lastEventHighlight: ItineraryEventHighlight? = null val accumulatedText = StringBuilder() try { + try { + val status = previewGenerativeModel.checkStatus() + if (status == FeatureStatus.DOWNLOADABLE) { + Log.i(TAG, "Nano v4 preview is DOWNLOADABLE. Triggering download before runner...") + previewGenerativeModel.download().collect { dStatus -> + Log.d(TAG, "Download progress: $dStatus") + } + } + } catch (e: Exception) { + Log.w(TAG, "Download check exception", e) + } + val activeRunner = getOrInitRunner() val session = activeRunner.getOrCreateSession(sessionId) @@ -731,10 +641,11 @@ constructor( when (event) { is FunctionCall -> { Log.i(TAG, "Agent calling tool '${event.name}' with args: ${event.args}") - val cleanName = cleanToolName(event.name) + val cleanName = + event.name.removePrefix("BaseJetPackerAppFunctionService_").substringAfterLast('.') if (cleanName == "addExpense" && !readOnlyBudgetQuery) { _isProcessingExpense.value = true - } else if (cleanName == "addVoiceNote" || cleanName == "addItineraryEvent" || cleanName == "saveTravelNoteDocument") { + } else if (cleanName == "addVoiceNote" || cleanName == "addItineraryEvent") { _isProcessingVoiceNote.value = true } @@ -742,27 +653,28 @@ constructor( if (cleanName == "addExpense" && readOnlyBudgetQuery) "getExpenses" else cleanName val isDuplicate = currentToolExecutions.any { existing -> - val existingClean = cleanToolName(existing.name) + val existingClean = + existing.name + .removePrefix("BaseJetPackerAppFunctionService_") + .substringAfterLast('.') 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 isMutationPresent = currentToolExecutions.any { + val clean = + it.name.removePrefix("BaseJetPackerAppFunctionService_").substringAfterLast('.') + clean in setOf("addExpense", "addVoiceNote", "addItineraryEvent") } - val isRedundantPostMutationRead = - hasPriorMutation && - effectiveStepToolName in setOf("getExpenses", "getActiveTrip", "searchTrip") - val isRedundantDuplicateMutation = - hasPriorMutation && - cleanName in setOf("addExpense", "addVoiceNote", "addItineraryEvent", "saveTravelNoteDocument") - - if (!isDuplicate && !isRedundantPostMutationRead && !isRedundantDuplicateMutation) { + val shouldSkipActionStep = + cleanName == "getExpenses" && isMutationPresent && !readOnlyBudgetQuery + if (!isDuplicate) { val record = ToolExecutionRecord(name = event.name, args = event.args) currentToolExecutions.add(record) - userActionSteps.add( - toInProgressUserStep(record.id, effectiveStepToolName, event.args) - ) + if (!shouldSkipActionStep) { + userActionSteps.add( + toInProgressUserStep(record.id, effectiveStepToolName, event.args) + ) + } } _uiState.value = ExecutingTool( @@ -770,19 +682,13 @@ constructor( 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.Conversational( + turns = _conversationTurns.value, + activeSteps = userActionSteps.toList(), + ) + } else { _overlayState.value = AssistantOverlayState.PerformingActions( userPrompt = trimmedPrompt, @@ -792,10 +698,11 @@ constructor( } is FunctionResponse -> { Log.i(TAG, "Tool '${event.name}' returned: ${event.response}") - val cleanToolName = cleanToolName(event.name) + val cleanToolName = + event.name.removePrefix("BaseJetPackerAppFunctionService_").substringAfterLast('.') if (cleanToolName == "addExpense") { _isProcessingExpense.value = false - } else if (cleanToolName == "addVoiceNote" || cleanToolName == "addItineraryEvent" || cleanToolName == "saveTravelNoteDocument") { + } else if (cleanToolName == "addVoiceNote" || cleanToolName == "addItineraryEvent") { _isProcessingVoiceNote.value = false } @@ -807,8 +714,9 @@ constructor( if (index != -1) { val updatedRecord = currentToolExecutions[index].copy(response = event.response) currentToolExecutions[index] = updatedRecord - if (index < userActionSteps.size) { - userActionSteps[index] = + val stepIndex = userActionSteps.indexOfFirst { it.id == updatedRecord.id } + if (stepIndex != -1) { + userActionSteps[stepIndex] = toCompletedUserStep( id = updatedRecord.id, cleanToolName = effectiveCompletedToolName, @@ -823,49 +731,49 @@ constructor( args = event.response, executedTools = currentToolExecutions.toList(), ) + if (isAlreadyConversational) { + _overlayState.value = + AssistantOverlayState.Conversational( + turns = _conversationTurns.value, + activeSteps = userActionSteps.toList(), + ) + } else { + _overlayState.value = + AssistantOverlayState.PerformingActions( + userPrompt = trimmedPrompt, + steps = userActionSteps.toList(), + ) + } // Extract structured entities for UI highlighting (unwrapping AppFunctionTool - // "result" envelope if present) BEFORE updating overlay state + // "result" envelope if present) 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, - ) - } + val status = payload["status"]?.toString() ?: "UNKNOWN" + val explanation = payload["explanation"]?.toString() ?: "" + val docType = payload["documentType"]?.toString() ?: "passport" + val expiryLong = + (payload["expiryDateLong"] as? Number)?.toLong() + ?: (payload["expiryDate"] as? Number)?.toLong() + ?: 0L + val docNum = payload["documentNumberMasked"]?.toString() ?: "" + val country = payload["issuingCountry"]?.toString() ?: "" + val docId = payload["documentId"]?.toString() ?: "" + val doc = + TravelDocument( + documentId = docId, + documentType = docType, + issuingCountry = country, + expiryDate = expiryLong, + documentNumberMasked = docNum, + ) + activeHighlightedEntity = + TravelDocumentHighlight( + document = doc, + validityStatus = status, + validityExplanation = explanation, + ) } "get_flight_itinerary" -> { val flights = @@ -955,46 +863,6 @@ constructor( } } } - - 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) { @@ -1047,16 +915,7 @@ constructor( 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) + _overlayState.value = AssistantOverlayState.Collapsed } } } @@ -1071,132 +930,51 @@ constructor( 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()) { + val finalAnswer = + if (cleanedAnswer.isNotBlank()) { cleanedAnswer } else { generateFallbackResponse(currentToolExecutions) } + val lastCleanTool = + currentToolExecutions + .lastOrNull() + ?.name + ?.removePrefix("BaseJetPackerAppFunctionService_") + ?.substringAfterLast('.') + val isReadOnlyCheck = isReadOnlyBudgetCheck(trimmedPrompt) val hasMutationTool = currentToolExecutions.any { record -> - val clean = cleanToolName(record.name) + val clean = + record.name.removePrefix("BaseJetPackerAppFunctionService_").substringAfterLast('.') 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)) && + currentToolExecutions.isNotEmpty() && + (lastCleanTool in setOf("addExpense", "addVoiceNote", "addItineraryEvent") || + (hasMutationTool && lastCleanTool == "getExpenses")) && !trimmedPrompt.contains("?") val completedTurn = ConversationTurnUi( userPrompt = trimmedPrompt, - actionSteps = effectiveActionSteps, + actionSteps = userActionSteps.toList(), assistantMessage = finalAnswer, - highlightedEntity = if (isReadOnlyCheck) null else effectiveHighlightedEntity, + highlightedEntity = if (isReadOnlyCheck) null else activeHighlightedEntity, 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, + steps = userActionSteps.toList(), + summaryCard = activeHighlightedEntity, ) val effectiveTripId = currentTripId ?: ActiveTripSession.activeTripId.orEmpty() saveConversationToHistory(effectiveTripId, listOf(completedTurn)) @@ -1213,21 +991,17 @@ constructor( _uiState.value = Success( answerText = finalAnswer, - highlightedEntity = if (isReadOnlyCheck) null else effectiveHighlightedEntity, + highlightedEntity = if (isReadOnlyCheck) null else activeHighlightedEntity, toolExecutions = currentToolExecutions.toList(), latencyMs = elapsedMs, - userActionSteps = effectiveActionSteps, + userActionSteps = userActionSteps.toList(), 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 @@ -1291,11 +1065,9 @@ constructor( override fun onCleared() { super.onCleared() - if (BuildConfig.DEBUG) { - try { - getApplication().unregisterReceiver(adbPromptReceiver) - } catch (_: Exception) {} - } + try { + getApplication().unregisterReceiver(adbPromptReceiver) + } catch (_: Exception) {} runner?.close() runner = null } @@ -1351,8 +1123,6 @@ constructor( 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" @@ -1490,21 +1260,12 @@ constructor( "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…", + title = "Checking documents…", ) else -> UserActionStep( @@ -1608,30 +1369,6 @@ constructor( 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", @@ -1692,31 +1429,26 @@ constructor( 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 + val messages = tools.mapNotNull { record -> + val payload = (record.response?.get("result") as? Map<*, *>) ?: record.response + payload?.get("message")?.toString() + ?: payload?.get("explanation")?.toString() ?: run { - val cleanName = - mutationRecord?.let { cleanToolName(it.name) } ?: "request" + val cleanName = record.name.substringAfterLast('.').substringAfterLast('_') "Completed $cleanName successfully." } + } + val fallback = + if (messages.isNotEmpty()) { + messages.joinToString("\n") + } else { + "Completed your request 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/tools/TravelDaoTools.kt b/jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt index 18960e22..03d9b7b2 100644 --- 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 @@ -16,7 +16,6 @@ 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 @@ -39,26 +38,36 @@ 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 kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +/** Base class adapting local Room DAO tools. */ +abstract class TravelDaoAgentTool(val name: String, val description: String) { + open val parametersSchema: FunctionSchema = FunctionSchema(type = FunctionType.OBJECT) + + abstract suspend fun execute(args: Map): Map +} + /** 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 = +) : + TravelDaoAgentTool( + name = "get_flight_itinerary", + description = + "Retrieves flight itinerary details (flight number, airline, airports, times, terminal, gate) for a trip.", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -76,7 +85,7 @@ class GetFlightItineraryTool( ), ) - suspend fun execute(args: Map): Map { + override suspend fun execute(args: Map): Map { val tripId = args["tripId"]?.toString()?.takeIf { it.isNotBlank() } ?: activeTripIdProvider() val targetFlightNumber = args["flightNumber"]?.toString() @@ -140,10 +149,13 @@ class GetFlightItineraryTool( 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 = +) : + TravelDaoAgentTool( + name = "get_travel_document", + description = + "Retrieves travel documents, hotel info, Wi-Fi details, tickets, or notes.", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -151,24 +163,22 @@ class GetTravelDocumentTool( "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'.", + description = "Type: 'PASSPORT', 'VISA', 'HOTEL', 'WIFI', 'TICKET'.", ), "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').", + description = "Search query (e.g. 'wifi', 'hotel', 'passport').", ), "documentId" to FunctionSchema( type = FunctionType.STRING, - description = "Optional unique identifier of a specific document.", + description = "Optional document ID.", ), "tripId" to FunctionSchema( type = FunctionType.STRING, - description = "Optional trip ID to filter documents for a specific trip.", + description = "Optional trip ID.", ), "eventId" to FunctionSchema( @@ -179,7 +189,7 @@ class GetTravelDocumentTool( ), ) - suspend fun execute(args: Map): Map { + override suspend fun execute(args: Map): Map { val documentId = args["documentId"]?.toString() val documentType = args["documentType"]?.toString() val query = args["query"]?.toString() @@ -242,10 +252,13 @@ class GetTravelDocumentTool( 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 = +) : + TravelDaoAgentTool( + name = "check_document_validity", + 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.", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -268,7 +281,7 @@ class CheckDocumentValidityTool( ), ) - suspend fun execute(args: Map): Map { + override 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() @@ -396,10 +409,13 @@ 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 = +) : + TravelDaoAgentTool( + name = "read_document_content", + 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.", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -423,7 +439,7 @@ class ReadDocumentContentTool( ), ) - suspend fun execute(args: Map): Map { + override suspend fun execute(args: Map): Map { val docId = args["documentId"]?.toString() val query = args["query"]?.toString() val tripId = args["tripId"]?.toString() @@ -459,16 +475,15 @@ class ReadDocumentContentTool( 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 isVisual = travelDocumentManager.isVisualDocument(targetDoc) val extractedText = - if (isImage && targetDoc.notes.isBlank()) { - val extracted = extractTextFromImage(targetDoc.filePath) + if (isVisual && targetDoc.notes.isBlank()) { + val extracted = extractTextFromVisualDocument(targetDoc) if (extracted != null) { try { - travelDocumentDao.insertDocument(targetDoc.copy(notes = extracted)) + val updated = targetDoc.copy(notes = extracted) + travelDocumentDao.insertDocument(updated) + documentSemanticSearch?.indexDocument(updated) } catch (e: Exception) { Log.w("ReadDocContentTool", "Could not cache extracted text in document", e) } @@ -481,7 +496,7 @@ class ReadDocumentContentTool( val content = travelDocumentManager.readDocumentContent(targetDoc) val finalContent = if (!extractedText.isNullOrBlank()) { - "$content\n\nExtracted Image Text:\n$extractedText" + "$content\n\nExtracted Visual Text:\n$extractedText" } else { content } @@ -495,30 +510,34 @@ class ReadDocumentContentTool( ) } - private suspend fun extractTextFromImage(filePath: String): String? = + private suspend fun extractTextFromVisualDocument(doc: TravelDocument): 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 bitmap = travelDocumentManager.loadDocumentBitmap(doc) ?: 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." + "You are an on-device document extractor. Extract all key text, names, dates (expiration, departure, arrival), document numbers, country, and travel details from this document. 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) + Log.w("ReadDocContentTool", "Could not extract text from visual document", 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 = +class AddTravelDocumentTool( + private val travelDocumentManager: TravelDocumentManager, + private val documentSemanticSearch: DocumentSemanticSearch? = null, +) : + TravelDaoAgentTool( + name = "add_travel_document", + description = + "Saves a new travel document, passport details, visa waiver, or travel voucher into the user's travel documents store.", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -576,7 +595,7 @@ class AddTravelDocumentTool(private val travelDocumentManager: TravelDocumentMan required = listOf("title"), ) - suspend fun execute(args: Map): Map { + override 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() ?: "" @@ -611,6 +630,7 @@ class AddTravelDocumentTool(private val travelDocumentManager: TravelDocumentMan tripId = tripId, eventId = eventId, ) + runCatching { documentSemanticSearch?.indexDocument(savedDoc) } return mapOf( "status" to "SUCCESS", @@ -626,10 +646,13 @@ 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 = +) : + TravelDaoAgentTool( + name = "addExpense", + 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.", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -637,37 +660,33 @@ class AddExpenseTool( "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.", + description = "Trip ID (e.g. '2026-1').", ), "title" to FunctionSchema( type = FunctionType.STRING, - description = - "The title or description of the expense (e.g. 'Airport Express Train', 'Lunch').", + description = "Expense title (e.g. 'Airport Uber', 'Museum Ticket').", ), "amount" to FunctionSchema( type = FunctionType.NUMBER, - description = "The numeric amount of the expense (e.g. 45.0).", + description = "Expense numeric amount (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').", + description = "Currency code: 'USD', 'EUR', 'JPY', 'GBP'.", ), "category" to FunctionSchema( type = FunctionType.STRING, - description = - "Expense category: 'Transport', 'Food', 'Accommodation', 'Activity', or 'Other'.", + description = "Category: 'Transport', 'Food', 'Accommodation', 'Activity'.", ), ), required = listOf("title", "amount", "currency"), ) - suspend fun execute(args: Map): Map { + override suspend fun execute(args: Map): Map { val title = args["title"]?.toString() ?: "Expense" val amount = when (val amt = args["amount"]) { @@ -745,7 +764,7 @@ class AddExpenseTool( "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.", + "Expense '$title' of $amount $currency is already logged in ${trip?.title ?: "trip"}. Task complete — do not call addExpense, getExpenses, or any other tool.", ) } @@ -770,7 +789,7 @@ class AddExpenseTool( "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.", + "Added expense '$title' of $amount $currency to ${trip?.title ?: "trip"}. Task complete — do not call addExpense, getExpenses, or any other tool.", ) } @@ -857,10 +876,13 @@ 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 = +) : + TravelDaoAgentTool( + name = "addItineraryEvent", + 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').", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -902,7 +924,7 @@ class AddItineraryEventTool( required = listOf("title"), ) - suspend fun execute(args: Map): Map { + override suspend fun execute(args: Map): Map { val title = args["title"]?.toString() ?: "Reminder" val typeStr = args["type"]?.toString() ?: "ACTIVITY" val eventType = @@ -1052,10 +1074,13 @@ 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 = +) : + TravelDaoAgentTool( + name = "addVoiceNote", + 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').", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -1086,7 +1111,7 @@ class AddVoiceNoteTool( required = listOf("title", "transcription"), ) - suspend fun execute(args: Map): Map { + override 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() } @@ -1202,10 +1227,13 @@ class AddVoiceNoteTool( } /** 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 = +class SearchTripTool(private val tripDao: TripDao) : + TravelDaoAgentTool( + name = "searchTrip", + 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.", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -1229,7 +1257,7 @@ class SearchTripTool(private val tripDao: TripDao) { ), ) - suspend fun execute(args: Map): Map { + override suspend fun execute(args: Map): Map { val query = args["query"]?.toString()?.trim() val id = args["id"]?.toString()?.trim() val title = args["title"]?.toString()?.trim() @@ -1274,10 +1302,12 @@ 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 = +) : + TravelDaoAgentTool( + name = "getExpenses", + description = "Retrieves all logged expenses for a specific trip.", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -1290,40 +1320,34 @@ class GetExpensesTool( ), ) - 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 { + override 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 + expenses.sumOf { + val rate = + when (it.currency.uppercase()) { + "USD" -> 1.0 + "EUR" -> 1.08 + "GBP" -> 1.27 + "JPY" -> 0.0067 + else -> 1.0 + } + it.amount * rate + } * 100.0 ) / 100.0 - val remainingBudgetUsd = kotlin.math.round((budgetUsd - totalSpentUsd) * 100.0) / 100.0 + val remainingBudgetUsd = kotlin.math.round((2000.0 - 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, + "totalBudgetUsd" to 2000.0, "remainingBudgetUsd" to remainingBudgetUsd, "remainingBudgetJpy" to remainingBudgetJpy, "expenses" to @@ -1337,7 +1361,7 @@ class GetExpensesTool( ) }, "message" to - "Found ${expenses.size} expenses totaling $$totalSpentUsd USD out of $$budgetUsd budget ($$remainingBudgetUsd USD / ¥$remainingBudgetJpy JPY remaining).", + "Found ${expenses.size} expenses totaling $$totalSpentUsd USD out of $2,000.00 budget ($$remainingBudgetUsd USD / ¥$remainingBudgetJpy JPY remaining).", ) } } @@ -1347,10 +1371,13 @@ 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 = +) : + TravelDaoAgentTool( + name = "getItinerary", + description = + "Retrieves the list of itinerary events (eventId, title, type, location) for a trip.", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -1363,7 +1390,7 @@ class GetItineraryTool( ), ) - suspend fun execute(args: Map): Map { + override suspend fun execute(args: Map): Map { val tripId = args["tripId"]?.toString()?.takeIf { it.isNotBlank() } ?: activeTripIdProvider()?.takeIf { it.isNotBlank() } @@ -1386,12 +1413,10 @@ class GetItineraryTool( "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).", @@ -1406,10 +1431,13 @@ class GetItineraryTool( 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 = +) : + TravelDaoAgentTool( + name = "getEventDetails", + description = + "Retrieves details for an itinerary event by eventId (ticket price, hotel rate, baggage allowance, visitor tips).", + ) { + override val parametersSchema = FunctionSchema( type = FunctionType.OBJECT, properties = @@ -1424,7 +1452,7 @@ class GetEventDetailsTool( required = listOf("eventId"), ) - suspend fun execute(args: Map): Map { + override 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.") @@ -1474,57 +1502,46 @@ class GetEventDetailsTool( 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()) { + result["admissionPrice"] = museum.admissionPrice summaryParts.add("Admission ticket: ${museum.admissionPrice}") } if (museum.openingHours.isNotBlank()) { + result["openingHours"] = museum.openingHours 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}") + val rate = hotel.pricePerNight + if (!rate.isNullOrBlank()) { + result["pricePerNight"] = rate + summaryParts.add("Rate: $rate") } - if (!hotel.confNumber.isNullOrBlank()) { - summaryParts.add("Confirmation: ${hotel.confNumber}") + val conf = hotel.confNumber + if (!conf.isNullOrBlank()) { + result["confirmationNumber"] = conf } } 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}") + val baggage = flight.baggageAllowance + if (!baggage.isNullOrBlank()) { + result["baggageAllowance"] = baggage + summaryParts.add("Baggage: $baggage") } } if (dining != null) { result["restaurantName"] = dining.restaurantName result["partySize"] = dining.partySize - result["priceRange"] = dining.priceRange ?: "" - result["address"] = dining.address + val range = dining.priceRange + if (!range.isNullOrBlank()) { + result["priceRange"] = range + } } 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("; ")}") } @@ -1537,7 +1554,6 @@ class GetEventDetailsTool( "$title ($location)" } result["summary"] = summary - result["message"] = summary return result } } @@ -1574,7 +1590,8 @@ class TravelDaoTools( travelDocumentManager?.let { ReadDocumentContentTool(it, travelDocumentDao, documentSemanticSearch) }, - addTravelDocument = travelDocumentManager?.let { AddTravelDocumentTool(it) }, + addTravelDocument = + travelDocumentManager?.let { AddTravelDocumentTool(it, documentSemanticSearch) }, addExpense = expenseDao?.let { AddExpenseTool(it, tripDao, activeTripIdProvider) }, addItineraryEvent = AddItineraryEventTool(eventDao, tripDao, activeTripIdProvider), addVoiceNote = AddVoiceNoteTool(eventDao, tripDao, activeTripIdProvider), @@ -1584,12 +1601,10 @@ class TravelDaoTools( getEventDetails = GetEventDetailsTool(eventDao, tourDetailDao), ) - fun asList(): List = buildList { + 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) } diff --git a/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModelTest.kt b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModelTest.kt new file mode 100644 index 00000000..106086bb --- /dev/null +++ b/jetpacker/android/feature/assistant/src/test/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModelTest.kt @@ -0,0 +1,384 @@ +/* + * 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.app.Application +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.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.TravelDocumentManager +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.documents.DocumentSemanticSearch +import com.example.jetpacker.feature.assistant.model.TravelAgentUiState.Idle +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.prompt.GenerativeModel +import java.lang.reflect.Proxy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class TravelAssistantViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var fakeEventDao: TestEventDao + private lateinit var fakeTripDao: TestTripDao + private lateinit var fakeDocumentDao: TestTravelDocumentDao + private lateinit var fakeExpenseDao: TestExpenseDao + private lateinit var viewModel: TravelAssistantViewModel + private lateinit var application: Application + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + application = ApplicationProvider.getApplicationContext() + fakeEventDao = TestEventDao() + fakeTripDao = TestTripDao() + fakeDocumentDao = TestTravelDocumentDao() + fakeExpenseDao = TestExpenseDao() + + val travelDocumentManager = TravelDocumentManager(application, fakeDocumentDao) + viewModel = + TravelAssistantViewModel( + application = application, + eventDao = fakeEventDao, + tripDao = fakeTripDao, + travelDocumentDao = fakeDocumentDao, + travelDocumentManager = travelDocumentManager, + expenseDao = fakeExpenseDao, + documentSemanticSearch = DocumentSemanticSearch(fakeDocumentDao, travelDocumentManager), + ) + .apply { testDispatcher = this@TravelAssistantViewModelTest.testDispatcher } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testSanitizeAssistantText_stripsControlTokensAndCodeBlocks() { + val raw = + "```tool_code\ncall:get_flight_itinerary{}\n```\nHere is your **flight** information (tripId: 2026-4)." + val cleaned = sanitizeAssistantText(raw) + assertThat(cleaned).isEqualTo("Here is your flight information.") + } + + @Test + fun testInitialState_isIdle() { + assertThat(viewModel.uiState.value).isEqualTo(Idle) + assertThat(viewModel.samplePrompts).isNotEmpty() + } + + @Test + fun testClearChat_resetsStateToIdle() = runTest { + viewModel.sendPrompt("Show my flight") + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.clearChat() + testDispatcher.scheduler.advanceUntilIdle() + + assertThat(viewModel.uiState.value).isEqualTo(Idle) + } + + @Test + fun testSamplePrompts_containsPrompts() { + assertThat(viewModel.samplePrompts).isNotEmpty() + assertThat(viewModel.samplePrompts.first()).contains("expenses") + } + + @Test + fun testDynamicTripDaoQuery_reflectsInsertedTrips() = runTest { + val trip = + Trip( + id = "trip-tokyo-2026", + title = "Tokyo Explorer", + location = "Tokyo, Japan", + startDate = 1791590400000L, + endDate = 1792108800000L, + ) + fakeTripDao.insertTrip(trip) + + val allTrips = fakeTripDao.getAllTrips().first() + assertThat(allTrips).contains(trip) + } + + @Test + fun testIsSupported_returnsTrueWhenAvailable() = runTest { + val fakeGenerativeModel = + Proxy.newProxyInstance( + GenerativeModel::class.java.classLoader, + arrayOf(GenerativeModel::class.java), + ) { _, method, _ -> + when (method.name) { + "checkStatus" -> FeatureStatus.AVAILABLE + else -> null + } + } as GenerativeModel + + viewModel.testGenerativeModel = fakeGenerativeModel + val supported = viewModel.isSupported() + assertThat(supported).isTrue() + } + + @Test + fun testIsSupported_downloadsWhenDownloadable() = runTest { + var checkCount = 0 + val fakeGenerativeModel = + Proxy.newProxyInstance( + GenerativeModel::class.java.classLoader, + arrayOf(GenerativeModel::class.java), + ) { _, method, _ -> + when (method.name) { + "checkStatus" -> { + checkCount++ + if (checkCount == 1) FeatureStatus.DOWNLOADABLE else FeatureStatus.AVAILABLE + } + "download" -> flowOf(DownloadStatus.DownloadCompleted) + else -> null + } + } as GenerativeModel + + viewModel.testGenerativeModel = fakeGenerativeModel + val supported = viewModel.isSupported() + assertThat(supported).isTrue() + assertThat(checkCount).isAtLeast(2) + } + + @Test + fun testIsSupported_returnsFalseWhenStatusThrows() = runTest { + val fakeGenerativeModel = + Proxy.newProxyInstance( + GenerativeModel::class.java.classLoader, + arrayOf(GenerativeModel::class.java), + ) { _, method, _ -> + if (method.name == "checkStatus") { + throw RuntimeException("Model check failed") + } + null + } as GenerativeModel + + viewModel.testGenerativeModel = fakeGenerativeModel + val supported = viewModel.isSupported() + assertThat(supported).isFalse() + } + + @Test + fun testSetTripId_updatesCurrentTripId() = runTest { + assertThat(viewModel.currentTripId).isNull() + + viewModel.setTripId("trip-tokyo-2026") + assertThat(viewModel.currentTripId).isEqualTo("trip-tokyo-2026") + + viewModel.setTripId(null) + assertThat(viewModel.currentTripId).isNull() + } +} + +private class TestTripDao : 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 TestTravelDocumentDao : 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 + } + + 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.documentId == id } + } + + override suspend fun deleteAllDocuments() { + documents.clear() + } +} + +private class TestEventDao : EventDao { + val events = mutableListOf() + val voiceNotes = mutableListOf() + + override fun getAllSessionIds(): Flow> = flowOf(emptyList()) + + override fun getEventsForTrip(tripId: String): Flow> = + flowOf(events.filter { it.tripId == tripId }) + + override suspend fun insertEvent(event: TimelineEvent) { + events.removeAll { it.id == event.id } + events.add(event) + } + + override suspend fun insertEvents(events: List) { + events.forEach { insertEvent(it) } + } + + override suspend fun deleteEventsForTrip(tripId: String) { + events.removeAll { it.tripId == tripId } + } + + override suspend fun deleteEvent(event: TimelineEvent) { + events.remove(event) + } + + 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(events.find { it.id == eventId }) + + override suspend fun deleteAllEvents() { + events.clear() + } + + override suspend fun deleteAllFlightDetails() {} + + override suspend fun deleteAllHotelDetails() {} + + override suspend fun deleteAllDiningDetails() {} + + override suspend fun deleteAllActivityDetails() {} + + 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 TestExpenseDao : 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/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 index 8b011cc6..150c3dcd 100644 --- 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 @@ -430,7 +430,7 @@ private class FakeTravelDocumentDao : TravelDocumentDao { flowOf(documents.filter { it.eventId == eventId }) override suspend fun getDocumentById(documentId: String): TravelDocument? = documents.find { - it.documentId == documentId || it.id == documentId + it.documentId == documentId } override suspend fun insertDocument(document: TravelDocument) { diff --git a/jetpacker/server/agents/orchestrator.py b/jetpacker/server/agents/orchestrator.py index 4945074d..bd937e84 100644 --- a/jetpacker/server/agents/orchestrator.py +++ b/jetpacker/server/agents/orchestrator.py @@ -66,7 +66,7 @@ async def _generate_conversational_reply( try: client = _get_genai_client() prompt = ( - "You are a warm, helpful travel concierge in the JetSet app.\n" + "You are a warm, helpful travel concierge in the JetPacker app.\n" f"Trip: {trip_title}{loc_suffix}\n" f"Trip itinerary items: {json.dumps(itinerary)}\n" f"User message: {json.dumps(user_prompt)}\n" From 6eb5b9f826ee940fe55f8c0518851986c12092c0 Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Tue, 3 Nov 2026 11:30:35 +0000 Subject: [PATCH 2/3] docs: add ARCHITECTURE.md, agent guidelines, and jetpacker workspace skill --- .agents/AGENTS.md | 83 ++++++++ .agents/skills/jetpacker/SKILL.md | 126 +++++++++++ ARCHITECTURE.md | 224 ++++++++++++++++++++ jetpacker/.agents/AGENTS.md | 85 +++++++- jetpacker/.agents/skills/jetpacker/SKILL.md | 126 +++++++++++ 5 files changed, 640 insertions(+), 4 deletions(-) create mode 100644 .agents/AGENTS.md create mode 100644 .agents/skills/jetpacker/SKILL.md create mode 100644 ARCHITECTURE.md create mode 100644 jetpacker/.agents/skills/jetpacker/SKILL.md diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 00000000..d0d3bc69 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,83 @@ +# Agent & Developer Guidelines: JetPacker + +This document outlines key technical constraints, coding standards, and testing procedures for AI agents and human developers working in the JetPacker repository. + +--- + +## 1. Code Style & Syntax Invariants + +### 1.1 Avoid Fully Qualified Class Names (FQCN) +When writing or modifying Kotlin, Java, or Compose files: +- **Always Use Top-Level Imports**: Never write inline Fully Qualified Class Names (e.g., `com.example.jetpacker.core.flags.FeatureFlags`, `java.time.Instant`, `androidx.compose.ui.Modifier`) in function bodies, parameter lists, or property definitions. +- **Explicit File-Level Imports**: Add explicit `import` statements at the top of the file for all referenced types, classes, and utilities. + +### 1.2 Jetpack Compose Conventions +- **Modifier Placement**: Every composable function (except top-level screen containers) must accept `modifier: Modifier = Modifier` as the first optional parameter. +- **Unidirectional Data Flow (UDF)**: ViewModels expose a single immutable `uiState: StateFlow`. Composables accept states and emit lambda event callbacks (e.g., `onTripSelected: (String) -> Unit`). +- **No Direct Context References in ViewModels**: ViewModels should remain agnostic of Android UI lifecycles. Never pass `Activity` or `Context` into ViewModel methods. If `Application` is required, inject `Application` directly via constructor. + +### 1.3 Hilt Dependency Injection Rules +- **No `@ApplicationContext` on `Application`**: In Hilt, `Application` is provided directly by default. Annotating constructor parameter `application: Application` with `@ApplicationContext` will cause a Dagger compiler failure (`[Dagger/MissingBinding]`). Only use `@ApplicationContext` when the parameter type is `Context`. + +--- + +## 2. Room Database & Persistence Rules + +- **Database Class**: `JetPackerDatabase` in `:data:db` (current version: 4). +- **Entities & DAOs**: + - `Trip` & `TripDao` in `:data:trips`. + - `TimelineEvent`, `Expense`, `TravelDocument`, `DayTheme`, `VoiceNoteEntity`, `AssistantConversation` in `:data:itinerary`. +- **Interface Parity**: When updating a DAO interface, immediately update corresponding test fakes (`FakeTripDao`, `TestTripDao`, `FakeEventDao`). Never add methods to test fakes that do not exist on the real DAO interface. +- **Schema Updates**: Any entity modification requires bumping the `version` number in `JetPackerDatabase.kt` and providing an appropriate `Migration` or fallback policy. + +--- + +## 3. AI & On-Device Agent Constraints + +### 3.1 ML Kit GenAI Agent Integration +- JetPacker uses the public Android ML Kit GenAI Agent library (`com.google.mlkit:genai-agent:1.0.0-alpha1`). +- Always use standard public Android AI dependencies. +- Use `AgentRunner(context, agent, generativeModel = previewGenerativeModel)` with `agentBuilder.enableAppFunctions(true)`. + +### 3.2 Gemini Nano v4 Preview +- Configured using `ModelReleaseStage.PREVIEW` and `ModelPreference.FAST`. +- Always check on-device availability via `previewGenerativeModel.checkStatus()` and handle download / unsupported states before invoking inference. + +### 3.3 Token Pruning for On-Device Tools +- On-device models have strict context window budgets. When defining tool descriptions or schemas in `TravelDaoTools.kt`, keep descriptions compact and concise to prevent exhausting token limits. + +--- + +## 4. Build & Test Recipes + +### 4.1 Running Tests +```bash +# Run all unit tests across all modules +cd jetpacker/android && ./gradlew testDebugUnitTest + +# Run tests for a specific module +./gradlew :feature:assistant:testDebugUnitTest +./gradlew :data:trips:testDebugUnitTest +./gradlew :feature:trip:booking_assistant:testDebugUnitTest + +# Assemble Debug APK +./gradlew :app:assembleDebug +``` + +### 4.2 Handling Incremental Build Quirks +If KSP or Kotlin compiler fails with unexpected unresolved symbols after switching branches or modifying interfaces: +```bash +./gradlew clean :compileDebugKotlin +``` + +--- + +## 5. Python Booking Assistant Server (`jetpacker/server`) + +The booking assistant backend coordinates agents for flights, hotels, and activities: +```bash +cd jetpacker/server +pip install -r requirements.txt +uvicorn main:app --host 0.0.0.0 --port 8000 +``` +- **Staging vs Production**: Configurable in `BookingAssistantViewModel.kt` (`PARALLEL_STAGING` connects to Cloud Run `/v2`, `LOCAL` connects to `localhost:8000`). diff --git a/.agents/skills/jetpacker/SKILL.md b/.agents/skills/jetpacker/SKILL.md new file mode 100644 index 00000000..7070a5ba --- /dev/null +++ b/.agents/skills/jetpacker/SKILL.md @@ -0,0 +1,126 @@ +--- +name: jetpacker +description: >- + Comprehensive guide for developing, building, testing, architecting, and debugging + the JetPacker Android travel companion application and its Python booking backend. + Use when compiling or running unit tests with Gradle, inspecting or updating Room database + schemas, editing ViewModels and Compose screens, configuring on-device ML Kit / Gemini Nano, + working with Jetpack A2UI booking flows, or adding AppFunctions. +--- + +# JetPacker Development & Architecture Skill + +This skill provides step-by-step procedures, architectural overviews, and build/test recipes for working with JetPacker. + +--- + +## 1. Quick Navigation & Module Layout + +JetPacker is organized into an Android multi-module project under `jetpacker/android/` and a Python backend under `jetpacker/server/`. + +- **Application Shell**: + - `jetpacker/android/app` (`:app`): Top-level navigation host, Hilt graph, theme. +- **Feature Modules**: + - `jetpacker/android/feature/home` (`:feature:home`): Upcoming trips list, quick actions. + - `jetpacker/android/feature/create_trip` (`:feature:create_trip`): Create/edit trip destination and date picker. + - `jetpacker/android/feature/trip` (`:feature:trip`): Trip dashboard and bottom bar container. + - `jetpacker/android/feature/trip/itinerary` (`:feature:trip:itinerary`): Daily timeline events & summaries. + - `jetpacker/android/feature/trip/expenses` (`:feature:trip:expenses`): Budgeting, expense logging, receipt OCR. + - `jetpacker/android/feature/trip/voice_notes` (`:feature:trip:voice_notes`): On-device speech recognition audio recorder. + - `jetpacker/android/feature/trip/booking_assistant` (`:feature:trip:booking_assistant`): Jetpack A2UI dynamic card streaming UI. + - `jetpacker/android/feature/assistant` (`:feature:assistant`): On-device Gemini Nano v4 Travel Assistant, Document Vault, and Chat History. + - `jetpacker/android/feature/detail` (`:feature:detail`): Detail screens (Museum Assistant, AI Reviews, Hotel Support Chat). + - `jetpacker/android/feature/appfunctions` (`:feature:appfunctions`): Android system AppFunctions service. +- **Data & Core Modules**: + - `jetpacker/android/data/db` (`:data:db`): Room database v4 (`JetPackerDatabase`), DAOs, Hilt module. + - `jetpacker/android/data/trips` (`:data:trips`): Trip repository, entities, and Flow streams. + - `jetpacker/android/data/itinerary` (`:data:itinerary`): Itinerary events, expenses, documents, and DAOs. + - `jetpacker/android/core/ui` (`:core:ui`): Design system, toolbar, FAB, tokens. + - `jetpacker/android/core/flags` (`:core:flags`): Feature toggles (`FeatureFlags`). + +--- + +## 2. Build & Test Recipes + +Execute Gradle commands from the `jetpacker/android` directory: + +### Run All Unit Tests +```bash +cd jetpacker/android +./gradlew testDebugUnitTest +``` + +### Run Targeted Module Tests +```bash +# Travel Assistant & Document Vault +./gradlew :feature:assistant:testDebugUnitTest + +# Trips Repository & DAOs +./gradlew :data:trips:testDebugUnitTest + +# Booking Assistant & A2UI +./gradlew :feature:trip:booking_assistant:testDebugUnitTest + +# AppFunctions +./gradlew :feature:appfunctions:testDebugUnitTest +``` + +### Build APK +```bash +./gradlew :app:assembleDebug +``` + +### Fix Stale Compiler / KSP Caches +If encountering unresolved references after switching branches or updating interfaces: +```bash +./gradlew clean :feature:assistant:compileDebugKotlin +``` + +--- + +## 3. Database Updates & Room Guidelines + +- **Database Class**: `JetPackerDatabase.kt` (`jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/JetPackerDatabase.kt`). +- When modifying an entity: + 1. Increment `version = N + 1` in `@Database(...)`. + 2. If adding fields, provide appropriate default values or migration steps. + 3. Ensure test fake DAOs (`FakeTripDao`, `TestTripDao`, `FakeEventDao`) match the interface in `data/trips` and `data/itinerary` exactly. + +--- + +## 4. On-Device AI: Gemini Nano & Travel Assistant + +The on-device assistant lives in `TravelAssistantViewModel.kt` (`jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt`). + +- **Model Setup**: + Uses `previewGenerativeModel` configured with `ModelReleaseStage.PREVIEW` and `ModelPreference.FAST`. +- **Dynamic Tool Registration**: + Tools in `TravelDaoTools.kt` (`jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt`) query the local Room database (`TripDao`). +- **Token Pruning Invariant**: Keep tool descriptions and schema payloads concise to stay well within on-device context windows (~1,200 tokens saved per turn). +- **Public API Rule**: Always use the public Android ML Kit GenAI Agent library (`com.google.mlkit:genai-agent:1.0.0-alpha1`). + +--- + +## 5. Booking Assistant & Jetpack A2UI + +- **UI Renderer**: Uses `androidx.a2ui` Compose renderer to dynamically render flight, hotel, and activity cards streamed from the backend. +- **Backend Service**: + ```bash + cd jetpacker/server + pip install -r requirements.txt + uvicorn main:app --host 0.0.0.0 --port 8000 + ``` +- **Reverse Port for Local Emulation**: + ```bash + adb reverse tcp:8000 tcp:8000 + ``` +- Switch between `BackendEndpoint.LOCAL`, `PARALLEL_STAGING`, and `PRODUCTION` in `BookingAssistantViewModel.kt` (`jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt`). + +--- + +## 6. AppFunctions Integration (`:feature:appfunctions`) + +Exposes travel data and actions to Android system intelligence: +- Entry point: `JetPackerAppFunctionService.kt` (`jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/JetPackerAppFunctionService.kt`). +- Annotated with `@AppFunctionService`. +- Functions must use `@AppFunctionSerializable` inputs/outputs. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..147c63a6 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,224 @@ +# JetPacker Architecture + +JetPacker is an AI-first travel companion application designed for Android, demonstrating on-device machine learning (ML Kit and Gemini Nano), cloud AI reasoning (Firebase AI Logic), dynamic agent-driven UI (Jetpack A2UI), and Android system intelligence integration ([AppFunctions](https://d.android.com/ai/appfunctions)). + +--- + +## 1. High-Level Subsystem Architecture + +```mermaid +graph TD + App[":app Application Shell"] + + subgraph UI_Features["Feature Modules (:feature:*)"] + Home[":feature:home"] + CreateTrip[":feature:create_trip"] + TripShell[":feature:trip"] + Itinerary[":feature:trip:itinerary"] + Expenses[":feature:trip:expenses"] + VoiceNotes[":feature:trip:voice_notes"] + BookingAssistant[":feature:trip:booking_assistant"] + Detail[":feature:detail"] + Museum[":feature:detail:museum_assistant"] + Review[":feature:detail:review"] + HotelChat[":feature:detail:hotel_chat"] + Assistant[":feature:assistant (Travel Assistant & Document Vault)"] + AppFunctions[":feature:appfunctions (Android AppFunctions Service)"] + end + + subgraph Data_Layer["Data Modules (:data:*)"] + DataTrips[":data:trips (Trip Repository & Entity)"] + DataItinerary[":data:itinerary (Timeline, Expenses, Docs, Themes)"] + DataDb[":data:db (Room Database v4 & DAOs)"] + end + + subgraph Core_Layer["Core Modules (:core:*)"] + CoreUI[":core:ui (Design System, Tokens, Components)"] + CoreFlags[":core:flags (Feature Toggles)"] + CoreSpeech[":core:speech (On-Device Speech-to-Text)"] + end + + subgraph Backend_Server["Backend Services (jetpacker/server)"] + FastAPIServer["FastAPI Server + SSE Streaming"] + ADKAgents["Google ADK Multi-Agent Coordinator"] + end + + App --> UI_Features + TripShell --> Itinerary + TripShell --> Expenses + TripShell --> VoiceNotes + TripShell --> BookingAssistant + Detail --> Museum + Detail --> Review + Detail --> HotelChat + + UI_Features --> DataTrips + UI_Features --> DataItinerary + UI_Features --> CoreUI + UI_Features --> CoreFlags + UI_Features --> CoreSpeech + + DataTrips --> DataDb + DataItinerary --> DataDb + AppFunctions --> DataDb + Assistant --> DataDb + + BookingAssistant -.->|"SSE / Streaming (REST)"| FastAPIServer + FastAPIServer --> ADKAgents +``` + +--- + +## 2. Module Directory Map + +| Layer | Module Path | Gradle Target | Key Responsibilities | +| :--- | :--- | :--- | :--- | +| **Application** | `android/app` | `:app` | Application entry point, Hilt dependency graph, root navigation compose host, theme provider. | +| **Features** | `android/feature/home` | `:feature:home` | Home dashboard, list of active and past trips, quick trip actions. | +| | `android/feature/create_trip` | `:feature:create_trip` | Dialogs and forms for creating new trips or editing existing destinations and dates. | +| | `android/feature/trip` | `:feature:trip` | Top-level trip shell holding bottom navigation bar across Itinerary, Expenses, Notes, and Booking. | +| | `android/feature/trip/itinerary` | `:feature:trip:itinerary` | Timeline view of daily itinerary events, event chips, and AI trip summaries. | +| | `android/feature/trip/expenses` | `:feature:trip:expenses` | Expense logging, category budgeting, currency conversions, and receipt OCR scanning. | +| | `android/feature/trip/voice_notes` | `:feature:trip:voice_notes` | On-device voice recorder with real-time speech-to-text transcription. | +| | `android/feature/trip/booking_assistant` | `:feature:trip:booking_assistant` | Jetpack A2UI dynamic UI rendering for flight/hotel option cards, seat selection, and booking flows. | +| | `android/feature/detail` | `:feature:detail` | Base detail screens for flights, hotels, restaurants, and museums. | +| | `android/feature/detail/museum_assistant` | `:feature:detail:museum_assistant` | Grounded museum chatbot using Gemini 3.5 flash-lite with Google Maps/Search grounding. | +| | `android/feature/detail/review` | `:feature:detail:review` | AI-assisted review drafting with topic chips and star rating evaluation. | +| | `android/feature/detail/hotel_chat` | `:feature:detail:hotel_chat` | Real-time bilingual chat with hotel reception using ML Kit on-device translation. | +| | `android/feature/assistant` | `:feature:assistant` | On-device Travel Assistant (Gemini Nano v4 Preview + Room DAO tool binding), Document Vault, and Chat History. | +| | `android/feature/appfunctions` | `:feature:appfunctions` | Android system intelligence integration via `androidx.appfunctions` exposing trips, expenses, documents, and notes. | +| **Data Layer** | `android/data/db` | `:data:db` | `JetPackerDatabase` (Room v4, 13 entities), Hilt database module, type converters. | +| | `android/data/trips` | `:data:trips` | `Trip` model, `TripDao`, and reactive repository. | +| | `android/data/itinerary` | `:data:itinerary` | `TimelineEvent`, `Expense`, `TravelDocument`, `DayTheme`, `VoiceNoteEntity` models and DAOs. | +| **Core Layer** | `android/core/ui` | `:core:ui` | Reusable Compose components (`JetPackerToolbar`, `JetPackerFab`), typography, and color tokens. | +| | `android/core/flags` | `:core:flags` | `FeatureFlags` controlling runtime feature toggles and AI model fallbacks. | +| | `android/core/speech` | `:core:speech` | On-device speech recognition utilities and audio recorder abstractions. | +| **Backend** | `jetpacker/server` | N/A (Python) | FastAPI server hosting Google ADK agents (Flight, Hotel, Activity, Orchestrator) with SSE streaming for A2UI. | + +--- + +## 3. Data Storage & Persistence (`:data:db`) + +JetPacker uses Room Database (`JetPackerDatabase`, schema version 4) as the single source of truth for all local trip records. + +### Database Entities & DAOs + +```mermaid +erDiagram + Trip ||--o{ TimelineEvent : contains + Trip ||--o{ Expense : tracks + Trip ||--o{ DayTheme : styles + Trip ||--o{ VoiceNoteEntity : records + Trip ||--o{ TravelDocument : stores + Trip ||--o{ AssistantConversation : logs + + Trip { + string id PK + string destination + long startDate + long endDate + string imageUrl + } + + TimelineEvent { + string id PK + string tripId FK + string title + string eventType + long startTime + long endTime + } + + Expense { + string id PK + string tripId FK + double amount + string currency + string category + long timestamp + } + + TravelDocument { + string id PK + string tripId FK + string title + string documentType + string rawContent + } + + DayTheme { + string date PK + string tripId FK + string primaryColor + string themeName + } +``` + +- **`TripDao`**: Reactive Flow streams (`getAllTrips()`, `getTrip(id)`), inserts, and deletions. +- **`EventDao`**: Timeline events and specialized details (`FlightDetail`, `HotelDetail`, `DiningDetail`, `MuseumDetail`, `ActivityDetail`). +- **`ExpenseDao`**: Expense records and aggregate queries. +- **`TravelDocumentDao`**: On-device travel vouchers, boarding passes, and email receipts. +- **`AssistantConversationDao`**: Serialized conversation logs for travel assistant chat history. + +--- + +## 4. AI & Agent Subsystems + +JetPacker implements a hybrid, multi-tier AI architecture balancing low-latency on-device processing with complex cloud orchestration: + +```mermaid +flowchart TD + UserQuery["User Input / Interaction"] + + subgraph OnDevice["On-Device AI Layer"] + Nano["Gemini Nano v4 Preview\n(TravelAssistantViewModel)"] + MLPrompt["ML Kit GenAI Prompt\n(Trip Summaries & Tips)"] + MLTrans["ML Kit Translation\n(Hotel Support Chat)"] + MLSpeech["On-Device SpeechRecognizer\n(Voice Notes)"] + AppSearch["AppSearch Document Index\n(Document Vault)"] + end + + subgraph CloudHybrid["Cloud & Backend AI Layer"] + FirebaseGemini["Firebase AI Logic (Gemini 3.5 Flash-Lite)\nMuseum Assistant & Reviews"] + ADKCoordinator["Google ADK Multi-Agent Coordinator\n(jetpacker/server)"] + A2UIRenderer["Jetpack A2UI Compose Renderer\n(Booking Assistant)"] + end + + UserQuery -->|"Trip queries / Planning"| Nano + UserQuery -->|"Summary generation"| MLPrompt + UserQuery -->|"Receptionist chat"| MLTrans + UserQuery -->|"Audio dictation"| MLSpeech + UserQuery -->|"Document search"| AppSearch + + UserQuery -->|"Complex museum Q&A"| FirebaseGemini + UserQuery -->|"Multi-step booking"| ADKCoordinator + ADKCoordinator -->|"SSE Event Stream"| A2UIRenderer +``` + +### 1. Travel Assistant (`TravelAssistantViewModel.kt`) +- Runs locally via **Gemini Nano v4 Preview** with automated download management. +- Directly binds pruned Room DAO tools (`TravelDaoAgentTool`) allowing the model to query live database trips and itineraries dynamically without token overhead. +- Pruned tool definitions save ~1,200 tokens per inference turn. + +### 2. Booking Assistant & Jetpack A2UI +- Uses **Jetpack A2UI** (`androidx.a2ui`) to dynamically render interactive UI cards streamed from the backend. +- The Python backend runs Google Agent Development Kit (ADK) agents that negotiate flight, hotel, and activity options and emit JSON A2UI payloads over Server-Sent Events (SSE). + +### 3. Document Vault & Semantic Search +- Combines on-device OCR / Vision scanning with Android **AppSearch** for fast, offline, privacy-first retrieval of boarding passes, tickets, and booking confirmations. + +--- + +## 5. System Intelligence Integration (`:feature:appfunctions`) + +JetPacker exposes its core capabilities to the Android OS and external assistant surfaces using the `androidx.appfunctions` framework: + +- **Service**: `JetPackerAppFunctionService` (annotated with `@AppFunctionService`). +- **Exposed Functions**: + - `getTrips`, `createTrip`, `updateTrip` + - `getTimelineEvents`, `addTimelineEvent` + - `getExpenses`, `addExpense` + - `getVoiceNotes`, `addVoiceNote` + - `searchTravelDocuments`, `getTravelDocumentDetails` + - `getDayThemes`, `setDayTheme` +- **Serialization**: Custom serializable types (`TripSerializable`, `ExpenseSerializable`, `TimelineEventSerializable`) with generated `@AppFunctionSerializable` factories. diff --git a/jetpacker/.agents/AGENTS.md b/jetpacker/.agents/AGENTS.md index c2c58114..d0d3bc69 100644 --- a/jetpacker/.agents/AGENTS.md +++ b/jetpacker/.agents/AGENTS.md @@ -1,6 +1,83 @@ -# Code Style & Formatting Constraints +# Agent & Developer Guidelines: JetPacker -## Avoid Fully Qualified Class Names (FQCN) +This document outlines key technical constraints, coding standards, and testing procedures for AI agents and human developers working in the JetPacker repository. + +--- + +## 1. Code Style & Syntax Invariants + +### 1.1 Avoid Fully Qualified Class Names (FQCN) When writing or modifying Kotlin, Java, or Compose files: -1. **Always Use Imports**: Never write inline Fully Qualified Class Names (e.g., `com.example.jetpacker.core.flags.FeatureFlags`, `java.time.Instant`) in function bodies, parameter lists, or property definitions. -2. **Explicit File-Level Imports**: Add explicit `import` statements at the top of the file for all referenced types, classes, and utilities. +- **Always Use Top-Level Imports**: Never write inline Fully Qualified Class Names (e.g., `com.example.jetpacker.core.flags.FeatureFlags`, `java.time.Instant`, `androidx.compose.ui.Modifier`) in function bodies, parameter lists, or property definitions. +- **Explicit File-Level Imports**: Add explicit `import` statements at the top of the file for all referenced types, classes, and utilities. + +### 1.2 Jetpack Compose Conventions +- **Modifier Placement**: Every composable function (except top-level screen containers) must accept `modifier: Modifier = Modifier` as the first optional parameter. +- **Unidirectional Data Flow (UDF)**: ViewModels expose a single immutable `uiState: StateFlow`. Composables accept states and emit lambda event callbacks (e.g., `onTripSelected: (String) -> Unit`). +- **No Direct Context References in ViewModels**: ViewModels should remain agnostic of Android UI lifecycles. Never pass `Activity` or `Context` into ViewModel methods. If `Application` is required, inject `Application` directly via constructor. + +### 1.3 Hilt Dependency Injection Rules +- **No `@ApplicationContext` on `Application`**: In Hilt, `Application` is provided directly by default. Annotating constructor parameter `application: Application` with `@ApplicationContext` will cause a Dagger compiler failure (`[Dagger/MissingBinding]`). Only use `@ApplicationContext` when the parameter type is `Context`. + +--- + +## 2. Room Database & Persistence Rules + +- **Database Class**: `JetPackerDatabase` in `:data:db` (current version: 4). +- **Entities & DAOs**: + - `Trip` & `TripDao` in `:data:trips`. + - `TimelineEvent`, `Expense`, `TravelDocument`, `DayTheme`, `VoiceNoteEntity`, `AssistantConversation` in `:data:itinerary`. +- **Interface Parity**: When updating a DAO interface, immediately update corresponding test fakes (`FakeTripDao`, `TestTripDao`, `FakeEventDao`). Never add methods to test fakes that do not exist on the real DAO interface. +- **Schema Updates**: Any entity modification requires bumping the `version` number in `JetPackerDatabase.kt` and providing an appropriate `Migration` or fallback policy. + +--- + +## 3. AI & On-Device Agent Constraints + +### 3.1 ML Kit GenAI Agent Integration +- JetPacker uses the public Android ML Kit GenAI Agent library (`com.google.mlkit:genai-agent:1.0.0-alpha1`). +- Always use standard public Android AI dependencies. +- Use `AgentRunner(context, agent, generativeModel = previewGenerativeModel)` with `agentBuilder.enableAppFunctions(true)`. + +### 3.2 Gemini Nano v4 Preview +- Configured using `ModelReleaseStage.PREVIEW` and `ModelPreference.FAST`. +- Always check on-device availability via `previewGenerativeModel.checkStatus()` and handle download / unsupported states before invoking inference. + +### 3.3 Token Pruning for On-Device Tools +- On-device models have strict context window budgets. When defining tool descriptions or schemas in `TravelDaoTools.kt`, keep descriptions compact and concise to prevent exhausting token limits. + +--- + +## 4. Build & Test Recipes + +### 4.1 Running Tests +```bash +# Run all unit tests across all modules +cd jetpacker/android && ./gradlew testDebugUnitTest + +# Run tests for a specific module +./gradlew :feature:assistant:testDebugUnitTest +./gradlew :data:trips:testDebugUnitTest +./gradlew :feature:trip:booking_assistant:testDebugUnitTest + +# Assemble Debug APK +./gradlew :app:assembleDebug +``` + +### 4.2 Handling Incremental Build Quirks +If KSP or Kotlin compiler fails with unexpected unresolved symbols after switching branches or modifying interfaces: +```bash +./gradlew clean :compileDebugKotlin +``` + +--- + +## 5. Python Booking Assistant Server (`jetpacker/server`) + +The booking assistant backend coordinates agents for flights, hotels, and activities: +```bash +cd jetpacker/server +pip install -r requirements.txt +uvicorn main:app --host 0.0.0.0 --port 8000 +``` +- **Staging vs Production**: Configurable in `BookingAssistantViewModel.kt` (`PARALLEL_STAGING` connects to Cloud Run `/v2`, `LOCAL` connects to `localhost:8000`). diff --git a/jetpacker/.agents/skills/jetpacker/SKILL.md b/jetpacker/.agents/skills/jetpacker/SKILL.md new file mode 100644 index 00000000..7070a5ba --- /dev/null +++ b/jetpacker/.agents/skills/jetpacker/SKILL.md @@ -0,0 +1,126 @@ +--- +name: jetpacker +description: >- + Comprehensive guide for developing, building, testing, architecting, and debugging + the JetPacker Android travel companion application and its Python booking backend. + Use when compiling or running unit tests with Gradle, inspecting or updating Room database + schemas, editing ViewModels and Compose screens, configuring on-device ML Kit / Gemini Nano, + working with Jetpack A2UI booking flows, or adding AppFunctions. +--- + +# JetPacker Development & Architecture Skill + +This skill provides step-by-step procedures, architectural overviews, and build/test recipes for working with JetPacker. + +--- + +## 1. Quick Navigation & Module Layout + +JetPacker is organized into an Android multi-module project under `jetpacker/android/` and a Python backend under `jetpacker/server/`. + +- **Application Shell**: + - `jetpacker/android/app` (`:app`): Top-level navigation host, Hilt graph, theme. +- **Feature Modules**: + - `jetpacker/android/feature/home` (`:feature:home`): Upcoming trips list, quick actions. + - `jetpacker/android/feature/create_trip` (`:feature:create_trip`): Create/edit trip destination and date picker. + - `jetpacker/android/feature/trip` (`:feature:trip`): Trip dashboard and bottom bar container. + - `jetpacker/android/feature/trip/itinerary` (`:feature:trip:itinerary`): Daily timeline events & summaries. + - `jetpacker/android/feature/trip/expenses` (`:feature:trip:expenses`): Budgeting, expense logging, receipt OCR. + - `jetpacker/android/feature/trip/voice_notes` (`:feature:trip:voice_notes`): On-device speech recognition audio recorder. + - `jetpacker/android/feature/trip/booking_assistant` (`:feature:trip:booking_assistant`): Jetpack A2UI dynamic card streaming UI. + - `jetpacker/android/feature/assistant` (`:feature:assistant`): On-device Gemini Nano v4 Travel Assistant, Document Vault, and Chat History. + - `jetpacker/android/feature/detail` (`:feature:detail`): Detail screens (Museum Assistant, AI Reviews, Hotel Support Chat). + - `jetpacker/android/feature/appfunctions` (`:feature:appfunctions`): Android system AppFunctions service. +- **Data & Core Modules**: + - `jetpacker/android/data/db` (`:data:db`): Room database v4 (`JetPackerDatabase`), DAOs, Hilt module. + - `jetpacker/android/data/trips` (`:data:trips`): Trip repository, entities, and Flow streams. + - `jetpacker/android/data/itinerary` (`:data:itinerary`): Itinerary events, expenses, documents, and DAOs. + - `jetpacker/android/core/ui` (`:core:ui`): Design system, toolbar, FAB, tokens. + - `jetpacker/android/core/flags` (`:core:flags`): Feature toggles (`FeatureFlags`). + +--- + +## 2. Build & Test Recipes + +Execute Gradle commands from the `jetpacker/android` directory: + +### Run All Unit Tests +```bash +cd jetpacker/android +./gradlew testDebugUnitTest +``` + +### Run Targeted Module Tests +```bash +# Travel Assistant & Document Vault +./gradlew :feature:assistant:testDebugUnitTest + +# Trips Repository & DAOs +./gradlew :data:trips:testDebugUnitTest + +# Booking Assistant & A2UI +./gradlew :feature:trip:booking_assistant:testDebugUnitTest + +# AppFunctions +./gradlew :feature:appfunctions:testDebugUnitTest +``` + +### Build APK +```bash +./gradlew :app:assembleDebug +``` + +### Fix Stale Compiler / KSP Caches +If encountering unresolved references after switching branches or updating interfaces: +```bash +./gradlew clean :feature:assistant:compileDebugKotlin +``` + +--- + +## 3. Database Updates & Room Guidelines + +- **Database Class**: `JetPackerDatabase.kt` (`jetpacker/android/data/db/src/main/kotlin/com/example/jetpacker/data/db/JetPackerDatabase.kt`). +- When modifying an entity: + 1. Increment `version = N + 1` in `@Database(...)`. + 2. If adding fields, provide appropriate default values or migration steps. + 3. Ensure test fake DAOs (`FakeTripDao`, `TestTripDao`, `FakeEventDao`) match the interface in `data/trips` and `data/itinerary` exactly. + +--- + +## 4. On-Device AI: Gemini Nano & Travel Assistant + +The on-device assistant lives in `TravelAssistantViewModel.kt` (`jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/TravelAssistantViewModel.kt`). + +- **Model Setup**: + Uses `previewGenerativeModel` configured with `ModelReleaseStage.PREVIEW` and `ModelPreference.FAST`. +- **Dynamic Tool Registration**: + Tools in `TravelDaoTools.kt` (`jetpacker/android/feature/assistant/src/main/kotlin/com/example/jetpacker/feature/assistant/tools/TravelDaoTools.kt`) query the local Room database (`TripDao`). +- **Token Pruning Invariant**: Keep tool descriptions and schema payloads concise to stay well within on-device context windows (~1,200 tokens saved per turn). +- **Public API Rule**: Always use the public Android ML Kit GenAI Agent library (`com.google.mlkit:genai-agent:1.0.0-alpha1`). + +--- + +## 5. Booking Assistant & Jetpack A2UI + +- **UI Renderer**: Uses `androidx.a2ui` Compose renderer to dynamically render flight, hotel, and activity cards streamed from the backend. +- **Backend Service**: + ```bash + cd jetpacker/server + pip install -r requirements.txt + uvicorn main:app --host 0.0.0.0 --port 8000 + ``` +- **Reverse Port for Local Emulation**: + ```bash + adb reverse tcp:8000 tcp:8000 + ``` +- Switch between `BackendEndpoint.LOCAL`, `PARALLEL_STAGING`, and `PRODUCTION` in `BookingAssistantViewModel.kt` (`jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt`). + +--- + +## 6. AppFunctions Integration (`:feature:appfunctions`) + +Exposes travel data and actions to Android system intelligence: +- Entry point: `JetPackerAppFunctionService.kt` (`jetpacker/android/feature/appfunctions/src/main/java/com/example/jetpacker/feature/appfunctions/JetPackerAppFunctionService.kt`). +- Annotated with `@AppFunctionService`. +- Functions must use `@AppFunctionSerializable` inputs/outputs. From 96721dc14159c757e377e8099ec6d3cdbbe9888d Mon Sep 17 00:00:00 2001 From: Jolanda Verhoef Date: Tue, 3 Nov 2026 11:30:35 +0000 Subject: [PATCH 3/3] feat(trips): support persistent budget, event scheduling, and local fallback --- .../assistant/TravelAssistantViewModel.kt | 101 ++++++++++++++++-- .../feature/create_trip/CreateTripPanel.kt | 58 ++++++++++ .../feature/create_trip/CreateTripUiState.kt | 2 + .../create_trip/CreateTripViewModel.kt | 26 ++++- .../jetpacker/feature/home/HomeViewModel.kt | 30 +++++- .../feature/expenses/ManageExpensesScreen.kt | 28 +++-- .../expenses/ManageExpensesViewModel.kt | 22 ++++ .../expenses/ManageExpensesViewModelTest.kt | 29 +++-- .../feature/itinerary/ItineraryScreen.kt | 4 +- .../feature/itinerary/ItineraryViewModel.kt | 27 ++++- .../itinerary/components/AddEventDialog.kt | 9 +- 11 files changed, 306 insertions(+), 30 deletions(-) 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 index 988d22d8..99d26b02 100644 --- 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 @@ -33,6 +33,7 @@ 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 @@ -909,13 +910,99 @@ constructor( ) } 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) - _overlayState.value = AssistantOverlayState.Collapsed + var fallbackApplied = false + val lowerPrompt = trimmedPrompt.lowercase() + val candidate = extractCandidateExpense(trimmedPrompt) + + if ((lowerPrompt.contains("expense") || lowerPrompt.contains("spent") || lowerPrompt.contains("cost") || candidate != null) && currentTripId != null) { + val (title, amount, currency) = candidate ?: run { + val amountMatch = Regex("""\d+(\.\d{1,2})?""").find(trimmedPrompt) + val amt = amountMatch?.value?.toDoubleOrNull() ?: 0.0 + Triple("Trip Expense", amt, "USD") + } + if (amount > 0.0) { + val expense = Expense( + id = UUID.randomUUID().toString(), + tripId = currentTripId!!, + amount = amount, + title = title, + category = "other", + currency = currency, + timestamp = System.currentTimeMillis() + ) + viewModelScope.launch { + expenseDao?.insertExpense(expense) + } + val record = ToolExecutionRecord( + name = "addExpense", + args = mapOf("amount" to amount, "title" to title, "currency" to currency) + ) + currentToolExecutions.add(record) + userActionSteps.add(UserActionStep( + id = UUID.randomUUID().toString(), + category = ActionStepCategory.EXPENSE, + status = ActionStepStatus.COMPLETED, + title = "Logged $title ($currency $amount)" + )) + lastExpenseHighlight = ExpenseHighlight( + title = title, + amount = amount, + currency = currency, + category = "other", + tripId = currentTripId!! + ) + activeHighlightedEntity = lastExpenseHighlight + fallbackApplied = true + } + } + + if (!fallbackApplied && lowerPrompt.contains("budget") && currentTripId != null) { + val record = ToolExecutionRecord( + name = "getExpenses", + args = emptyMap() + ) + currentToolExecutions.add(record) + userActionSteps.add(UserActionStep( + id = UUID.randomUUID().toString(), + category = ActionStepCategory.EXPENSE, + status = ActionStepStatus.COMPLETED, + title = "Retrieved trip budget status" + )) + fallbackApplied = true + } + + if (fallbackApplied) { + val fallbackAnswer = if (lastExpenseHighlight != null) { + val currSymbol = when (lastExpenseHighlight?.currency) { + "EUR" -> "€" + "GBP" -> "£" + "JPY" -> "¥" + else -> "$" + } + "I've recorded ${lastExpenseHighlight?.title} for $currSymbol${lastExpenseHighlight?.amount} in your trip expenses." + } else if (lowerPrompt.contains("budget")) { + "Here are your trip expenses and remaining budget." + } else { + "Request completed." + } + completeTurnWithResults( + trimmedPrompt = trimmedPrompt, + rawAnswer = fallbackAnswer, + currentToolExecutions = currentToolExecutions, + userActionSteps = userActionSteps, + activeHighlightedEntity = activeHighlightedEntity, + isAlreadyConversational = isAlreadyConversational, + startTime = startTime + ) + } else { + 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) + _overlayState.value = AssistantOverlayState.Collapsed + } } } } 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 fa042f16..6a4b8bd3 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 @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -48,6 +49,8 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -72,6 +75,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage @@ -107,7 +111,9 @@ fun CreateTripPanelContent( var showStartDatePicker by remember { mutableStateOf(false) } var showEndDatePicker by remember { mutableStateOf(false) } + var showCurrencyDropdown by remember { mutableStateOf(false) } var participantName by remember { mutableStateOf("") } + val currencies = remember { listOf("USD", "EUR", "GBP", "JPY", "KRW", "CAD", "AUD") } val dateFormatter = remember { DateTimeFormatter.ofPattern("MMM dd, yyyy", Locale.getDefault()) } @@ -324,6 +330,58 @@ fun CreateTripPanelContent( } } + // Budget and Currency + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextField( + value = uiState.budget, + onValueChange = viewModel::onBudgetChange, + placeholder = { Text("Budget") }, + label = { Text("Budget") }, + maxLines = 1, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + shape = CircleShape, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + modifier = Modifier.weight(1.3f), + ) + + Box( + modifier = Modifier.weight(0.7f), + ) { + Button( + onClick = { showCurrencyDropdown = true }, + modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp), + shape = CircleShape, + ) { + Text(uiState.currency) + } + DropdownMenu( + expanded = showCurrencyDropdown, + onDismissRequest = { showCurrencyDropdown = false }, + ) { + currencies.forEach { curr -> + DropdownMenuItem( + text = { Text(curr) }, + onClick = { + viewModel.onCurrencyChange(curr) + showCurrencyDropdown = false + }, + ) + } + } + } + } + // Participants Section Column( modifier = Modifier.fillMaxWidth(), diff --git a/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripUiState.kt b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripUiState.kt index a94cc9bf..e2a39f9f 100644 --- a/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripUiState.kt +++ b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripUiState.kt @@ -29,4 +29,6 @@ data class CreateTripUiState( val isGenerating: Boolean = false, val isEditing: Boolean = false, val editingTripId: String? = null, + val budget: String = "2000", + val currency: String = "USD", ) diff --git a/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripViewModel.kt b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripViewModel.kt index 96e73ab9..441223a7 100644 --- a/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripViewModel.kt +++ b/jetpacker/android/feature/create_trip/src/main/kotlin/com/example/jetpacker/feature/create_trip/CreateTripViewModel.kt @@ -52,6 +52,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -101,6 +102,8 @@ constructor( endDate = endStr, participants = trip.participants, imageUri = trip.imageUri, + budget = if (trip.budget > 0) trip.budget.toInt().toString() else "2000", + currency = trip.currency.ifBlank { "USD" }, ) } } @@ -236,6 +239,14 @@ constructor( _uiState.update { it.copy(endDate = newDate) } } + open fun onBudgetChange(newBudget: String) { + _uiState.update { it.copy(budget = newBudget) } + } + + open fun onCurrencyChange(newCurrency: String) { + _uiState.update { it.copy(currency = newCurrency) } + } + open fun onAddParticipant(name: String) { if (name.isBlank()) return _uiState.update { current -> @@ -282,6 +293,13 @@ constructor( } parsedTime } + val editingId = _uiState.value.editingTripId + val existingTrip = editingId?.let { id -> + runCatching { tripDao.getTripById(id).first() }.getOrNull() + } + val parsedBudget = _uiState.value.budget.toDoubleOrNull() ?: (existingTrip?.budget ?: 2000.0) + val selectedCurrency = _uiState.value.currency.ifBlank { existingTrip?.currency ?: "USD" } + _uiState.update { current -> val startTs = parseDate(current.startDate) val endTs = parseDate(current.endDate) @@ -293,8 +311,12 @@ constructor( location = current.location, startDate = startTs, endDate = endTs, + imageRes = existingTrip?.imageRes, + imageUri = current.imageUri ?: existingTrip?.imageUri, participants = current.participants, - imageUri = current.imageUri, + tripSummaryAndTips = existingTrip?.tripSummaryAndTips, + budget = parsedBudget, + currency = selectedCurrency, ) tripDao.insertTrip(newTrip) current.copy( @@ -307,6 +329,8 @@ constructor( endDate = "", participants = emptyList(), imageUri = null, + budget = "2000", + currency = "USD", ) } } diff --git a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeViewModel.kt b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeViewModel.kt index b439890b..32880b57 100644 --- a/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeViewModel.kt +++ b/jetpacker/android/feature/home/src/main/kotlin/com/example/jetpacker/feature/home/HomeViewModel.kt @@ -17,6 +17,7 @@ package com.example.jetpacker.feature.home import android.annotation.SuppressLint +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetpacker.core.speech.VoiceInputManager @@ -27,6 +28,7 @@ import com.example.jetpacker.data.trips.DummyData import com.example.jetpacker.data.trips.Trip import com.example.jetpacker.data.trips.TripDao import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -34,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -54,6 +57,7 @@ constructor( private val tourDetailDao: TourDetailDao, private val expenseDao: ExpenseDao, private val voiceInputManager: VoiceInputManager, + @param:ApplicationContext private val context: Context? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) open val uiState: StateFlow = _uiState.asStateFlow() @@ -89,7 +93,10 @@ constructor( delay(1500L) } .collectLatest { trips -> - if (trips.isEmpty()) { + val prefs = context?.getSharedPreferences("jetpacker_prefs", Context.MODE_PRIVATE) + val hasSeeded = prefs?.getBoolean("has_seeded_trips", false) ?: false + if (trips.isEmpty() && !hasSeeded) { + prefs?.edit()?.putBoolean("has_seeded_trips", true)?.apply() prepopulateDatabase() } else { // Sort trips naturally by timestamp @@ -116,11 +123,32 @@ constructor( open fun deleteTrip(trip: Trip) { viewModelScope.launch { + val prefs = context?.getSharedPreferences("jetpacker_prefs", Context.MODE_PRIVATE) + prefs?.edit()?.putBoolean("has_seeded_trips", true)?.apply() eventDao.deleteEventsForTrip(trip.id) tripDao.deleteTrip(trip) } } + open fun clearAllTrips() { + viewModelScope.launch { + val prefs = context?.getSharedPreferences("jetpacker_prefs", Context.MODE_PRIVATE) + prefs?.edit()?.putBoolean("has_seeded_trips", true)?.apply() + tripDao.getAllTrips().first().forEach { trip -> + eventDao.deleteEventsForTrip(trip.id) + tripDao.deleteTrip(trip) + } + } + } + + open fun restoreDemoTrips() { + viewModelScope.launch { + val prefs = context?.getSharedPreferences("jetpacker_prefs", Context.MODE_PRIVATE) + prefs?.edit()?.putBoolean("has_seeded_trips", true)?.apply() + prepopulateDatabase() + } + } + open fun setPermissionGranted(granted: Boolean) { voiceInputManager.setPermissionGranted(granted) } 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 13ad5db5..f8d83c21 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 @@ -179,6 +179,7 @@ fun ManageExpensesScreen( val context = LocalContext.current val expenses by viewModel.expenses.collectAsStateWithLifecycle(initialValue = emptyList()) + val currentTrip by viewModel.trip.collectAsStateWithLifecycle(initialValue = null) LaunchedEffect(tripId) { if (tripId.isNotEmpty()) { @@ -186,7 +187,16 @@ fun ManageExpensesScreen( } } - var budget by remember { mutableStateOf(2000.00) } + val budget = currentTrip?.budget ?: 2000.00 + val currency = currentTrip?.currency ?: "USD" + val currencySymbol = + when (currency.uppercase()) { + "EUR", "€" -> "€" + "GBP", "£" -> "£" + "JPY", "¥" -> "¥" + "KRW", "₩" -> "₩" + else -> "$" + } var isBudgetSheetVisible by remember { mutableStateOf(false) } var selectedExpense by remember { mutableStateOf(null) } @@ -310,6 +320,7 @@ fun ManageExpensesScreen( TotalSpentDisplay( totalSpent = totalSpent, budget = budget, + currencySymbol = currencySymbol, isProcessing = isProcessing.value || isAssistantProcessingExpense, onBudgetClick = { isBudgetSheetVisible = true }, ) @@ -414,8 +425,12 @@ fun ManageExpensesScreen( if (isBudgetSheetVisible) { BudgetEditSheet( initialBudget = budget, + currencySymbol = currencySymbol, onDismiss = { isBudgetSheetVisible = false }, - onSave = { budget = it }, + onSave = { + viewModel.updateBudget(it) + isBudgetSheetVisible = false + }, ) } @@ -438,6 +453,7 @@ private fun TotalSpentDisplay( totalSpent: Double, budget: Double, onBudgetClick: () -> Unit, + currencySymbol: String = "$", modifier: Modifier = Modifier, isProcessing: Boolean = false, ) { @@ -447,7 +463,7 @@ private fun TotalSpentDisplay( animationSpec = tween(durationMillis = 600), label = "total_spent_anim", ) - val totalText = "$${"%.2f".format(animatedTotalSpent)}" + val totalText = "$currencySymbol${"%.2f".format(animatedTotalSpent)}" Column( modifier = modifier.fillMaxWidth().padding(vertical = 16.dp), @@ -495,7 +511,7 @@ private fun TotalSpentDisplay( modifier = Modifier.clickable { onBudgetClick() }, ) { Text( - text = "$${budget.toInt()}", + text = "$currencySymbol${budget.toInt()}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onPrimary, @@ -875,7 +891,7 @@ fun ExpenseDetailSheet( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BudgetEditSheet(initialBudget: Double, onDismiss: () -> Unit, onSave: (Double) -> Unit) { +fun BudgetEditSheet(initialBudget: Double, currencySymbol: String = "$", onDismiss: () -> Unit, onSave: (Double) -> Unit) { var text by remember { mutableStateOf(initialBudget.toInt().toString()) } val sheetState = rememberModalBottomSheetState() @@ -894,7 +910,7 @@ fun BudgetEditSheet(initialBudget: Double, onDismiss: () -> Unit, onSave: (Doubl value = text, onValueChange = { text = it }, label = { Text("Budget Amount") }, - prefix = { Text("$") }, + prefix = { Text(currencySymbol) }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, diff --git a/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModel.kt b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModel.kt index 5e9343fa..d1d800c9 100644 --- a/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModel.kt +++ b/jetpacker/android/feature/trip/expenses/src/main/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModel.kt @@ -22,6 +22,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetpacker.data.itinerary.Expense import com.example.jetpacker.data.itinerary.ExpenseDao +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao +import kotlinx.coroutines.flow.first import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -37,6 +40,7 @@ constructor( private val savedStateHandle: SavedStateHandle, private val receiptParser: ReceiptParser, private val expenseDao: ExpenseDao, + private val tripDao: TripDao, ) : ViewModel() { private val _tripIdFlow = MutableStateFlow(savedStateHandle.get("tripId") ?: "") @@ -49,6 +53,24 @@ constructor( } } + @OptIn(ExperimentalCoroutinesApi::class) + val trip: Flow = _tripIdFlow.flatMapLatest { id -> + tripDao.getTripById(id) + } + + fun updateBudget(newBudget: Double, currency: String? = null) { + viewModelScope.launch { + val t = tripDao.getTripById(tripId).first() + if (t != null) { + val updatedTrip = t.copy( + budget = newBudget, + currency = currency ?: t.currency + ) + tripDao.insertTrip(updatedTrip) + } + } + } + @OptIn(ExperimentalCoroutinesApi::class) val expenses: Flow> = _tripIdFlow.flatMapLatest { id -> expenseDao.getExpensesForTrip(id) diff --git a/jetpacker/android/feature/trip/expenses/src/test/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModelTest.kt b/jetpacker/android/feature/trip/expenses/src/test/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModelTest.kt index 862919db..6988626f 100644 --- a/jetpacker/android/feature/trip/expenses/src/test/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModelTest.kt +++ b/jetpacker/android/feature/trip/expenses/src/test/kotlin/com/example/jetpacker/feature/expenses/ManageExpensesViewModelTest.kt @@ -20,6 +20,8 @@ import android.graphics.Bitmap import androidx.lifecycle.SavedStateHandle import com.example.jetpacker.data.itinerary.Expense import com.example.jetpacker.data.itinerary.ExpenseDao +import com.example.jetpacker.data.trips.Trip +import com.example.jetpacker.data.trips.TripDao import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -42,12 +44,14 @@ class ManageExpensesViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var fakeExpenseDao: FakeExpenseDao + private lateinit var fakeTripDao: FakeTripDao private lateinit var fakeReceiptParser: FakeReceiptParser @Before fun setup() { Dispatchers.setMain(testDispatcher) fakeExpenseDao = FakeExpenseDao() + fakeTripDao = FakeTripDao() fakeReceiptParser = FakeReceiptParser() } @@ -65,7 +69,7 @@ class ManageExpensesViewModelTest { fakeExpenseDao.insertExpense(initialExpense) val savedStateHandle = SavedStateHandle(mapOf("tripId" to "trip-initial")) - val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao) + val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao, fakeTripDao) advanceUntilIdle() @@ -99,7 +103,7 @@ class ManageExpensesViewModelTest { fakeExpenseDao.insertExpense(expenseTripB) val savedStateHandle = SavedStateHandle(mapOf("tripId" to "trip-a")) - val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao) + val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao, fakeTripDao) advanceUntilIdle() assertEquals("trip-a", viewModel.tripId) @@ -115,7 +119,7 @@ class ManageExpensesViewModelTest { @Test fun addExpense_withEmptyTripId_assignsCurrentTripId() = runTest { val savedStateHandle = SavedStateHandle(mapOf("tripId" to "trip-active")) - val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao) + val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao, fakeTripDao) advanceUntilIdle() @@ -140,7 +144,7 @@ class ManageExpensesViewModelTest { @Test fun addExpense_withExistingTripId_preservesTripId() = runTest { val savedStateHandle = SavedStateHandle(mapOf("tripId" to "trip-active")) - val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao) + val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao, fakeTripDao) advanceUntilIdle() @@ -174,7 +178,7 @@ class ManageExpensesViewModelTest { fakeExpenseDao.insertExpense(expense) val savedStateHandle = SavedStateHandle(mapOf("tripId" to "trip-1")) - val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao) + val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao, fakeTripDao) advanceUntilIdle() assertEquals(1, fakeExpenseDao.expenses.size) @@ -188,7 +192,7 @@ class ManageExpensesViewModelTest { @Test fun isSupported_delegatesToReceiptParser() = runTest { val savedStateHandle = SavedStateHandle(mapOf("tripId" to "trip-1")) - val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao) + val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao, fakeTripDao) fakeReceiptParser.supported = true assertTrue(viewModel.isSupported()) @@ -200,7 +204,7 @@ class ManageExpensesViewModelTest { @Test fun parseReceipt_delegatesToReceiptParser() = runTest { val savedStateHandle = SavedStateHandle(mapOf("tripId" to "trip-1")) - val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao) + val viewModel = ManageExpensesViewModel(savedStateHandle, fakeReceiptParser, fakeExpenseDao, fakeTripDao) val unsafeField = Unsafe::class.java.getDeclaredField("theUnsafe") unsafeField.isAccessible = true @@ -214,7 +218,16 @@ class ManageExpensesViewModelTest { assertEquals(expectedJson, response) } - private class FakeExpenseDao : ExpenseDao { + private class FakeTripDao : TripDao { + override fun getAllTrips(): Flow> = MutableStateFlow(emptyList()) + override fun getTripById(tripId: String): Flow = MutableStateFlow(null) + override suspend fun insertTrip(trip: Trip) {} + override suspend fun insertTrips(trips: List) {} + override suspend fun deleteTrip(trip: Trip) {} + override suspend fun deleteAllTrips() {} +} + +class FakeExpenseDao : ExpenseDao { val expenses = mutableListOf() private val _expensesFlow = MutableStateFlow>(emptyList()) 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 121be0f0..85e2c9b9 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 @@ -216,8 +216,8 @@ fun ItineraryScreen( if (uiState.showAddEventDialog) { AddEventDialog( onDismiss = { viewModel.hideAddEvent() }, - onSave = { type, title, location, time, extraFields -> - viewModel.addEvent(type, title, location, time, extraFields) + onSave = { type, title, location, time, date, extraFields -> + viewModel.addEvent(type, title, location, time, date, extraFields) }, ) } diff --git a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryViewModel.kt b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryViewModel.kt index c29361cd..3751d09c 100644 --- a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryViewModel.kt +++ b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/ItineraryViewModel.kt @@ -41,9 +41,11 @@ import com.example.jetpacker.data.trips.Trip import com.example.jetpacker.data.trips.TripDao import dagger.hilt.android.lifecycle.HiltViewModel import java.io.BufferedReader +import java.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime +import java.util.Locale import java.util.UUID import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -338,16 +340,33 @@ constructor( title: String, location: String, time: String, + date: String, extraFields: Map, ) { viewModelScope.launch { val eventId = UUID.randomUUID().toString() + + val parsedTime = try { + if (date.isNotBlank() && time.isNotBlank()) { + val formatStr = "$date $time".trim() + val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.US) + sdf.parse(formatStr)?.time ?: System.currentTimeMillis() + } else if (date.isNotBlank()) { + val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.US) + sdf.parse(date)?.time ?: System.currentTimeMillis() + } else { + System.currentTimeMillis() + } + } catch (e: Exception) { + System.currentTimeMillis() + } + val newEvent = TimelineEvent( id = eventId, tripId = tripId, type = type, - timestamp = System.currentTimeMillis(), // Mock timestamp for now + timestamp = parsedTime, title = title.ifEmpty { "New Event" }, location = location.ifEmpty { "Unknown Location" }, description = null, @@ -375,8 +394,8 @@ constructor( eventId = eventId, name = extraFields["hotelName"] ?: "", address = location, - checkInTime = System.currentTimeMillis(), - checkOutTime = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1), + checkInTime = parsedTime, + checkOutTime = parsedTime + TimeUnit.DAYS.toMillis(1), ) eventDao.insertHotelDetail(detail) } @@ -386,7 +405,7 @@ constructor( eventId = eventId, restaurantName = extraFields["restaurantName"] ?: "", address = location, - reservationTime = System.currentTimeMillis(), + reservationTime = parsedTime, partySize = DEFAULT_PARTY_SIZE, ) eventDao.insertDiningDetail(detail) diff --git a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/AddEventDialog.kt b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/AddEventDialog.kt index 62ac748a..d8869fe9 100644 --- a/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/AddEventDialog.kt +++ b/jetpacker/android/feature/trip/itinerary/src/main/kotlin/com/example/jetpacker/feature/itinerary/components/AddEventDialog.kt @@ -50,6 +50,7 @@ fun AddEventDialog( title: String, location: String, time: String, + date: String, extraFields: Map, ) -> Unit, ) { @@ -57,6 +58,7 @@ fun AddEventDialog( var title by remember { mutableStateOf("") } var location by remember { mutableStateOf("") } var time by remember { mutableStateOf("") } + var date by remember { mutableStateOf("") } // Extra fields based on type var airline by remember { mutableStateOf("") } @@ -99,6 +101,11 @@ fun AddEventDialog( onValueChange = { location = it }, label = { Text("Location") }, ) + OutlinedTextField( + value = date, + onValueChange = { date = it }, + label = { Text("Date (e.g. Dec 15, 2026)") }, + ) OutlinedTextField( value = time, onValueChange = { time = it }, @@ -154,7 +161,7 @@ fun AddEventDialog( } else -> {} } - onSave(type, title, location, time, extraFields) + onSave(type, title, location, time, date, extraFields) onDismiss() } ) {